diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php new file mode 100644 index 0000000..5445230 --- /dev/null +++ b/lib/ImportExport/AbstractImportExport.php @@ -0,0 +1,167 @@ +exists($user)) { + throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]); + } + // first extract useful information from the input + list($feeds, $folders) = $this->parse($data, $flat); + $folderMap = []; + foreach ($folders as $f) { + // check to make sure folder names are all valid + if (!strlen(trim($f['name']))) { + throw new Exception("invalidFolderName"); + } + // check for duplicates + if (!isset($folderMap[$f['parent']])) { + $folderMap[$f['parent']] = []; + } + if (isset($folderMap[$f['parent']][$f['name']])) { + throw new Exception("invalidFolderCopy"); + } else { + $folderMap[$f['parent']][$f['name']] = true; + } + } + // get feed IDs for each URL, adding feeds where necessary + foreach ($feeds as $k => $f) { + $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); + } + // start a transaction for atomic rollback + $tr = Arsse::$db->begin(); + // get current state of database + $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); + $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); + $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); + // reconcile folders + $folderMap = [0 => 0]; + foreach ($folders as $id => $f) { + $parent = $folderMap[$f['parent']]; + // find a match for the import folder in the existing folders + foreach ($foldersDb as $db) { + if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { + $folderMap[$id] = (int) $db['id']; + break; + } + } + if (!isset($folderMap[$id])) { + // if no existing folder exists, add one + $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); + } + } + // process newsfeed subscriptions + $feedMap = []; + $tagMap = []; + foreach ($feeds as $f) { + $folder = $folderMap[$f['folder']]; + $title = strlen(trim($f['title'])) ? $f['title'] : null; + $found = false; + // find a match for the import feed is existing subscriptions + foreach ($feedsDb as $db) { + if ((int) $db['feed'] == $f['id']) { + $found = true; + $feedMap[$f['id']] = (int) $db['id']; + break; + } + } + if (!$found) { + // if no subscription exists, add one + $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); + } + if (!$found || $replace) { + // set the subscription's properties, if this is a new feed or we're doing a full replacement + Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); + // compile the set of used tags, if this is a new feed or we're doing a full replacement + foreach ($f['tags'] as $t) { + if (!strlen(trim($t))) { + // ignore any blank tags + continue; + } + if (!isset($tagMap[$t])) { + // populate the tag map + $tagMap[$t] = []; + } + $tagMap[$t][] = $f['id']; + } + } + } + // set tags + $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; + foreach ($tagMap as $tag => $subs) { + // make sure the tag exists + $found = false; + foreach ($tagsDb as $db) { + if ($tag === $db['name']) { + $found = true; + break; + } + } + if (!$found) { + // add the tag if it wasn't found + Arsse::$db->tagAdd($user, ['name' => $tag]); + } + Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); + } + // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import + if ($replace) { + foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { + try { + Arsse::$db->subscriptionRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { + try { + Arsse::$db->folderRemove($user, $id); + } catch (InputException $e) { + // ignore errors + } + } + foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { + try { + Arsse::$db->tagRemove($user, $id, true); + } catch (InputException $e) { + // ignore errors + } + } + } + $tr->commit(); + return true; + } + + abstract public function parse(string $data, bool $flat): array; + + abstract public function export(string $user, bool $flat = false): string; + + public function exportFile(string $file, string $user, bool $flat = false): bool { + $data = $this->export($user, $flat); + if (!@file_put_contents($file, $data)) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return true; + } + + public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { + $data = @file_get_contents($file); + if ($data === false) { + // if it fails throw an exception + $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; + throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", get_class($this))]); + } + return $this->import($user, $data, $flat, $replace); + } +} diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php index 5c633b4..9225269 100644 --- a/lib/ImportExport/OPML.php +++ b/lib/ImportExport/OPML.php @@ -7,137 +7,9 @@ declare(strict_types=1); namespace JKingWeb\Arsse\ImportExport; use JKingWeb\Arsse\Arsse; -use JKingWeb\Arsse\Database; -use JKingWeb\Arsse\Db\ExceptionInput as InputException; use JKingWeb\Arsse\User\Exception as UserException; -class OPML { - public function import(string $user, string $opml, bool $flat = false, bool $replace = false): bool { - // first extract useful information from the input - list($feeds, $folders) = $this->parse($opml, $flat); - $folderMap = []; - foreach ($folders as $f) { - // check to make sure folder names are all valid - if (!strlen(trim($f['name']))) { - throw new Exception("invalidFolderName"); - } - // check for duplicates - if (!isset($folderMap[$f['parent']])) { - $folderMap[$f['parent']] = []; - } - if (isset($folderMap[$f['parent']][$f['name']])) { - throw new Exception("invalidFolderCopy"); - } else { - $folderMap[$f['parent']][$f['name']] = true; - } - } - // get feed IDs for each URL, adding feeds where necessary - foreach ($feeds as $k => $f) { - $feeds[$k]['id'] = Arsse::$db->feedAdd(($f['url'])); - } - // start a transaction for atomic rollback - $tr = Arsse::$db->begin(); - // get current state of database - $foldersDb = iterator_to_array(Arsse::$db->folderList($user)); - $feedsDb = iterator_to_array(Arsse::$db->subscriptionList($user)); - $tagsDb = iterator_to_array(Arsse::$db->tagList($user)); - // reconcile folders - $folderMap = [0 => 0]; - foreach ($folders as $id => $f) { - $parent = $folderMap[$f['parent']]; - // find a match for the import folder in the existing folders - foreach ($foldersDb as $db) { - if ((int) $db['parent'] == $parent && $db['name'] === $f['name']) { - $folderMap[$id] = (int) $db['id']; - break; - } - } - if (!isset($folderMap[$id])) { - // if no existing folder exists, add one - $folderMap[$id] = Arsse::$db->folderAdd($user, ['name' => $f['name'], 'parent' -> $parent]); - } - } - // process newsfeed subscriptions - $feedMap = []; - $tagMap = []; - foreach ($feeds as $f) { - $folder = $folderMap[$f['folder']]; - $title = strlen(trim($f['title'])) ? $f['title'] : null; - $found = false; - // find a match for the import feed is existing subscriptions - foreach ($feedsDb as $db) { - if ((int) $db['feed'] == $f['id']) { - $found = true; - $feedMap[$f['id']] = (int) $db['id']; - break; - } - } - if (!$found) { - // if no subscription exists, add one - $feedMap[$f['id']] = Arsse::$db->subscriptionAdd($user, $f['url']); - } - if (!$found || $replace) { - // set the subscription's properties, if this is a new feed or we're doing a full replacement - Arsse::$db->subscriptionPropertiesSet($user, $feedMap[$f['id']], ['title' => $title, 'folder' => $folder]); - // compile the set of used tags, if this is a new feed or we're doing a full replacement - foreach ($f['tags'] as $t) { - if (!strlen(trim($t))) { - // ignore any blank tags - continue; - } - if (!isset($tagMap[$t])) { - // populate the tag map - $tagMap[$t] = []; - } - $tagMap[$t][] = $f['id']; - } - } - } - // set tags - $mode = $replace ? Database::ASSOC_REPLACE : Database::ASSOC_ADD; - foreach ($tagMap as $tag => $subs) { - // make sure the tag exists - $found = false; - foreach ($tagsDb as $db) { - if ($tag === $db['name']) { - $found = true; - break; - } - } - if (!$found) { - // add the tag if it wasn't found - Arsse::$db->tagAdd($user, ['name' => $tag]); - } - Arsse::$db->tagSubscriptionsSet($user, $tag, $subs, $mode, true); - } - // finally, if we're performing a replacement, delete any subscriptions, folders, or tags which were not present in the import - if ($replace) { - foreach (array_diff(array_column($feedsDb, "id"), $feedMap) as $id) { - try { - Arsse::$db->subscriptionRemove($user, $id); - } catch (InputException $e) { - // ignore errors - } - } - foreach (array_diff(array_column($foldersDb, "id"), $folderMap) as $id) { - try { - Arsse::$db->folderRemove($user, $id); - } catch (InputException $e) { - // ignore errors - } - } - foreach (array_diff(array_column($tagsDb, "name"), array_keys($tagMap)) as $id) { - try { - Arsse::$db->tagRemove($user, $id, true); - } catch (InputException $e) { - // ignore errors - } - } - } - $tr->commit(); - return true; - } - +class OPML extends AbstractImportExport { public function parse(string $opml, bool $flat): array { $d = new \DOMDocument; if (!@$d->loadXML($opml)) { @@ -276,24 +148,4 @@ class OPML { // return the serialization return $document->saveXML(); } - - public function exportFile(string $file, string $user, bool $flat = false): bool { - $data = $this->export($user, $flat); - if (!@file_put_contents($file, $data)) { - // if it fails throw an exception - $err = file_exists($file) ? "fileUnwritable" : "fileUncreatable"; - throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); - } - return true; - } - - public function importFile(string $file, string $user, bool $flat = false, bool $replace): bool { - $data = @file_get_contents($file); - if ($data === false) { - // if it fails throw an exception - $err = file_exists($file) ? "fileUnreadable" : "fileMissing"; - throw new Exception($err, ['file' => $file, 'format' => str_replace(__NAMESPACE__."\\", "", __CLASS__)]); - } - return $this->import($user, $data, $flat, $replace); - } } diff --git a/tests/cases/ImportExport/TestOPMLFile.php b/tests/cases/ImportExport/TestFile.php similarity index 85% rename from tests/cases/ImportExport/TestOPMLFile.php rename to tests/cases/ImportExport/TestFile.php index 35147ef..be2ebef 100644 --- a/tests/cases/ImportExport/TestOPMLFile.php +++ b/tests/cases/ImportExport/TestFile.php @@ -10,24 +10,24 @@ use JKingWeb\Arsse\ImportExport\OPML; use JKingWeb\Arsse\ImportExport\Exception; use org\bovigo\vfs\vfsStream; -/** @covers \JKingWeb\Arsse\ImportExport\OPML */ -class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { +/** @covers \JKingWeb\Arsse\ImportExport\AbstractImportExport */ +class TestFile extends \JKingWeb\Arsse\Test\AbstractTest { protected $vfs; protected $path; - protected $opml; + protected $proc; public function setUp() { self::clearData(); // create a mock OPML processor with stubbed underlying import/export routines - $this->opml = \Phake::partialMock(OPML::class); - \Phake::when($this->opml)->export->thenReturn("OPML_FILE"); - \Phake::when($this->opml)->import->thenReturn(true); + $this->proc = \Phake::partialMock(OPML::class); + \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE"); + \Phake::when($this->proc)->import->thenReturn(true); $this->vfs = vfsStream::setup("root", null, [ 'exportGoodFile' => "", 'exportGoodDir' => [], 'exportBadFile' => "", 'exportBadDir' => [], - 'importGoodFile' => "", + 'importGoodFile' => "GOOD_FILE", 'importBadFile' => "", ]); $this->path = $this->vfs->url()."/"; @@ -40,7 +40,7 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { public function tearDown() { $this->path = null; $this->vfs = null; - $this->opml = null; + $this->proc = null; self::clearData(); } @@ -50,13 +50,13 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - $this->opml->exportFile($path, $user, $flat); + $this->proc->exportFile($path, $user, $flat); } else { - $this->assertSame($exp, $this->opml->exportFile($path, $user, $flat)); - $this->assertSame("OPML_FILE", $this->vfs->getChild($file)->getContent()); + $this->assertSame($exp, $this->proc->exportFile($path, $user, $flat)); + $this->assertSame("EXPORT_FILE", $this->vfs->getChild($file)->getContent()); } } finally { - \Phake::verify($this->opml)->export($user, $flat); + \Phake::verify($this->proc)->export($user, $flat); } } @@ -89,12 +89,12 @@ class TestOPMLFile extends \JKingWeb\Arsse\Test\AbstractTest { try { if ($exp instanceof \JKingWeb\Arsse\AbstractException) { $this->assertException($exp); - $this->opml->importFile($path, $user, $flat, $replace); + $this->proc->importFile($path, $user, $flat, $replace); } else { - $this->assertSame($exp, $this->opml->importFile($path, $user, $flat, $replace)); + $this->assertSame($exp, $this->proc->importFile($path, $user, $flat, $replace)); } } finally { - \Phake::verify($this->opml, \Phake::times((int) ($exp === true)))->import($user, "", $flat, $replace); + \Phake::verify($this->proc, \Phake::times((int) ($exp === true)))->import($user, "GOOD_FILE", $flat, $replace); } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 6ad94f3..fb753f3 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -7,7 +7,8 @@ convertWarningsToExceptions="false" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" - stopOnError="true"> + forceCoversAnnotation="true" +> @@ -114,8 +115,8 @@ cases/CLI/TestCLI.php + cases/ImportExport/TestFile.php cases/ImportExport/TestOPML.php - cases/ImportExport/TestOPMLFile.php