diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6864732..5845aa110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Version counting is based on semantic versioning (Major.Feature.Patch) * Fix rating context menu in the grid view. * Add reset rating to the comic context menu. * Add support for renaming folders inside the app. This preserves the folder and subfolders state (completed, read, dates, etc.) rather than creating a new folder like updating the library does if you rename the folder directly on the file system. +* Add organizing fuctionalities for renaming files and create folder structures based on metadata. Highly experimental. ### WebUI * Add per-library search. diff --git a/CMakeLists.txt b/CMakeLists.txt index 577568a62..00a529b43 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,6 +173,7 @@ add_subdirectory(YACReaderLibrary/server) if(NOT BUILD_SERVER_STANDALONE) add_subdirectory(YACReaderLibrary/comic_vine) + add_subdirectory(YACReaderLibrary/organize_files) endif() # Always add YACReaderLibrary: defines library_common and db_helper (shared with server) diff --git a/YACReaderLibrary/CMakeLists.txt b/YACReaderLibrary/CMakeLists.txt index 178713527..47d4c6b8f 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -109,12 +109,6 @@ qt_add_executable(YACReaderLibrary WIN32 add_library_dialog.cpp rename_library_dialog.h rename_library_dialog.cpp - organize_files_dialog.h - organize_files_dialog.cpp - organize_files_coordinator.h - organize_files_coordinator.cpp - organize_files_preview_dialog.h - organize_files_preview_dialog.cpp properties_dialog.h properties_dialog.cpp options_dialog.h @@ -252,6 +246,7 @@ set(yacreaderlibrary_image_files ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/getInfo.svg ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/hideComicFlow.svg ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/openInYACReader.svg + ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/organize.svg ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/selectAll.svg ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/setReadButton.svg ${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/setUnread.svg @@ -515,6 +510,7 @@ qt_add_translations(YACReaderLibrary custom_widgets_library shortcuts_library comic_vine + organize_files # Keep extraction scoped to targets used by this app and add the QML files # directly so qsTr() strings in QML are collected too. TS_FILES @@ -558,6 +554,7 @@ target_link_libraries(YACReaderLibrary PRIVATE shortcuts_library server comic_vine + organize_files cbx_backend concurrent_queue worker diff --git a/YACReaderLibrary/db_helper.cpp b/YACReaderLibrary/db_helper.cpp index 9924fc82d..26f9e751c 100644 --- a/YACReaderLibrary/db_helper.cpp +++ b/YACReaderLibrary/db_helper.cpp @@ -24,6 +24,7 @@ #include #include +#include using namespace YACReader; @@ -1458,6 +1459,152 @@ bool DBHelper::renameFolder(qulonglong id, const QString &name, const QString &o return execute(updateComics); } +bool DBHelper::moveComic(qulonglong comicId, qulonglong newParentId, const QString &newFileName, const QString &newRelativePath, QSqlDatabase &db) +{ + QSqlQuery query(db); + query.prepare("UPDATE comic SET parentId = :parentId, fileName = :fileName, path = :path WHERE id = :id"); + query.bindValue(":parentId", newParentId); + query.bindValue(":fileName", newFileName); + query.bindValue(":path", newRelativePath); + query.bindValue(":id", comicId); + + return query.exec() && query.numRowsAffected() == 1; +} + +qulonglong DBHelper::ensureFolderPath(const QString &relativePath, QSqlDatabase &db, QList *createdFolderIds) +{ + const auto segments = relativePath.split('/', Qt::SkipEmptyParts); + + qulonglong parentId = 1; + auto inheritedType = DBHelper::loadFolder(parentId, db).type; + QString currentPath; + + for (const auto &segment : segments) { + currentPath += '/' + segment; + + const auto existing = DBHelper::loadFolder(segment, parentId, db); + if (existing.knownId) { + parentId = existing.id; + inheritedType = existing.type; + continue; + } + + Folder folder(segment, currentPath); + folder.setFather(parentId); + folder.type = inheritedType; + + parentId = DBHelper::insert(&folder, db); + if (createdFolderIds != nullptr) + createdFolderIds->append(parentId); + } + + return parentId; +} + +void DBHelper::syncFolderAddedFromContents(const QList &folderIds, QSqlDatabase &db) +{ + QSqlQuery query(db); + query.prepare("UPDATE folder SET added = COALESCE(" + "(SELECT MIN(ci.added) FROM comic c INNER JOIN comic_info ci ON c.comicInfoId = ci.id WHERE c.parentId = folder.id), added) " + "WHERE id = :id"); + + for (const auto id : folderIds) { + query.bindValue(":id", id); + if (!query.exec()) + QLOG_ERROR() << "syncFolderAddedFromContents: update failed for folder" << id << query.lastError().text(); + } +} + +void DBHelper::removeEmptyFolderPaths(const QStringList &relativePaths, QSqlDatabase &db, QList *removedRows) +{ + QSqlQuery select(db); + select.prepare("SELECT * FROM folder WHERE path = :path AND id <> 1" + " AND NOT EXISTS (SELECT 1 FROM comic WHERE comic.parentId = folder.id)" + " AND NOT EXISTS (SELECT 1 FROM folder AS child WHERE child.parentId = folder.id)"); + + QSqlQuery remove(db); + remove.prepare("DELETE FROM folder WHERE id = :id"); + + for (const auto &path : relativePaths) { + select.bindValue(":path", path); + if (!select.exec()) { + QLOG_ERROR() << "removeEmptyFolderPaths: select failed for" << path << select.lastError().text(); + continue; + } + + if (!select.next()) + continue; + + const auto record = select.record(); + + QVariantMap row; + for (int i = 0; i < record.count(); ++i) + row.insert(record.fieldName(i), record.value(i)); + + remove.bindValue(":id", row.value(QStringLiteral("id"))); + if (!remove.exec()) { + QLOG_ERROR() << "removeEmptyFolderPaths: delete failed for" << path << remove.lastError().text(); + continue; + } + + if (removedRows != nullptr) + removedRows->append(row); + } +} + +void DBHelper::removeEmptyFolderRows(const QList &folderIds, QSqlDatabase &db) +{ + QSqlQuery remove(db); + remove.prepare("DELETE FROM folder WHERE id = :id AND id <> 1" + " AND NOT EXISTS (SELECT 1 FROM comic WHERE comic.parentId = folder.id)" + " AND NOT EXISTS (SELECT 1 FROM folder AS child WHERE child.parentId = folder.id)"); + + for (const auto id : folderIds) { + remove.bindValue(":id", id); + if (!remove.exec()) + QLOG_ERROR() << "removeEmptyFolderRows: delete failed for folder" << id << remove.lastError().text(); + } +} + +bool DBHelper::restoreFolderRows(const QList &rows, QSqlDatabase &db) +{ + // A child cannot be inserted before its parent, because parentId is a foreign + // key into the same table. + auto ordered = rows; + std::sort(ordered.begin(), ordered.end(), [](const QVariantMap &a, const QVariantMap &b) { + return a.value(QStringLiteral("path")).toString().count(QLatin1Char('/')) < b.value(QStringLiteral("path")).toString().count(QLatin1Char('/')); + }); + + bool success = true; + + for (const auto &row : std::as_const(ordered)) { + if (row.value(QStringLiteral("id")).toULongLong() == 0) + continue; + + QStringList columns; + QStringList placeholders; + for (auto it = row.constBegin(); it != row.constEnd(); ++it) { + columns << it.key(); + placeholders << QLatin1Char(':') + it.key(); + } + + QSqlQuery insert(db); + insert.prepare(QStringLiteral("INSERT OR IGNORE INTO folder (%1) VALUES (%2)") + .arg(columns.join(QStringLiteral(", ")), placeholders.join(QStringLiteral(", ")))); + + for (auto it = row.constBegin(); it != row.constEnd(); ++it) + insert.bindValue(QLatin1Char(':') + it.key(), it.value()); + + if (!insert.exec()) { + QLOG_ERROR() << "restoreFolderRows: insert failed for" + << row.value(QStringLiteral("path")).toString() << insert.lastError().text(); + success = false; + } + } + + return success; +} + // inserts qulonglong DBHelper::insert(Folder *folder, QSqlDatabase &db) { diff --git a/YACReaderLibrary/db_helper.h b/YACReaderLibrary/db_helper.h index a5407a4d7..febd4d92b 100644 --- a/YACReaderLibrary/db_helper.h +++ b/YACReaderLibrary/db_helper.h @@ -83,6 +83,12 @@ class DBHelper static void renameLabel(qulonglong id, const QString &name, QSqlDatabase &db); static void renameList(qulonglong id, const QString &name, QSqlDatabase &db); static bool renameFolder(qulonglong id, const QString &name, const QString &oldPath, const QString &newPath, QSqlDatabase &db, QString *error = nullptr); + static bool moveComic(qulonglong comicId, qulonglong newParentId, const QString &newFileName, const QString &newRelativePath, QSqlDatabase &db); + static qulonglong ensureFolderPath(const QString &relativePath, QSqlDatabase &db, QList *createdFolderIds = nullptr); + static void syncFolderAddedFromContents(const QList &folderIds, QSqlDatabase &db); + static void removeEmptyFolderPaths(const QStringList &relativePaths, QSqlDatabase &db, QList *removedRows = nullptr); + static bool restoreFolderRows(const QList &rows, QSqlDatabase &db); + static void removeEmptyFolderRows(const QList &folderIds, QSqlDatabase &db); static void reasignOrderToSublists(QList ids, QSqlDatabase &db); static void reasignOrderToComicsInFavorites(QList comicIds, QSqlDatabase &db); static void reasignOrderToComicsInLabel(qulonglong labelId, QList comicIds, QSqlDatabase &db); diff --git a/YACReaderLibrary/feature_flags.h b/YACReaderLibrary/feature_flags.h index 275b312c1..270c5906f 100644 --- a/YACReaderLibrary/feature_flags.h +++ b/YACReaderLibrary/feature_flags.h @@ -5,7 +5,7 @@ namespace YACReader::FeatureFlags { // The file organization workflow is still experimental. Keep its actions out // of menus and shortcut management until the feature is ready for production. -inline constexpr bool organizeFiles = false; +inline constexpr bool organizeFiles = true; } // namespace YACReader::FeatureFlags diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index 63ba1ff97..cfaeea9c3 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -13,6 +13,7 @@ #include "edit_shortcuts_dialog.h" #include "export_comics_info_dialog.h" #include "export_library_dialog.h" +#include "feature_flags.h" #include "folder_item.h" #include "folder_management_coordinator.h" #include "folder_model.h" @@ -267,6 +268,12 @@ void LibraryWindow::setupUI() void LibraryWindow::applyTheme(const Theme &theme) { editInfoToolBar->setStyleSheet(theme.comicsViewToolbar.toolbarQSS); + // Both menu buttons carry their own icon, because neither has a default action + // to take one from. See createMenuToolButton(). + if (organizeToolButton != nullptr) + organizeToolButton->setIcon(theme.comicsViewToolbar.organizeIcon); + if (setTypeToolButton != nullptr) + setTypeToolButton->setIcon(theme.comicsViewToolbar.setAsNormalIcon); mainSplitter->setStyleSheet(theme.contentSplitter.horizontalSplitterQSS); // Update main toolbar and comics view toolbar icons @@ -425,12 +432,19 @@ void LibraryWindow::setupCoordinators() comicsModel, foldersModel, [this] { return getSelectedComics(); }, - [this] { return getCurrentFolderIndex(); }, + [this] { + // A search shows comics from the whole library while the folder + // tree keeps its old selection. That folder says nothing about the + // results, so the base is the library root, forced. + if (librarySearchCoordinator != nullptr && librarySearchCoordinator->isSearching()) + return QModelIndex(); + return getCurrentFolderIndex(); + }, [this] { const auto libraryName = selectedLibrary->currentText(); return OrganizeFilesCoordinator::LibraryContext { static_cast(libraries.getId(libraryName)), libraries.getPath(libraryName) }; }); - connect(organizeFilesCoordinator, &OrganizeFilesCoordinator::currentSourceReloadRequested, this, &LibraryWindow::reloadCurrentFolderComicsContent); + connect(organizeFilesCoordinator, &OrganizeFilesCoordinator::libraryContentChanged, this, &LibraryWindow::reloadCurrentLibrary); comicManagementCoordinator = new ComicManagementCoordinator( this, settings, @@ -548,7 +562,6 @@ void LibraryWindow::setupCoordinators() connect(noLibrariesWidget, &NoLibrariesWidget::createNewLibrary, libraryManagementCoordinator, &LibraryManagementCoordinator::showCreateLibraryDialog); connect(noLibrariesWidget, &NoLibrariesWidget::addExistingLibrary, libraryManagementCoordinator, &LibraryManagementCoordinator::showAddLibraryDialog); connect(libraryDatabaseMaintenanceCoordinator, &LibraryDatabaseMaintenanceCoordinator::libraryReloadRequested, libraryManagementCoordinator, &LibraryManagementCoordinator::loadLibrary); - connect(organizeFilesCoordinator, &OrganizeFilesCoordinator::folderRefreshRequested, libraryManagementCoordinator, &LibraryManagementCoordinator::updateFolder); connect(comicManagementCoordinator, &ComicManagementCoordinator::importRequested, libraryManagementCoordinator, [this](qulonglong folderId) { libraryManagementCoordinator->updateFolder(foldersModel->getIndexFromFolderId(folderId)); }); @@ -634,6 +647,29 @@ bool LibraryWindow::hasLoadedLibraryModels() const listsModelProxy->sourceModel() == listsModel; } +namespace { + +QToolButton *createMenuToolButton(const QList &entries, const QString &toolTip) +{ + Q_ASSERT(!entries.isEmpty()); + + auto button = new QToolButton(); + for (auto *entry : entries) + button->addAction(entry); + + button->setPopupMode(QToolButton::InstantPopup); + button->setToolTip(toolTip); + + auto *first = entries.first(); + const auto followFirstEntry = [button, first] { button->setEnabled(first->isEnabled()); }; + QObject::connect(first, &QAction::changed, button, followFirstEntry); + followFirstEntry(); + + return button; +} + +} + void LibraryWindow::createToolBars() { @@ -690,6 +726,11 @@ void LibraryWindow::createToolBars() editInfoToolBar->addAction(actions.openComicAction); editInfoToolBar->addSeparator(); editInfoToolBar->addAction(actions.editSelectedComicsAction); + if (YACReader::FeatureFlags::organizeFiles) { + organizeToolButton = createMenuToolButton({ actions.renameComicsFilesAction, actions.organizeComicsFilesAction }, + tr("Rename or organize files")); + editInfoToolBar->addWidget(organizeToolButton); + } editInfoToolBar->addAction(actions.getInfoAction); editInfoToolBar->addAction(actions.asignOrderAction); @@ -706,14 +747,10 @@ void LibraryWindow::createToolBars() editInfoToolBar->addSeparator(); - auto setTypeToolButton = new QToolButton(); - setTypeToolButton->addAction(actions.setNormalAction); - setTypeToolButton->addAction(actions.setMangaAction); - setTypeToolButton->addAction(actions.setWesternMangaAction); - setTypeToolButton->addAction(actions.setWebComicAction); - setTypeToolButton->addAction(actions.setYonkomaAction); - setTypeToolButton->setPopupMode(QToolButton::InstantPopup); - setTypeToolButton->setDefaultAction(actions.setNormalAction); + setTypeToolButton = createMenuToolButton({ actions.setNormalAction, actions.setMangaAction, + actions.setWesternMangaAction, actions.setWebComicAction, + actions.setYonkomaAction }, + tr("Set the type of the selected comics")); editInfoToolBar->addWidget(setTypeToolButton); editInfoToolBar->addSeparator(); diff --git a/YACReaderLibrary/library_window.h b/YACReaderLibrary/library_window.h index 08d844b0b..5e92a50a5 100644 --- a/YACReaderLibrary/library_window.h +++ b/YACReaderLibrary/library_window.h @@ -73,6 +73,7 @@ class EmptySpecialListWidget; class EmptyReadingListWidget; class RecentVisibilityCoordinator; class OrganizeFilesCoordinator; +class QToolButton; class ComicManagementCoordinator; class ReadingListManagementCoordinator; class FolderManagementCoordinator; @@ -153,6 +154,8 @@ class LibraryWindow : public QMainWindow, protected Themable QToolBar *treeActions; QToolBar *comicsToolBar; QToolBar *editInfoToolBar; + QToolButton *organizeToolButton = nullptr; + QToolButton *setTypeToolButton = nullptr; QList comicToolbarEntries; QAction *comicToolbarEndAnchor = nullptr; diff --git a/YACReaderLibrary/library_window_actions.cpp b/YACReaderLibrary/library_window_actions.cpp index 0fabad90a..6925dc20d 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -240,9 +240,17 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti openContainingFolderAction->setData(OPEN_CONTAINING_FOLDER_ACTION_YL); openContainingFolderAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(OPEN_CONTAINING_FOLDER_ACTION_YL)); + renameFilesAction = new QAction(window); + renameFilesAction->setText(tr("Rename files...")); + renameFilesAction->setVisible(YACReader::FeatureFlags::organizeFiles); + renameFilesAction->setData(RENAME_FILES_ACTION_YL); + renameFilesAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(RENAME_FILES_ACTION_YL)); + organizeFilesAction = new QAction(window); - organizeFilesAction->setText(tr("Organize files")); + organizeFilesAction->setText(tr("Organize into folders...")); organizeFilesAction->setVisible(YACReader::FeatureFlags::organizeFiles); + organizeFilesAction->setData(ORGANIZE_FILES_ACTION_YL); + organizeFilesAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(ORGANIZE_FILES_ACTION_YL)); setFolderAsNotCompletedAction = new QAction(window); setFolderAsNotCompletedAction->setText(tr("Set as uncompleted")); @@ -306,9 +314,17 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti openContainingFolderComicAction->setData(OPEN_CONTAINING_FOLDER_COMIC_ACTION_YL); openContainingFolderComicAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(OPEN_CONTAINING_FOLDER_COMIC_ACTION_YL)); + renameComicsFilesAction = new QAction(window); + renameComicsFilesAction->setText(tr("Rename files...")); + renameComicsFilesAction->setVisible(YACReader::FeatureFlags::organizeFiles); + renameComicsFilesAction->setData(RENAME_COMICS_FILES_ACTION_YL); + renameComicsFilesAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(RENAME_COMICS_FILES_ACTION_YL)); + organizeComicsFilesAction = new QAction(window); - organizeComicsFilesAction->setText(tr("Organize files")); + organizeComicsFilesAction->setText(tr("Organize into folders...")); organizeComicsFilesAction->setVisible(YACReader::FeatureFlags::organizeFiles); + organizeComicsFilesAction->setData(ORGANIZE_COMICS_FILES_ACTION_YL); + organizeComicsFilesAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(ORGANIZE_COMICS_FILES_ACTION_YL)); resetComicRatingAction = new QAction(window); resetComicRatingAction->setText(tr("Reset rating")); @@ -421,8 +437,10 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti // actions not asigned to any widget window->addAction(saveCoversToAction); window->addAction(openContainingFolderAction); - if (YACReader::FeatureFlags::organizeFiles) + if (YACReader::FeatureFlags::organizeFiles) { + window->addAction(renameFilesAction); window->addAction(organizeFilesAction); + } window->addAction(updateCurrentFolderAction); window->addAction(resetComicRatingAction); window->addAction(setFolderAsCompletedAction); @@ -439,8 +457,10 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti window->addAction(deleteMetadataAction); window->addAction(rescanXMLFromCurrentFolderAction); window->addAction(openContainingFolderComicAction); - if (YACReader::FeatureFlags::organizeFiles) + if (YACReader::FeatureFlags::organizeFiles) { + window->addAction(renameComicsFilesAction); window->addAction(organizeComicsFilesAction); + } #ifndef Q_OS_MACOS window->addAction(toggleFullScreenAction); #endif @@ -506,8 +526,10 @@ void LibraryWindowActions::createConnections( // ContextMenus QObject::connect(openContainingFolderComicAction, &QAction::triggered, comicManagementCoordinator, &ComicManagementCoordinator::openContainingFolderOfCurrentComic); - if (YACReader::FeatureFlags::organizeFiles) + if (YACReader::FeatureFlags::organizeFiles) { + QObject::connect(renameComicsFilesAction, &QAction::triggered, organizeFilesCoordinator, &OrganizeFilesCoordinator::renameSelectedComics); QObject::connect(organizeComicsFilesAction, &QAction::triggered, organizeFilesCoordinator, &OrganizeFilesCoordinator::organizeSelectedComics); + } QObject::connect(setFolderAsNotCompletedAction, &QAction::triggered, folderManagementCoordinator, [folderManagementCoordinator] { folderManagementCoordinator->setCurrentFolderCompleted(false); }); @@ -521,8 +543,10 @@ void LibraryWindowActions::createConnections( folderManagementCoordinator->setCurrentFolderRead(false); }); QObject::connect(openContainingFolderAction, &QAction::triggered, folderManagementCoordinator, &FolderManagementCoordinator::openCurrentFolder); - if (YACReader::FeatureFlags::organizeFiles) + if (YACReader::FeatureFlags::organizeFiles) { + QObject::connect(renameFilesAction, &QAction::triggered, organizeFilesCoordinator, &OrganizeFilesCoordinator::renameCurrentFolder); QObject::connect(organizeFilesAction, &QAction::triggered, organizeFilesCoordinator, &OrganizeFilesCoordinator::organizeCurrentFolder); + } QObject::connect(setFolderCoverAction, &QAction::triggered, folderManagementCoordinator, &FolderManagementCoordinator::selectAndSetCurrentFolderCover); QObject::connect(deleteCustomFolderCoverAction, &QAction::triggered, folderManagementCoordinator, &FolderManagementCoordinator::resetCurrentFolderCover); @@ -642,6 +666,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << setMangaAction << setNormalAction << openContainingFolderComicAction + << renameComicsFilesAction << organizeComicsFilesAction << resetComicRatingAction << selectAllComicsAction @@ -650,8 +675,10 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << deleteMetadataAction << deleteComicsAction << getInfoAction; - if (!YACReader::FeatureFlags::organizeFiles) + if (!YACReader::FeatureFlags::organizeFiles) { + tmpList.removeOne(renameComicsFilesAction); tmpList.removeOne(organizeComicsFilesAction); + } editShortcutsDialog->addActionsGroup("Comics", theme.shortcutsIcons.comicsIcon, tmpList); allActions << tmpList; @@ -664,6 +691,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << expandAllNodesAction << colapseAllNodesAction << openContainingFolderAction + << renameFilesAction << organizeFilesAction << setFolderAsNotCompletedAction << setFolderAsCompletedAction @@ -675,8 +703,10 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << rescanXMLFromCurrentFolderAction << setFolderCoverAction << deleteCustomFolderCoverAction; - if (!YACReader::FeatureFlags::organizeFiles) + if (!YACReader::FeatureFlags::organizeFiles) { + tmpList.removeOne(renameFilesAction); tmpList.removeOne(organizeFilesAction); + } editShortcutsDialog->addActionsGroup("Folders", theme.shortcutsIcons.foldersIcon, tmpList); allActions << tmpList; @@ -767,6 +797,7 @@ void LibraryWindowActions::setComicSelectionActionsEnabled(bool enabled) deleteMetadataAction->setEnabled(enabled); deleteComicsAction->setEnabled(enabled); openContainingFolderComicAction->setEnabled(enabled); + renameComicsFilesAction->setEnabled(enabled); organizeComicsFilesAction->setEnabled(enabled); resetComicRatingAction->setEnabled(enabled); getInfoAction->setEnabled(enabled); @@ -807,6 +838,7 @@ void LibraryWindowActions::disableFoldersActions(bool disabled) colapseAllNodesAction->setDisabled(disabled); openContainingFolderAction->setDisabled(disabled); + renameFilesAction->setDisabled(disabled); organizeFilesAction->setDisabled(disabled); renameFolderAction->setDisabled(disabled); diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index b8cede6d3..2c896c2af 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -72,6 +72,7 @@ class LibraryWindowActions QAction *colapseAllNodesAction; QAction *openContainingFolderAction; + QAction *renameFilesAction; QAction *organizeFilesAction; QAction *saveCoversToAction; //-- @@ -91,6 +92,7 @@ class LibraryWindowActions QAction *deleteCustomFolderCoverAction; QAction *openContainingFolderComicAction; + QAction *renameComicsFilesAction; QAction *organizeComicsFilesAction; QAction *setAsReadAction; QAction *setAsNonReadAction; diff --git a/YACReaderLibrary/library_window_menus.cpp b/YACReaderLibrary/library_window_menus.cpp index 8f42eb62b..2592906b2 100644 --- a/YACReaderLibrary/library_window_menus.cpp +++ b/YACReaderLibrary/library_window_menus.cpp @@ -241,8 +241,12 @@ void LibraryWindowMenus::showComicsContextMenu(const QPoint &point, bool showFul menu->addAction(actions.saveCoversToAction); menu->addSeparator(); menu->addAction(actions.openContainingFolderComicAction); - if (YACReader::FeatureFlags::organizeFiles) + if (YACReader::FeatureFlags::organizeFiles) { + menu->addSeparator(); + menu->addAction(actions.renameComicsFilesAction); menu->addAction(actions.organizeComicsFilesAction); + menu->addSeparator(); + } menu->addAction(actions.updateCurrentFolderAction); menu->addSeparator(); menu->addAction(actions.editSelectedComicsAction); @@ -387,9 +391,12 @@ void LibraryWindowMenus::showFoldersContextMenu(const QPoint &point) QMenu menu; menu.addAction(actions.openContainingFolderAction); menu.addAction(actions.renameFolderAction); - if (YACReader::FeatureFlags::organizeFiles) - menu.addAction(actions.organizeFilesAction); menu.addAction(actions.updateFolderAction); + if (YACReader::FeatureFlags::organizeFiles) { + menu.addSeparator(); + menu.addAction(actions.renameFilesAction); + menu.addAction(actions.organizeFilesAction); + } menu.addSeparator(); menu.addAction(actions.rescanXMLFromCurrentFolderAction); menu.addSeparator(); diff --git a/YACReaderLibrary/organize_files/CMakeLists.txt b/YACReaderLibrary/organize_files/CMakeLists.txt new file mode 100644 index 000000000..43e17f36e --- /dev/null +++ b/YACReaderLibrary/organize_files/CMakeLists.txt @@ -0,0 +1,34 @@ +# File organization (rename files / organize into folders) for YACReaderLibrary + +add_library(organize_files STATIC + organize_files_plan.h + organize_files_plan.cpp + organize_files_journal.h + organize_files_journal.cpp + organize_files_worker.h + organize_files_worker.cpp + organize_files_dialog.h + organize_files_dialog.cpp + organize_files_coordinator.h + organize_files_coordinator.cpp +) +target_include_directories(organize_files PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) +yacreader_apply_build_options(organize_files) +# App-specific theme.h needed for themable.h → theme_manager.h → theme.h chain +target_include_directories(organize_files PRIVATE + ${PROJECT_SOURCE_DIR}/YACReaderLibrary/themes +) +# ComicModel and FolderModel live in the app target; their headers come from the +# db_helper include dirs, their symbols from the final app link. +target_link_libraries(organize_files PUBLIC + Qt6::Core + Qt6::Widgets + Qt6::Sql + common_all + common_gui + custom_widgets_library + db_helper + QsLog +) diff --git a/YACReaderLibrary/organize_files/organize_files_coordinator.cpp b/YACReaderLibrary/organize_files/organize_files_coordinator.cpp new file mode 100644 index 000000000..71730a6bd --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_coordinator.cpp @@ -0,0 +1,382 @@ +#include "organize_files_coordinator.h" + +#include "QsLog.h" +#include "comic_model.h" +#include "data_base_management.h" +#include "db_helper.h" +#include "folder_model.h" +#include "organize_files_dialog.h" +#include "organize_files_journal.h" +#include "yacreader_global.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using OrganizeFiles::ComicEntry; +using OrganizeFiles::FileMove; + +namespace { + +void collectComicsRecursively(qulonglong libraryId, qulonglong folderId, QList &out) +{ + const auto comics = DBHelper::getFolderComicsFromLibrary(libraryId, folderId); + for (auto *item : comics) { + if (auto *comic = static_cast(item)) + out.append(*comic); + } + qDeleteAll(comics); + + const auto subfolders = DBHelper::getFolderSubfoldersFromLibrary(libraryId, folderId); + for (auto *item : subfolders) + collectComicsRecursively(libraryId, item->id, out); + qDeleteAll(subfolders); +} + +QList buildEntries(const QList &comics, const QString &libraryRoot) +{ + QList entries; + entries.reserve(comics.size()); + + for (const ComicDB &comic : comics) { + ComicEntry entry; + entry.comicId = comic.id; + entry.sourceAbsolute = QDir::cleanPath(libraryRoot + comic.path); + + const QFileInfo info(entry.sourceAbsolute); + entry.missing = !info.exists(); + entry.baseName = info.completeBaseName(); + entry.extension = info.suffix().isEmpty() ? QString() : QLatin1Char('.') + info.suffix(); + + entry.publisher = comic.info.publisher.toString(); + entry.imprint = comic.info.imprint.toString(); + entry.series = comic.info.series.toString(); + entry.volume = comic.info.volume.toString(); + entry.number = comic.info.number.toString(); + entry.count = comic.info.count.toString(); + entry.title = comic.info.title.toString(); + entry.year = comic.info.year.toString(); + entry.month = comic.info.month.toString(); + entry.storyArc = comic.info.storyArc.toString(); + entry.arcNumber = comic.info.arcNumber.toString(); + entry.writer = comic.info.writer.toString(); + + entries.append(entry); + } + + return entries; +} + +QString libraryRelativePath(const QString &libraryRoot, const QString &absolutePath) +{ + return QLatin1Char('/') + QDir(libraryRoot).relativeFilePath(absolutePath); +} + +} + +OrganizeFilesCoordinator::OrganizeFilesCoordinator(QSettings *settings, + QWidget *window, + ComicModel *comicsModel, + FolderModel *foldersModel, + SelectionProvider selectionProvider, + CurrentFolderProvider currentFolderProvider, + CurrentLibraryProvider currentLibraryProvider) + : QObject(window), settings(settings), window(window), comicsModel(comicsModel), foldersModel(foldersModel), selectionProvider(std::move(selectionProvider)), currentFolderProvider(std::move(currentFolderProvider)), currentLibraryProvider(std::move(currentLibraryProvider)) +{ +} + +void OrganizeFilesCoordinator::renameCurrentFolder() +{ + runOnCurrentFolder(OrganizeFiles::Mode::Rename); +} + +void OrganizeFilesCoordinator::organizeCurrentFolder() +{ + runOnCurrentFolder(OrganizeFiles::Mode::Organize); +} + +void OrganizeFilesCoordinator::renameSelectedComics() +{ + runOnSelectedComics(OrganizeFiles::Mode::Rename); +} + +void OrganizeFilesCoordinator::organizeSelectedComics() +{ + runOnSelectedComics(OrganizeFiles::Mode::Organize); +} + +void OrganizeFilesCoordinator::runOnCurrentFolder(OrganizeFiles::Mode mode) +{ + const auto folderIndex = currentFolderProvider(); + if (!folderIndex.isValid()) + return; + + const auto library = currentLibraryProvider(); + const auto folder = foldersModel->getFolder(folderIndex); + const auto folderPath = QDir::cleanPath(library.rootPath + foldersModel->getFolderPath(folderIndex)); + + QApplication::setOverrideCursor(Qt::WaitCursor); + QList comics; + collectComicsRecursively(library.id, folder.id, comics); + QApplication::restoreOverrideCursor(); + + if (comics.isEmpty()) { + QMessageBox::information(window, tr("Organize files"), tr("This folder does not contain any comics.")); + return; + } + + organizeComics(mode, comics, library.rootPath, folderPath); +} + +void OrganizeFilesCoordinator::runOnSelectedComics(OrganizeFiles::Mode mode) +{ + const auto selection = selectionProvider(); + if (selection.isEmpty()) + return; + + const auto comics = comicsModel->getComics(selection); + if (comics.isEmpty()) + return; + + const auto folderIndex = currentFolderProvider(); + const auto library = currentLibraryProvider(); + const auto folderPath = folderIndex.isValid() + ? QDir::cleanPath(library.rootPath + foldersModel->getFolderPath(folderIndex)) + : QString(); + + organizeComics(mode, comics, library.rootPath, folderPath); +} + +void OrganizeFilesCoordinator::organizeComics(OrganizeFiles::Mode mode, const QList &comics, const QString &libraryRoot, const QString &folderPath) +{ + const QString cleanLibraryRoot = QDir::cleanPath(libraryRoot); + + LibraryMaintenanceLock maintenanceLock(cleanLibraryRoot); + if (!maintenanceLock.tryLock()) { + QMessageBox::warning(window, tr("Organize files"), + tr("This library is busy: %1").arg(maintenanceLock.errorString())); + return; + } + + OrganizeFilesDialog::Context context; + context.mode = mode; + context.libraryPath = cleanLibraryRoot; + const QString cleanFolderPath = folderPath.isEmpty() ? QString() : QDir::cleanPath(folderPath); + context.folderPath = cleanFolderPath == cleanLibraryRoot ? QString() : cleanFolderPath; + context.entries = buildEntries(comics, cleanLibraryRoot); + + OrganizeFilesDialog dialog(context, settings, window); + dialog.setApplier([this, cleanLibraryRoot](const QList &moves, const QStringList &removedDirectories, const QString &journalPath, QString *error) { + return applyToDatabase(moves, removedDirectories, cleanLibraryRoot, journalPath, { }, { }, error); + }); + dialog.setUndoer([this, cleanLibraryRoot](const QString &journalPath, QList *failures, QString *error, + const std::function &fileProgress, + const std::function &databasePhase) { + return undo(journalPath, cleanLibraryRoot, failures, error, fileProgress, databasePhase); + }); + + dialog.exec(); + + if (dialog.libraryChanged()) + emit libraryContentChanged(); +} + +bool OrganizeFilesCoordinator::applyToDatabase(const QList &moves, + const QStringList &removedDirectories, + const QString &libraryRoot, + const QString &journalPath, + const QList &foldersToRestore, + const QList &createdFolderIdsToRemove, + QString *error) +{ + bool success = true; + QString connectionName; + QList removedFolders; + QList createdFolders; + + { + QSqlDatabase db = DataBaseManagement::loadDatabase(YACReader::LibraryPaths::libraryDataPath(libraryRoot)); + if (!db.isOpen()) { + *error = tr("the library database could not be opened"); + return false; + } + + connectionName = db.connectionName(); + + if (!db.transaction()) { + *error = tr("the library database could not be locked for writing"); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(connectionName); + return false; + } + + // Restored before anything is repointed, so ensureFolderPath() finds the + // original rows instead of creating new ids (covers are keyed by id). + if (!foldersToRestore.isEmpty() && !DBHelper::restoreFolderRows(foldersToRestore, db)) { + *error = tr("a folder entry could not be restored"); + success = false; + } + + if (success) { + for (const auto &move : moves) { + const QString relativePath = libraryRelativePath(libraryRoot, move.destination); + const QString relativeDirectory = relativePath.left(relativePath.lastIndexOf(QLatin1Char('/'))); + const auto parentId = DBHelper::ensureFolderPath(relativeDirectory, db, &createdFolders); + + if (!DBHelper::moveComic(move.comicId, parentId, QFileInfo(move.destination).fileName(), relativePath, db)) { + *error = tr("a comic entry could not be updated"); + success = false; + break; + } + } + } + + if (success) { + QStringList removedPaths; + for (const auto &directory : removedDirectories) + removedPaths << libraryRelativePath(libraryRoot, directory); + + DBHelper::removeEmptyFolderPaths(removedPaths, db, &removedFolders); + // Undo: drop the rows the run created that are empty again. + DBHelper::removeEmptyFolderRows(createdFolderIdsToRemove, db); + DBHelper::syncFolderAddedFromContents(createdFolders, db); + DBHelper::updateChildrenInfo(db); + + if (!db.commit()) { + *error = tr("the library database could not be saved: %1").arg(db.lastError().text()); + db.rollback(); + removedFolders.clear(); + createdFolders.clear(); + success = false; + } + } else { + db.rollback(); + } + + db = QSqlDatabase(); + } + + QSqlDatabase::removeDatabase(connectionName); + + // Written only after the transaction is on disk, so the record never claims a + // folder was deleted that is still there. + if (success && (!removedFolders.isEmpty() || !createdFolders.isEmpty()) && !journalPath.isEmpty()) { + OrganizeFiles::Journal journal(libraryRoot); + if (journal.reopen(journalPath)) { + for (const auto &row : std::as_const(removedFolders)) + journal.appendRemovedFolder(row); + for (const auto id : std::as_const(createdFolders)) + journal.appendCreatedFolder(id); + journal.finish(); + } else { + // Nothing to roll back; without this record an undo recreates the + // deleted folders with new ids and their custom covers are lost. + QLOG_ERROR() << "organize: could not reopen the journal" << journalPath + << "to record the folder rows:" << journal.errorString(); + } + } + + return success; +} + +bool OrganizeFilesCoordinator::undo(const QString &journalPath, + const QString &libraryRoot, + QList *failures, + QString *error, + const std::function &fileProgress, + const std::function &databasePhase) +{ + OrganizeFiles::JournalData data; + if (!OrganizeFiles::Journal::read(libraryRoot, journalPath, &data)) { + *error = tr("the record of the last organize run could not be read"); + return false; + } + + QList restored; + + const int total = data.moves.size(); + int done = 0; + + for (int i = data.moves.size() - 1; i >= 0; --i) { + const auto &journalMove = data.moves.at(i); + + FileMove move; + move.comicId = journalMove.comicId; + move.source = OrganizeFiles::absoluteFromRelative(libraryRoot, journalMove.to); + move.destination = OrganizeFiles::absoluteFromRelative(libraryRoot, journalMove.from); + + fileProgress(++done, total, QDir(libraryRoot).relativeFilePath(move.destination)); + + // An earlier attempt already put this one back. Repoint the row again + // anyway, because that attempt may have failed after the file had moved. + const bool alreadyBack = !QFileInfo::exists(move.source) && QFileInfo::exists(move.destination); + + if (!alreadyBack) { + if (!QDir().mkpath(QFileInfo(move.destination).absolutePath())) { + failures->append({ move.source, tr("the folder %1 could not be created").arg(QDir::toNativeSeparators(QFileInfo(move.destination).absolutePath())) }); + continue; + } + + QString reason; + if (!OrganizeFiles::moveFile(move.source, move.destination, &reason)) { + failures->append({ move.source, reason }); + continue; + } + } + + restored.append(move); + } + + // A cycle in the plan was broken with a temporary name, so the journal holds two + // steps for one comic. Only the last step undone carries the original path. + QHash lastStepForComic; + for (int i = 0; i < restored.size(); ++i) + lastStepForComic.insert(restored.at(i).comicId, i); + + QList collapsed; + collapsed.reserve(restored.size()); + for (int i = 0; i < restored.size(); ++i) { + if (lastStepForComic.value(restored.at(i).comicId) == i) + collapsed.append(restored.at(i)); + } + + // Only the directories the run created; one it merely filled is not the undo's + // to delete, even when the undo leaves it empty. + QStringList createdDirectories; + for (const auto &relative : std::as_const(data.createdDirectories)) + createdDirectories << OrganizeFiles::absoluteFromRelative(libraryRoot, relative); + + const auto removedDirectories = OrganizeFiles::removeCreatedDirectories(createdDirectories); + + // Recorded parents first; reversed, a created branch deletes bottom-up. + QList createdFolderIds = data.createdFolders; + std::reverse(createdFolderIds.begin(), createdFolderIds.end()); + + if (!collapsed.isEmpty() || !data.removedFolders.isEmpty() || !createdFolderIds.isEmpty()) { + databasePhase(); + if (!applyToDatabase(collapsed, removedDirectories, libraryRoot, QString(), data.removedFolders, createdFolderIds, error)) + return false; + } + + // The journal is deleted only when every file is back; otherwise the user + // keeps a way to try again. + if (!failures->isEmpty()) { + *error = tr("%n file(s) could not be moved back", "", failures->size()); + return false; + } + + QFile::remove(journalPath); + + return true; +} diff --git a/YACReaderLibrary/organize_files_coordinator.h b/YACReaderLibrary/organize_files/organize_files_coordinator.h similarity index 54% rename from YACReaderLibrary/organize_files_coordinator.h rename to YACReaderLibrary/organize_files/organize_files_coordinator.h index a6e0c16d7..c29b1ba7d 100644 --- a/YACReaderLibrary/organize_files_coordinator.h +++ b/YACReaderLibrary/organize_files/organize_files_coordinator.h @@ -2,9 +2,11 @@ #define ORGANIZE_FILES_COORDINATOR_H #include "comic_db.h" +#include "organize_files_worker.h" #include #include +#include #include @@ -35,19 +37,33 @@ class OrganizeFilesCoordinator : public QObject CurrentLibraryProvider currentLibraryProvider); public slots: + void renameCurrentFolder(); void organizeCurrentFolder(); + void renameSelectedComics(); void organizeSelectedComics(); signals: - void folderRefreshRequested(const QModelIndex &folder); - void currentSourceReloadRequested(); + void libraryContentChanged(); private: - bool organizeFolder(qulonglong libraryId, - qulonglong folderId, - const QString &libraryRoot, - const QString &folderPath); - bool organizeComics(const QList &comics, const QString &libraryRoot, const QString &cleanupPath); + void runOnCurrentFolder(OrganizeFiles::Mode mode); + void runOnSelectedComics(OrganizeFiles::Mode mode); + void organizeComics(OrganizeFiles::Mode mode, const QList &comics, const QString &libraryRoot, const QString &folderPath); + + bool applyToDatabase(const QList &moves, + const QStringList &removedDirectories, + const QString &libraryRoot, + const QString &journalPath, + const QList &foldersToRestore, + const QList &createdFolderIdsToRemove, + QString *error); + // Runs on a worker thread; must not touch the GUI. + bool undo(const QString &journalPath, + const QString &libraryRoot, + QList *failures, + QString *error, + const std::function &fileProgress, + const std::function &databasePhase); QSettings *settings; QWidget *window; diff --git a/YACReaderLibrary/organize_files/organize_files_dialog.cpp b/YACReaderLibrary/organize_files/organize_files_dialog.cpp new file mode 100644 index 000000000..add0879be --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_dialog.cpp @@ -0,0 +1,1239 @@ +#include "organize_files_dialog.h" + +#include "organize_files_journal.h" +#include "yacreader_busy_widget.h" +#include "yacreader_global.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using OrganizeFiles::ComicEntry; +using OrganizeFiles::FileFailure; +using OrganizeFiles::FileMove; +using OrganizeFiles::PlannedMove; + +namespace { + +constexpr int SourceRole = Qt::UserRole + 1; +constexpr int ExtensionRole = Qt::UserRole + 2; +// The path a folder row had before the user renamed it. The new path is read off +// the tree, and the old one is needed to find the moves the rename applies to. +constexpr int FolderPathRole = Qt::UserRole + 3; + +// The format field is typed into, so it waits for a pause. Every other input is a +// single discrete act, so it rebuilds on the next turn of the event loop. +constexpr int PatternDebounceMs = 300; +constexpr int ImmediateMs = 0; + +constexpr int LoadingPage = 0; +constexpr int PlanPage = 1; +constexpr int WorkingPage = 2; +constexpr int ResultPage = 3; + +class SegmentEditDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + if (index.column() != 0) + return nullptr; + return QStyledItemDelegate::createEditor(parent, option, index); + } + + void setEditorData(QWidget *editor, const QModelIndex &index) const override + { + auto *lineEdit = qobject_cast(editor); + if (lineEdit == nullptr) { + QStyledItemDelegate::setEditorData(editor, index); + return; + } + + QString text = index.data(Qt::EditRole).toString(); + const QString extension = index.data(ExtensionRole).toString(); + if (!extension.isEmpty() && text.endsWith(extension)) + text.chop(extension.size()); + + lineEdit->setText(text); + } + + void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override + { + auto *lineEdit = qobject_cast(editor); + if (lineEdit == nullptr) { + QStyledItemDelegate::setModelData(editor, model, index); + return; + } + + const QString name = OrganizeFiles::sanitizeSegment(lineEdit->text()); + if (name.isEmpty()) + return; + + model->setData(index, name + index.data(ExtensionRole).toString(), Qt::EditRole); + } +}; + +// Qt has no segmented control: two checkable buttons with the border between them +// collapsed. Colours come from the palette, which is what the theme system drives. +QString segmentedStyleSheet() +{ + return QStringLiteral( + "QPushButton {" + " border: 1px solid palette(mid);" + " padding: 4px 14px;" + " background-color: palette(button);" + " color: palette(button-text);" + "}" + "QPushButton:hover { background-color: palette(midlight); }" + "QPushButton:checked {" + " background-color: palette(highlight);" + " color: palette(highlighted-text);" + " border-color: palette(highlight);" + "}" + "QPushButton#organizeBaseLeft {" + " border-top-left-radius: 4px;" + " border-bottom-left-radius: 4px;" + " border-right: none;" + "}" + "QPushButton#organizeBaseRight {" + " border-top-right-radius: 4px;" + " border-bottom-right-radius: 4px;" + "}"); +} + +bool isExecutable(PlannedMove::Status status) +{ + return status == PlannedMove::Status::Move || status == PlannedMove::Status::Renamed || status == PlannedMove::Status::Incomplete; +} + +} + +OrganizeFilesDialog::OrganizeFilesDialog(const Context &context, QSettings *settings, QWidget *parent) + : QDialog(parent), context(context), settings(settings), planThread(nullptr), planWorker(nullptr), moveThread(nullptr), moveWorker(nullptr), undoThread(nullptr), undoWorker(nullptr), generation(0), updatingTree(false), changedLibrary(false), patternIsValid(true), moveRunning(false), undoRunning(false), planIsStale(true) +{ + qRegisterMetaType>(); + qRegisterMetaType(); + + setupPages(); + setupPlanWorker(); + + setModal(true); + setWindowTitle(renaming() ? tr("Rename files") : tr("Organize files")); + resize(760, 560); + + pages->setCurrentIndex(LoadingPage); + updateBasePathLabel(); + startBuild(); +} + +OrganizeFilesDialog::~OrganizeFilesDialog() +{ + if (planThread != nullptr) { + planThread->quit(); + planThread->wait(); + delete planWorker; + planWorker = nullptr; + } + + if (moveThread != nullptr) { + moveThread->quit(); + moveThread->wait(); + delete moveWorker; + moveWorker = nullptr; + } + + if (undoThread != nullptr) { + undoThread->quit(); + undoThread->wait(); + delete undoWorker; + undoWorker = nullptr; + } +} + +void OrganizeFilesDialog::setApplier(Applier applier) +{ + this->applier = std::move(applier); +} + +void OrganizeFilesDialog::setUndoer(Undoer undoer) +{ + this->undoer = std::move(undoer); +} + +bool OrganizeFilesDialog::libraryChanged() const +{ + return changedLibrary; +} + +void OrganizeFilesDialog::setupPages() +{ + pages = new QStackedWidget; + pages->addWidget(createLoadingPage()); + pages->addWidget(createPlanPage()); + pages->addWidget(createWorkingPage()); + pages->addWidget(createResultPage()); + + auto layout = new QVBoxLayout; + layout->addWidget(pages); + setLayout(layout); +} + +QWidget *OrganizeFilesDialog::createLoadingPage() +{ + auto page = new QWidget; + auto layout = new QVBoxLayout; + + loadingLabel = new QLabel(tr("Preparing the preview...")); + loadingLabel->setAlignment(Qt::AlignHCenter); + + layout->addStretch(); + layout->addWidget(new YACReaderBusyWidget, 0, Qt::AlignHCenter); + layout->addSpacing(12); + layout->addWidget(loadingLabel); + layout->addStretch(); + + page->setLayout(layout); + return page; +} + +QWidget *OrganizeFilesDialog::createPlanPage() +{ + auto page = new QWidget; + auto layout = new QVBoxLayout; + + const QString patternKey = renaming() ? QStringLiteral(ORGANIZE_FILES_FILENAME_PATTERN) : QStringLiteral(ORGANIZE_FILES_PATH_PATTERN); + const QString fallbackPattern = OrganizeFiles::defaultPattern(context.mode); + + auto formatLabel = new QLabel(renaming() ? tr("&Filename format:") : tr("&Path format:")); + patternEdit = new QLineEdit(settings != nullptr ? settings->value(patternKey, fallbackPattern).toString() : fallbackPattern); + patternEdit->setAccessibleName(renaming() ? tr("Filename format") : tr("Path format")); + formatLabel->setBuddy(patternEdit); + connect(patternEdit, &QLineEdit::textChanged, this, &OrganizeFilesDialog::patternEdited); + + auto presetsButton = new QPushButton(tr("Presets")); + presetsButton->setAutoDefault(false); + auto presetsMenu = new QMenu(presetsButton); + const auto presets = OrganizeFiles::presets(context.mode); + for (const auto &preset : presets) { + auto action = presetsMenu->addAction(preset.first); + const QString pattern = preset.second; + connect(action, &QAction::triggered, this, [this, pattern] { patternEdit->setText(pattern); }); + } + presetsButton->setMenu(presetsMenu); + + auto insertButton = new QPushButton(tr("Insert")); + insertButton->setAutoDefault(false); + auto insertMenu = new QMenu(insertButton); + + const auto tokens = OrganizeFiles::knownTokens(); + for (const QString &token : tokens) { + auto action = insertMenu->addAction(QStringLiteral("{") + token + QStringLiteral("}")); + connect(action, &QAction::triggered, this, [this, token] { + patternEdit->insert(QStringLiteral("{") + token + QStringLiteral("}")); + patternEdit->setFocus(); + }); + } + + insertMenu->addSeparator(); + + auto optionalAction = insertMenu->addAction(tr("Optional part < >")); + optionalAction->setToolTip(tr("Disappears completely when the fields inside it are empty.")); + connect(optionalAction, &QAction::triggered, this, &OrganizeFilesDialog::wrapSelectionInOptionalGroup); + + auto paddedAction = insertMenu->addAction(tr("Padded number {number:000}")); + connect(paddedAction, &QAction::triggered, this, [this] { + patternEdit->insert(QStringLiteral("{number:000}")); + patternEdit->setFocus(); + }); + + insertMenu->addSeparator(); + connect(insertMenu->addAction(tr("Format help...")), &QAction::triggered, this, &OrganizeFilesDialog::showFormatHelp); + + insertButton->setMenu(insertMenu); + + auto formatRow = new QHBoxLayout; + formatRow->addWidget(formatLabel); + formatRow->addWidget(patternEdit, 1); + formatRow->addWidget(insertButton); + formatRow->addWidget(presetsButton); + + patternError = new QLabel; + patternError->setWordWrap(true); + patternError->setVisible(false); + + folderBaseButton = new QPushButton(tr("selected folder")); + folderBaseButton->setObjectName(QStringLiteral("organizeBaseLeft")); + folderBaseButton->setToolTip(QDir::toNativeSeparators(context.folderPath)); + + rootBaseButton = new QPushButton(tr("library root")); + rootBaseButton->setObjectName(QStringLiteral("organizeBaseRight")); + rootBaseButton->setToolTip(QDir::toNativeSeparators(context.libraryPath)); + + auto baseButtons = new QButtonGroup(this); + baseButtons->setExclusive(true); + for (auto *segment : { folderBaseButton, rootBaseButton }) { + segment->setCheckable(true); + // A QPushButton inside a QDialog claims the default-button role, which + // would let Return trigger a setting instead of Move files. + segment->setAutoDefault(false); + baseButtons->addButton(segment); + } + + baseSelector = new QWidget; + auto segmented = new QHBoxLayout(baseSelector); + segmented->setSpacing(0); + segmented->setContentsMargins(0, 0, 0, 0); + segmented->addWidget(folderBaseButton); + segmented->addWidget(rootBaseButton); + baseSelector->setStyleSheet(segmentedStyleSheet()); + + basePathLabel = new QLabel; + basePathLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + basePathLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + if (context.folderPath.isEmpty()) { + baseSelector->setVisible(false); + rootBaseButton->setChecked(true); + } else { + const bool relativeToRoot = settings != nullptr ? settings->value(ORGANIZE_FILES_RELATIVE_TO_ROOT, true).toBool() : true; + rootBaseButton->setChecked(relativeToRoot); + folderBaseButton->setChecked(!relativeToRoot); + // One click, not a stream of keystrokes, so there is nothing to wait for. + connect(rootBaseButton, &QPushButton::toggled, this, [this] { + updateBasePathLabel(); + markPlanStale(ImmediateMs); + }); + } + + auto baseRow = new QHBoxLayout; + baseRow->addWidget(new QLabel(tr("Move into"))); + baseRow->addSpacing(8); + baseRow->addWidget(baseSelector); + baseRow->addSpacing(12); + baseRow->addWidget(basePathLabel, 1); + + overridesBanner = new QLabel; + overridesBanner->setVisible(false); + + resetButton = new QPushButton(tr("Reset changes")); + resetButton->setVisible(false); + connect(resetButton, &QPushButton::clicked, this, &OrganizeFilesDialog::resetOverrides); + + removeButton = new QPushButton(tr("Remove selected")); + removeButton->setEnabled(false); + connect(removeButton, &QPushButton::clicked, this, &OrganizeFilesDialog::removeSelectedItems); + + showUnchangedCheck = new QCheckBox(tr("Show unchanged")); + if (settings != nullptr) + showUnchangedCheck->setChecked(settings->value(ORGANIZE_FILES_SHOW_UNCHANGED, false).toBool()); + connect(showUnchangedCheck, &QCheckBox::toggled, this, [this] { + rebuildTree(); + updateStatusLine(); + }); + + auto toolbar = new QHBoxLayout; + toolbar->addWidget(removeButton); + toolbar->addWidget(showUnchangedCheck); + toolbar->addStretch(); + toolbar->addWidget(overridesBanner); + toolbar->addWidget(resetButton); + + tree = new QTreeWidget; + tree->setColumnCount(3); + // The second header carries the verb: two neutral nouns side by side never say + // which way the change runs. + tree->setHeaderLabels(renaming() ? QStringList { tr("New name"), tr("Renamed from"), QString() } + : QStringList { tr("New location"), tr("Moved from"), QString() }); + tree->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::SelectedClicked | QAbstractItemView::EditKeyPressed); + tree->setItemDelegate(new SegmentEditDelegate(tree)); + tree->setUniformRowHeights(true); + tree->setAlternatingRowColors(true); + tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + tree->header()->setStretchLastSection(false); + tree->header()->setSectionResizeMode(1, QHeaderView::Stretch); + tree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + connect(tree, &QTreeWidget::itemChanged, this, &OrganizeFilesDialog::itemChanged); + connect(tree, &QTreeWidget::itemSelectionChanged, this, &OrganizeFilesDialog::updateSelectionState); + + auto removeAction = new QAction(tr("Remove from list"), this); + removeAction->setShortcut(QKeySequence::Delete); + removeAction->setShortcutContext(Qt::WidgetShortcut); + connect(removeAction, &QAction::triggered, this, &OrganizeFilesDialog::removeSelectedItems); + tree->addAction(removeAction); + tree->setContextMenuPolicy(Qt::ActionsContextMenu); + + statusLabel = new QLabel; + warningLabel = new QLabel; + warningLabel->setWordWrap(true); + + moveButton = new QPushButton(renaming() ? tr("Rename files") : tr("Move files")); + moveButton->setDefault(true); + connect(moveButton, &QPushButton::clicked, this, &OrganizeFilesDialog::startMove); + + cancelButton = new QPushButton(tr("Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + + auto buttons = new QHBoxLayout; + buttons->addWidget(statusLabel); + buttons->addStretch(); + buttons->addWidget(moveButton); + buttons->addWidget(cancelButton); + + layout->addLayout(formatRow); + layout->addWidget(patternError); + if (!renaming()) + layout->addLayout(baseRow); + layout->addLayout(toolbar); + layout->addWidget(tree, 1); + layout->addWidget(warningLabel); + layout->addLayout(buttons); + + page->setLayout(layout); + return page; +} + +QWidget *OrganizeFilesDialog::createWorkingPage() +{ + auto page = new QWidget; + auto layout = new QVBoxLayout; + + progressBar = new QProgressBar; + progressLabel = new QLabel; + progressLabel->setWordWrap(true); + progressLabel->setAlignment(Qt::AlignHCenter); + + layout->addStretch(); + layout->addWidget(progressBar); + layout->addWidget(progressLabel); + layout->addStretch(); + + page->setLayout(layout); + return page; +} + +QWidget *OrganizeFilesDialog::createResultPage() +{ + auto page = new QWidget; + auto layout = new QVBoxLayout; + + resultLabel = new QLabel; + resultLabel->setWordWrap(true); + resultLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + failureList = new QListWidget; + failureList->setVisible(false); + + copyFailuresButton = new QPushButton(tr("Copy the list")); + copyFailuresButton->setVisible(false); + connect(copyFailuresButton, &QPushButton::clicked, this, &OrganizeFilesDialog::copyFailures); + + undoButton = new QPushButton(tr("Undo")); + connect(undoButton, &QPushButton::clicked, this, &OrganizeFilesDialog::undo); + + closeButton = new QPushButton(tr("Close")); + connect(closeButton, &QPushButton::clicked, this, &QDialog::accept); + + auto buttons = new QHBoxLayout; + buttons->addWidget(copyFailuresButton); + buttons->addStretch(); + buttons->addWidget(undoButton); + buttons->addWidget(closeButton); + + layout->addWidget(resultLabel); + layout->addWidget(failureList, 1); + layout->addLayout(buttons); + + page->setLayout(layout); + return page; +} + +void OrganizeFilesDialog::setupPlanWorker() +{ + planThread = new QThread(this); + planWorker = new OrganizeFiles::PlanWorker(context.entries, currentBase(), context.mode); + planWorker->moveToThread(planThread); + + connect(this, &OrganizeFilesDialog::buildRequested, planWorker, &OrganizeFiles::PlanWorker::build); + connect(planWorker, &OrganizeFiles::PlanWorker::built, this, &OrganizeFilesDialog::planBuilt); + + planThread->start(); + + buildTimer = new QTimer(this); + buildTimer->setSingleShot(true); + connect(buildTimer, &QTimer::timeout, this, &OrganizeFilesDialog::startBuild); +} + +void OrganizeFilesDialog::markPlanStale(int delayMs) +{ + planIsStale = true; + + // Dead before the click that follows the interaction is delivered — committing + // a tree editor by clicking Move files must not run the pre-edit plan. + moveButton->setEnabled(false); + + // QTimer::start(int) also sets the interval, so the delay is always passed. + buildTimer->start(delayMs); +} + +bool OrganizeFilesDialog::renaming() const +{ + return context.mode == OrganizeFiles::Mode::Rename; +} + +QString OrganizeFilesDialog::currentBase() const +{ + if (renaming()) + return context.libraryPath; + + if (context.folderPath.isEmpty() || rootBaseButton == nullptr || rootBaseButton->isChecked()) + return context.libraryPath; + + return context.folderPath; +} + +void OrganizeFilesDialog::updateBasePathLabel() +{ + const QString path = QDir::toNativeSeparators(currentBase()); + + basePathLabel->setToolTip(path); + basePathLabel->setText(basePathLabel->fontMetrics().elidedText(path, Qt::ElideMiddle, qMax(120, basePathLabel->width()))); +} + +void OrganizeFilesDialog::patternEdited() +{ + const auto invalid = OrganizeFiles::invalidTokens(patternEdit->text()); + + const bool createsFolders = renaming() && OrganizeFiles::patternCreatesFolders(patternEdit->text()); + patternIsValid = invalid.isEmpty() && !createsFolders; + + if (!patternIsValid) { + if (createsFolders) + patternError->setText(tr("A filename format cannot contain \"/\". Use Organize files to move comics into folders.")); + else + patternError->setText(tr("This format cannot be used: %1").arg(invalid.join(QStringLiteral(" ")))); + patternError->setVisible(true); + moveButton->setEnabled(false); + // Bumping the generation drops a build still in flight, so it cannot land + // and report itself current while the format on screen is invalid. + planIsStale = true; + ++generation; + buildTimer->stop(); + return; + } + + patternError->setVisible(false); + scheduleBuild(); +} + +void OrganizeFilesDialog::scheduleBuild() +{ + markPlanStale(PatternDebounceMs); +} + +void OrganizeFilesDialog::startBuild() +{ + buildTimer->stop(); + emit buildRequested(patternEdit->text(), currentBase(), overrides, ++generation); +} + +void OrganizeFilesDialog::planBuilt(const QList &moves, quint64 buildGeneration) +{ + // An older build finishing after a newer one was asked for; the newer result + // is still on its way. + if (buildGeneration != generation) + return; + + // The user interacted again while this build was in flight (the timer has not + // fired yet, so the generation still matches). The pending rebuild covers it. + if (buildTimer->isActive()) + return; + + plan = moves; + planIsStale = false; + + planDestinations.clear(); + for (const auto &move : plan) + planDestinations.insert(move.sourceAbsolute, move.destinationRelative); + + rebuildTree(); + updateStatusLine(); + + if (pages->currentIndex() == LoadingPage) + pages->setCurrentIndex(PlanPage); +} + +bool OrganizeFilesDialog::isFileItem(QTreeWidgetItem *item) const +{ + return item != nullptr && item->data(0, SourceRole).isValid(); +} + +void OrganizeFilesDialog::collectFileItems(QTreeWidgetItem *item, QList &out) const +{ + if (isFileItem(item)) { + out.append(item); + return; + } + + for (int i = 0; i < item->childCount(); ++i) + collectFileItems(item->child(i), out); +} + +QString OrganizeFilesDialog::relativePathForItem(QTreeWidgetItem *item) const +{ + QStringList segments; + for (QTreeWidgetItem *node = item; node != nullptr; node = node->parent()) { + const QString clean = OrganizeFiles::sanitizeSegment(node->text(0)); + if (!clean.isEmpty()) + segments.prepend(clean); + } + + return segments.join(QLatin1Char('/')); +} + +void OrganizeFilesDialog::rebuildTree() +{ + updatingTree = true; + + tree->clear(); + newFolderCount = 0; + + const QDir libraryDir(context.libraryPath); + const QString base = currentBase(); + const bool showUnchanged = showUnchangedCheck->isChecked(); + + const QColor mutedColor = tree->palette().color(QPalette::Disabled, QPalette::Text); + const bool dark = tree->palette().color(QPalette::Base).lightness() < 128; + const QColor warningColor = dark ? QColor(0xE0, 0xA0, 0x30) : QColor(0xB2, 0x6B, 0x00); + const QColor errorColor = dark ? QColor(0xE0, 0x6C, 0x5A) : QColor(0xC0, 0x39, 0x2B); + + QFont statusFont = QFontDatabase::systemFont(QFontDatabase::FixedFont); + statusFont.setPointSize(tree->font().pointSize()); + + const auto setStatus = [&](QTreeWidgetItem *item, const QString &glyph, const QString &text, const QColor &color) { + item->setText(2, glyph.isEmpty() ? text : glyph + QLatin1Char(' ') + text); + item->setForeground(2, color); + item->setFont(2, statusFont); + item->setTextAlignment(2, Qt::AlignRight | Qt::AlignVCenter); + }; + + QList visible; + for (const auto &move : std::as_const(plan)) { + if (move.status == PlannedMove::Status::Excluded) + continue; + if (move.status == PlannedMove::Status::Unchanged && !showUnchanged) + continue; + visible.append(move); + } + + std::sort(visible.begin(), visible.end(), [](const PlannedMove &a, const PlannedMove &b) { + return a.destinationRelative.compare(b.destinationRelative, Qt::CaseInsensitive) < 0; + }); + + QHash folders; + + for (const auto &move : std::as_const(visible)) { + const auto segments = move.destinationRelative.split(QLatin1Char('/'), Qt::SkipEmptyParts); + if (segments.isEmpty()) + continue; + + QTreeWidgetItem *parent = nullptr; + QString cumulative; + for (int i = 0; i < segments.size() - 1; ++i) { + cumulative += (cumulative.isEmpty() ? QString() : QStringLiteral("/")) + segments.at(i); + + QTreeWidgetItem *&folderItem = folders[cumulative]; + if (folderItem == nullptr) { + folderItem = parent != nullptr ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree); + folderItem->setText(0, segments.at(i)); + folderItem->setData(0, FolderPathRole, cumulative); + + QFont folderFont = folderItem->font(0); + folderFont.setBold(true); + folderItem->setFont(0, folderFont); + + if (!renaming()) { + folderItem->setFlags(folderItem->flags() | Qt::ItemIsEditable); + + const QString absolute = base + QLatin1Char('/') + cumulative; + auto known = folderExistsCache.find(absolute); + if (known == folderExistsCache.end()) + known = folderExistsCache.insert(absolute, QDir(absolute).exists()); + + if (!known.value()) { + newFolderCount++; + setStatus(folderItem, QString(), tr("new folder"), mutedColor); + folderItem->setToolTip(2, tr("This folder does not exist yet. It will be created.")); + } + } + } + parent = folderItem; + } + + auto fileItem = parent != nullptr ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree); + fileItem->setText(0, segments.last()); + fileItem->setData(0, SourceRole, move.sourceAbsolute); + fileItem->setData(0, ExtensionRole, QFileInfo(segments.last()).suffix().isEmpty() ? QString() : QLatin1Char('.') + QFileInfo(segments.last()).suffix()); + + // In rename mode the folder part is identical on both sides, so printing + // the whole path again would only repeat the tree above it. + fileItem->setText(1, renaming() ? QFileInfo(move.sourceAbsolute).fileName() : libraryDir.relativeFilePath(move.sourceAbsolute)); + fileItem->setForeground(1, mutedColor); + fileItem->setToolTip(1, QDir::toNativeSeparators(move.sourceAbsolute)); + + if (!move.note.isEmpty()) + fileItem->setToolTip(2, move.note); + + // Only the exceptions are marked. A row with no marker is the normal case, + // and marking that too would bury the rows that need attention. + switch (move.status) { + case PlannedMove::Status::Missing: + setStatus(fileItem, QStringLiteral("x"), tr("file not found"), errorColor); + fileItem->setToolTip(2, tr("This comic is in the library but not on disk. It is skipped.")); + fileItem->setDisabled(true); + break; + case PlannedMove::Status::Renamed: + setStatus(fileItem, QStringLiteral("!"), tr("name in use"), warningColor); + break; + case PlannedMove::Status::Incomplete: + setStatus(fileItem, QStringLiteral("?"), tr("no metadata"), warningColor); + break; + case PlannedMove::Status::Unchanged: + setStatus(fileItem, QStringLiteral("="), tr("already here"), mutedColor); + fileItem->setToolTip(2, tr("This file is already in the right place.")); + break; + default: + if (move.edited) + setStatus(fileItem, QString(), tr("edited"), mutedColor); + break; + } + + if (move.status != PlannedMove::Status::Missing) + fileItem->setFlags(fileItem->flags() | Qt::ItemIsEditable); + } + + tree->expandAll(); + tree->resizeColumnToContents(0); + + // A deeply indented file name sitting flush against a flat path reads as one + // string, so the first column keeps a gutter and never eats the second one. + const int viewportWidth = tree->viewport()->width(); + const int widest = viewportWidth > 0 ? viewportWidth * 3 / 5 : 420; + tree->setColumnWidth(0, qMin(tree->columnWidth(0) + 56, widest)); + + updatingTree = false; + + updateSelectionState(); +} + +void OrganizeFilesDialog::updateStatusLine() +{ + int willMove = 0; + int unchanged = 0; + int renamed = 0; + int excluded = 0; + int missing = 0; + + for (const auto &move : std::as_const(plan)) { + switch (move.status) { + case PlannedMove::Status::Unchanged: + unchanged++; + break; + case PlannedMove::Status::Excluded: + excluded++; + break; + case PlannedMove::Status::Missing: + missing++; + break; + case PlannedMove::Status::Renamed: + renamed++; + willMove++; + break; + default: + willMove++; + break; + } + } + + QStringList parts; + parts << (renaming() ? tr("%n will be renamed", "", willMove) : tr("%n will move", "", willMove)); + parts << tr("%n unchanged", "", unchanged); + if (renamed > 0) + parts << tr("%n renamed", "", renamed); + if (excluded > 0) + parts << tr("%n removed", "", excluded); + if (missing > 0) + parts << tr("%n missing", "", missing); + if (newFolderCount > 0) + parts << tr("%n new folder(s)", "", newFolderCount); + + statusLabel->setText(parts.join(QStringLiteral(" · "))); + + const bool hasOverrides = !overrides.isEmpty(); + overridesBanner->setText(tr("%n manual change(s) kept", "", overrides.size())); + overridesBanner->setVisible(hasOverrides); + resetButton->setVisible(hasOverrides); + + if (willMove == 0) { + warningLabel->setText(renaming() ? tr("Nothing would be renamed with this format.") + : tr("Nothing would move with this format.")); + } else if (renaming()) { + warningLabel->setText(tr("%n file(s) will be renamed. The folders do not change. You can undo it afterwards.", "", willMove)); + } else { + warningLabel->setText(tr("%n file(s) will move into %1. This changes your files on disk. You can undo it afterwards.", "", willMove) + .arg(QDir::toNativeSeparators(currentBase()))); + } + + // planIsStale keeps the button off while a rebuild is pending or in flight: + // the tree on screen is the plan from before the last interaction. + moveButton->setEnabled(willMove > 0 && patternIsValid && !moveRunning && !planIsStale); +} + +void OrganizeFilesDialog::updateSelectionState() +{ + removeButton->setEnabled(!tree->selectedItems().isEmpty()); +} + +void OrganizeFilesDialog::itemChanged(QTreeWidgetItem *item, int column) +{ + if (updatingTree || column != 0) + return; + + captureOverrides(item); + + // The item delegate is still closing its editor over this item, so the tree + // cannot be rebuilt before the event loop comes back around. + markPlanStale(ImmediateMs); +} + +void OrganizeFilesDialog::captureOverrides(QTreeWidgetItem *item) +{ + if (isFileItem(item)) { + const QString source = item->data(0, SourceRole).toString(); + const QString path = relativePathForItem(item); + + if (!path.isEmpty() && path != planDestinations.value(source)) + overrides[source].destinationRelative = path; + + return; + } + + // A folder rename is applied to the plan, not read off the tree: "Show + // unchanged" hides rows that belong to the folder just as much. + const QString oldPath = item->data(0, FolderPathRole).toString(); + const QString newPath = relativePathForItem(item); + + if (oldPath.isEmpty() || newPath.isEmpty() || oldPath == newPath) + return; + + const QString prefix = oldPath + QLatin1Char('/'); + + for (const auto &move : std::as_const(plan)) { + // A missing comic has its source path here, not a planned destination, and + // an override on it would do nothing but inflate the count of manual changes. + if (move.status == PlannedMove::Status::Missing) + continue; + + if (!move.destinationRelative.startsWith(prefix)) + continue; + + overrides[move.sourceAbsolute].destinationRelative = newPath + QLatin1Char('/') + move.destinationRelative.mid(prefix.size()); + } +} + +void OrganizeFilesDialog::removeSelectedItems() +{ + const auto selected = tree->selectedItems(); + if (selected.isEmpty()) + return; + + QList fileItems; + for (auto *item : selected) + collectFileItems(item, fileItems); + + for (auto *fileItem : std::as_const(fileItems)) + overrides[fileItem->data(0, SourceRole).toString()].excluded = true; + + markPlanStale(ImmediateMs); +} + +void OrganizeFilesDialog::resetOverrides() +{ + overrides.clear(); + markPlanStale(ImmediateMs); +} + +QList OrganizeFilesDialog::movesToExecute() const +{ + const QDir baseDir(currentBase()); + + QList moves; + for (const auto &move : std::as_const(plan)) { + if (!isExecutable(move.status)) + continue; + + FileMove fileMove; + fileMove.comicId = move.comicId; + fileMove.source = move.sourceAbsolute; + fileMove.destination = QDir::cleanPath(baseDir.absoluteFilePath(move.destinationRelative)); + moves.append(fileMove); + } + + return moves; +} + +void OrganizeFilesDialog::saveSettings() +{ + if (settings == nullptr) + return; + + settings->setValue(renaming() ? ORGANIZE_FILES_FILENAME_PATTERN : ORGANIZE_FILES_PATH_PATTERN, patternEdit->text()); + settings->setValue(ORGANIZE_FILES_SHOW_UNCHANGED, showUnchangedCheck->isChecked()); + if (!renaming() && !context.folderPath.isEmpty()) + settings->setValue(ORGANIZE_FILES_RELATIVE_TO_ROOT, rootBaseButton->isChecked()); +} + +void OrganizeFilesDialog::startMove() +{ + // The button is disabled in all of these cases. This is the second lock: a click + // that was already on its way when the state changed must not get through. + if (planIsStale || !patternIsValid || moveRunning) + return; + + const auto moves = movesToExecute(); + if (moves.isEmpty()) + return; + + saveSettings(); + + moveRunning = true; + moveButton->setEnabled(false); + + progressBar->setRange(0, moves.size()); + progressBar->setValue(0); + progressLabel->clear(); + pages->setCurrentIndex(WorkingPage); + + moveWorker = new OrganizeFiles::MoveWorker(context.libraryPath, currentBase(), moves, !renaming()); + // The database phase runs on the worker thread too; on the GUI thread it froze + // the window while the progress bar stood at 100%. + moveWorker->setApplier(applier); + moveThread = new QThread(this); + moveWorker->moveToThread(moveThread); + + connect(moveThread, &QThread::started, moveWorker, &OrganizeFiles::MoveWorker::process); + connect(moveWorker, &OrganizeFiles::MoveWorker::progress, this, &OrganizeFilesDialog::moveProgress); + connect(moveWorker, &OrganizeFiles::MoveWorker::updatingLibrary, this, &OrganizeFilesDialog::showUpdatingLibrary); + connect(moveWorker, &OrganizeFiles::MoveWorker::finished, this, &OrganizeFilesDialog::moveFinished); + + moveThread->start(); +} + +void OrganizeFilesDialog::moveProgress(int done, int total, const QString ¤tFile) +{ + progressBar->setRange(0, total); + progressBar->setValue(done); + progressLabel->setText(tr("Moving %1 of %2\n%3").arg(done).arg(total).arg(QDir::toNativeSeparators(currentFile))); +} + +void OrganizeFilesDialog::showUpdatingLibrary() +{ + progressBar->setRange(0, 0); + progressLabel->setText(tr("Updating the library...")); +} + +void OrganizeFilesDialog::showFailures(const QList &failures) +{ + failureList->clear(); + for (const auto &failure : failures) + failureList->addItem(QDir::toNativeSeparators(failure.path) + QStringLiteral(" — ") + failure.reason); + + failureList->setVisible(!failures.isEmpty()); + copyFailuresButton->setVisible(!failures.isEmpty()); +} + +void OrganizeFilesDialog::moveFinished() +{ + moveThread->quit(); + moveThread->wait(); + + const auto completed = moveWorker->completedMoves(); + const auto failures = moveWorker->failures(); + const auto removedDirectories = moveWorker->removedDirectories(); + const QString journalPath = moveWorker->journalPath(); + const QString startError = moveWorker->startError(); + const QString recordError = moveWorker->recordError(); + const int notAttempted = moveWorker->notAttempted(); + const bool databaseUpdated = moveWorker->databaseUpdated(); + const QString databaseError = moveWorker->databaseError(); + + QStringList lines; + + if (!startError.isEmpty()) { + // Nothing was touched: the run refuses to start without a record. + lines << tr("Nothing was moved.") + << tr("The record this run could be undone from could not be written, so the run did not start: %1").arg(startError); + } else { + lines << (renaming() ? tr("%n file(s) renamed.", "", completed.size()) + : tr("%n file(s) moved into %1.", "", completed.size()).arg(QDir::toNativeSeparators(currentBase()))); + + if (!recordError.isEmpty()) { + lines << tr("The record of this run stopped early, so the run stopped with it: %1").arg(recordError); + if (notAttempted > 0) + lines << tr("%n file(s) were not moved.", "", notAttempted); + } + } + + if (!completed.isEmpty()) { + if (databaseUpdated) + changedLibrary = true; + else + lines << tr("The library database could not be updated: %1").arg(databaseError) + << tr("Use Undo to move the files back, or update the library to make it match the files."); + } + + if (!removedDirectories.isEmpty()) + lines << tr("%n empty folder(s) were removed.", "", removedDirectories.size()); + + if (!failures.isEmpty()) + lines << tr("%n file(s) could not be moved.", "", failures.size()); + + showFailures(failures); + + resultLabel->setText(lines.join(QStringLiteral("\n"))); + + lastJournalPath = journalPath; + undoButton->setEnabled(!journalPath.isEmpty() && !completed.isEmpty() && static_cast(undoer)); + + // Deleted directly: a deferred delete posted to a stopped thread never runs. + delete moveWorker; + moveWorker = nullptr; + moveThread->deleteLater(); + moveThread = nullptr; + moveRunning = false; + + pages->setCurrentIndex(ResultPage); +} + +void OrganizeFilesDialog::undo() +{ + const QString journalPath = lastJournalPath; + if (journalPath.isEmpty() || !undoer || undoRunning || moveRunning) + return; + + undoRunning = true; + undoButton->setEnabled(false); + + progressBar->setRange(0, 0); + progressBar->setValue(0); + progressLabel->setText(tr("Moving the files back...")); + pages->setCurrentIndex(WorkingPage); + + // Same treatment as the run it reverses: worker thread and progress page. + const Undoer runner = undoer; + undoWorker = new OrganizeFiles::UndoWorker( + [runner, journalPath](QList *failures, QString *error, + const std::function &fileProgress, + const std::function &databasePhase) { + return runner(journalPath, failures, error, fileProgress, databasePhase); + }); + undoThread = new QThread(this); + undoWorker->moveToThread(undoThread); + + connect(undoThread, &QThread::started, undoWorker, &OrganizeFiles::UndoWorker::process); + connect(undoWorker, &OrganizeFiles::UndoWorker::progress, this, &OrganizeFilesDialog::undoProgress); + connect(undoWorker, &OrganizeFiles::UndoWorker::updatingLibrary, this, &OrganizeFilesDialog::showUpdatingLibrary); + connect(undoWorker, &OrganizeFiles::UndoWorker::finished, this, &OrganizeFilesDialog::undoFinished); + + undoThread->start(); +} + +void OrganizeFilesDialog::undoProgress(int done, int total, const QString ¤tFile) +{ + progressBar->setRange(0, total); + progressBar->setValue(done); + progressLabel->setText(tr("Moving back %1 of %2\n%3").arg(done).arg(total).arg(QDir::toNativeSeparators(currentFile))); +} + +void OrganizeFilesDialog::undoFinished() +{ + undoThread->quit(); + undoThread->wait(); + + const bool success = undoWorker->succeeded(); + const auto failures = undoWorker->failures(); + const QString error = undoWorker->errorString(); + + delete undoWorker; + undoWorker = nullptr; + undoThread->deleteLater(); + undoThread = nullptr; + undoRunning = false; + + // Even a failed undo has moved files and touched the database. + changedLibrary = true; + + if (success) { + resultLabel->setText(tr("Everything was moved back.")); + showFailures({ }); + undoButton->setEnabled(false); + } else { + resultLabel->setText(tr("The undo did not finish: %1").arg(error)); + showFailures(failures); + // The journal survives a failed undo so it can be retried, and this button + // is the only way to reach it. + undoButton->setEnabled(true); + } + + pages->setCurrentIndex(ResultPage); +} + +void OrganizeFilesDialog::reject() +{ + if (moveRunning || undoRunning) + return; + + QDialog::reject(); +} + +void OrganizeFilesDialog::resizeEvent(QResizeEvent *event) +{ + QDialog::resizeEvent(event); + updateBasePathLabel(); +} + +void OrganizeFilesDialog::closeEvent(QCloseEvent *event) +{ + if (moveRunning || undoRunning) { + event->ignore(); + return; + } + + QDialog::closeEvent(event); +} + +void OrganizeFilesDialog::wrapSelectionInOptionalGroup() +{ + QString text = patternEdit->text(); + int start = patternEdit->selectionStart(); + + if (start < 0) { + start = patternEdit->cursorPosition(); + text.insert(start, QStringLiteral("<>")); + patternEdit->setText(text); + patternEdit->setCursorPosition(start + 1); + } else { + const int length = patternEdit->selectedText().size(); + text.insert(start + length, QLatin1Char('>')); + text.insert(start, QLatin1Char('<')); + patternEdit->setText(text); + patternEdit->setCursorPosition(start + length + 2); + } + + patternEdit->setFocus(); +} + +void OrganizeFilesDialog::showFormatHelp() +{ + auto help = new QDialog(this); + help->setAttribute(Qt::WA_DeleteOnClose); + help->setWindowTitle(tr("Format help")); + + auto layout = new QVBoxLayout(help); + + const auto section = [&](const QString &title, const QString &description, const QString &example) { + auto group = new QGroupBox(title, help); + group->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); + auto groupLayout = new QVBoxLayout(group); + + auto text = new QLabel(description, group); + text->setWordWrap(true); + groupLayout->addWidget(text); + + auto code = new QLabel(example, group); + code->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + code->setFrameStyle(QFrame::StyledPanel | QFrame::Sunken); + code->setMargin(6); + code->setTextInteractionFlags(Qt::TextSelectableByMouse); + groupLayout->addWidget(code); + + layout->addWidget(group); + }; + + const QChar lineBreak = QChar::LineFeed; + const QChar quote = QLatin1Char('"'); + const auto quoted = [quote](const QString &text) { return quote + text + quote; }; + + section(tr("Fields"), + tr("Every field is written between braces and is replaced by the metadata of the comic. " + "The Insert menu lists all of them."), + OrganizeFiles::knownTokens().join(QStringLiteral(" ")) + lineBreak + tr("{series} gives %1").arg(quoted(QStringLiteral("The Amazing Spider-Man")))); + + section(tr("Optional parts"), + tr("A part written between the signs < and > disappears completely when every field inside it is empty. " + "Use it for punctuation that belongs to a field, such as brackets or a leading number sign. " + "Text at the start or the end of a name is trimmed without it."), + tr("{series} ({year}) with no year gives %1").arg(quoted(QStringLiteral("Series ()"))) + lineBreak + tr("{series}< ({year})> with no year gives %1").arg(quoted(QStringLiteral("Series")))); + + section(tr("Numbers"), + tr("Write a colon and some zeros to pad the issue number. " + "This keeps the issues in order in a file browser."), + QStringLiteral("{number} ") + quoted(QStringLiteral("42")) + lineBreak + QStringLiteral("{number:000} ") + quoted(QStringLiteral("042"))); + + if (renaming()) { + section(tr("Folders"), + tr("A filename format cannot contain a slash. Every comic keeps its current folder. " + "Use Organize into folders to move comics."), + QStringLiteral("{series} #{number:000}")); + } else { + section(tr("Folders"), + tr("Each part separated by a slash becomes a folder. The last part becomes the file name. " + "The original extension is always kept."), + QStringLiteral("{publisher}/{series}/{number:000}")); + } + + auto buttons = new QDialogButtonBox(QDialogButtonBox::Close, help); + connect(buttons, &QDialogButtonBox::rejected, help, &QDialog::reject); + layout->addWidget(buttons); + + help->resize(540, help->sizeHint().height()); + help->open(); +} + +void OrganizeFilesDialog::copyFailures() +{ + QStringList lines; + for (int i = 0; i < failureList->count(); ++i) + lines << failureList->item(i)->text(); + + QApplication::clipboard()->setText(lines.join(QStringLiteral("\n"))); +} diff --git a/YACReaderLibrary/organize_files/organize_files_dialog.h b/YACReaderLibrary/organize_files/organize_files_dialog.h new file mode 100644 index 000000000..e32170cb7 --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_dialog.h @@ -0,0 +1,173 @@ +#ifndef ORGANIZE_FILES_DIALOG_H +#define ORGANIZE_FILES_DIALOG_H + +#include "organize_files_plan.h" +#include "organize_files_worker.h" + +#include +#include +#include + +#include + +class QCheckBox; +class QCloseEvent; +class QLabel; +class QLineEdit; +class QListWidget; +class QProgressBar; +class QPushButton; +class QResizeEvent; +class QSettings; +class QStackedWidget; +class QToolButton; +class QThread; +class QTimer; +class QTreeWidget; +class QTreeWidgetItem; + +class OrganizeFilesDialog : public QDialog +{ + Q_OBJECT +public: + struct Context { + OrganizeFiles::Mode mode = OrganizeFiles::Mode::Organize; + QString libraryPath; + QString folderPath; + QList entries; + }; + + // Both run on a worker thread and must not touch the GUI. + using Applier = std::function &moves, const QStringList &removedDirectories, const QString &journalPath, QString *error)>; + using Undoer = std::function *failures, + QString *error, + const std::function &fileProgress, + const std::function &databasePhase)>; + + OrganizeFilesDialog(const Context &context, QSettings *settings, QWidget *parent = nullptr); + ~OrganizeFilesDialog() override; + + void setApplier(Applier applier); + void setUndoer(Undoer undoer); + + bool libraryChanged() const; + +private slots: + void patternEdited(); + void scheduleBuild(); + void startBuild(); + void planBuilt(const QList &moves, quint64 buildGeneration); + void itemChanged(QTreeWidgetItem *item, int column); + void removeSelectedItems(); + void resetOverrides(); + void updateSelectionState(); + void startMove(); + void moveProgress(int done, int total, const QString ¤tFile); + void showUpdatingLibrary(); + void moveFinished(); + void undo(); + void undoProgress(int done, int total, const QString ¤tFile); + void undoFinished(); + void copyFailures(); + void wrapSelectionInOptionalGroup(); + void showFormatHelp(); + +public slots: + void reject() override; + +protected: + void closeEvent(QCloseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +signals: + void buildRequested(const QString &pattern, const QString &base, const OrganizeFiles::Overrides &overrides, quint64 generation); + +private: + void setupPages(); + QWidget *createLoadingPage(); + QWidget *createPlanPage(); + QWidget *createWorkingPage(); + QWidget *createResultPage(); + void setupPlanWorker(); + + // Every input that changes what the run would do goes through this: it arms + // the rebuild and keeps Move files refused until the rebuild lands. + void markPlanStale(int delayMs); + + bool renaming() const; + QString currentBase() const; + void updateBasePathLabel(); + void rebuildTree(); + void updateStatusLine(); + void captureOverrides(QTreeWidgetItem *item); + QString relativePathForItem(QTreeWidgetItem *item) const; + bool isFileItem(QTreeWidgetItem *item) const; + void collectFileItems(QTreeWidgetItem *item, QList &out) const; + QList movesToExecute() const; + void showFailures(const QList &failures); + void saveSettings(); + + Context context; + QSettings *settings; + + Applier applier; + Undoer undoer; + + QStackedWidget *pages; + + QLineEdit *patternEdit; + QLabel *patternError; + QPushButton *folderBaseButton; + QPushButton *rootBaseButton; + QWidget *baseSelector; + QLabel *basePathLabel; + QLabel *overridesBanner; + QPushButton *resetButton; + QPushButton *removeButton; + QCheckBox *showUnchangedCheck; + QTreeWidget *tree; + QLabel *statusLabel; + QLabel *warningLabel; + QPushButton *moveButton; + QPushButton *cancelButton; + + QLabel *loadingLabel; + + QProgressBar *progressBar; + QLabel *progressLabel; + + QLabel *resultLabel; + QListWidget *failureList; + QPushButton *copyFailuresButton; + QPushButton *undoButton; + QPushButton *closeButton; + + QTimer *buildTimer; + QThread *planThread; + OrganizeFiles::PlanWorker *planWorker; + QThread *moveThread; + OrganizeFiles::MoveWorker *moveWorker; + QThread *undoThread; + OrganizeFiles::UndoWorker *undoWorker; + + OrganizeFiles::Overrides overrides; + QList plan; + QHash planDestinations; + // Valid for the dialog's whole life: nothing on disk moves until commit. + QHash folderExistsCache; + + QString lastJournalPath; + + int newFolderCount = 0; + + quint64 generation; + bool updatingTree; + bool changedLibrary; + bool patternIsValid; + bool moveRunning; + bool undoRunning; + bool planIsStale; +}; + +#endif // ORGANIZE_FILES_DIALOG_H diff --git a/YACReaderLibrary/organize_files/organize_files_journal.cpp b/YACReaderLibrary/organize_files/organize_files_journal.cpp new file mode 100644 index 000000000..2f6564be2 --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_journal.cpp @@ -0,0 +1,229 @@ +#include "organize_files_journal.h" + +#include "yacreader_global.h" + +#include +#include +#include +#include +#include + +namespace OrganizeFiles { + +QString absoluteFromRelative(const QString &libraryPath, const QString &relativePath) +{ + return QDir::cleanPath(libraryPath + QLatin1Char('/') + relativePath); +} + +Journal::Journal(const QString &libraryPath) + : libraryPath(QDir::cleanPath(libraryPath)) +{ +} + +QString Journal::directory(const QString &libraryPath) +{ + return QDir(YACReader::LibraryPaths::libraryDataPath(libraryPath)).filePath(QStringLiteral("organize")); +} + +QString Journal::filePath() const +{ + return path; +} + +bool Journal::healthy() const +{ + return !broken; +} + +QString Journal::errorString() const +{ + return error; +} + +QString Journal::toRelative(const QString &absolutePath) const +{ + return QLatin1Char('/') + QDir(libraryPath).relativeFilePath(absolutePath); +} + +bool Journal::begin(const QString &base) +{ + const QString folder = directory(libraryPath); + if (!QDir().mkpath(folder)) { + broken = true; + error = QCoreApplication::translate("OrganizeFiles", "%1 could not be created").arg(QDir::toNativeSeparators(folder)); + return false; + } + + path = QDir(folder).filePath(QString::number(QDateTime::currentMSecsSinceEpoch()) + QStringLiteral(".jsonl")); + + file.setFileName(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + broken = true; + error = file.errorString(); + path.clear(); + return false; + } + + QJsonObject header; + header[QStringLiteral("type")] = QStringLiteral("header"); + header[QStringLiteral("version")] = 1; + header[QStringLiteral("startedAt")] = QDateTime::currentSecsSinceEpoch(); + header[QStringLiteral("base")] = toRelative(base); + + return writeLine(header); +} + +bool Journal::reopen(const QString &filePath) +{ + path = filePath; + + file.setFileName(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + broken = true; + error = file.errorString(); + path.clear(); + return false; + } + + return true; +} + +bool Journal::writeLine(const QJsonObject &object) +{ + if (!file.isOpen()) { + broken = true; + return false; + } + + const QByteArray line = QJsonDocument(object).toJson(QJsonDocument::Compact) + '\n'; + + if (file.write(line) != line.size() || !file.flush()) { + broken = true; + error = file.errorString(); + return false; + } + + return true; +} + +void Journal::appendMove(qulonglong comicId, const QString &fromAbsolute, const QString &toAbsolute) +{ + QJsonObject move; + move[QStringLiteral("type")] = QStringLiteral("move"); + move[QStringLiteral("comicId")] = static_cast(comicId); + move[QStringLiteral("from")] = toRelative(fromAbsolute); + move[QStringLiteral("to")] = toRelative(toAbsolute); + writeLine(move); +} + +void Journal::appendRemovedDirectory(const QString &absolutePath) +{ + QJsonObject removed; + removed[QStringLiteral("type")] = QStringLiteral("removedDir"); + removed[QStringLiteral("path")] = toRelative(absolutePath); + writeLine(removed); +} + +void Journal::appendCreatedDirectory(const QString &absolutePath) +{ + QJsonObject created; + created[QStringLiteral("type")] = QStringLiteral("createdDir"); + created[QStringLiteral("path")] = toRelative(absolutePath); + writeLine(created); +} + +void Journal::appendRemovedFolder(const QVariantMap &row) +{ + QJsonObject removed; + removed[QStringLiteral("type")] = QStringLiteral("removedFolder"); + removed[QStringLiteral("row")] = QJsonObject::fromVariantMap(row); + writeLine(removed); +} + +void Journal::appendCreatedFolder(qulonglong folderId) +{ + QJsonObject created; + created[QStringLiteral("type")] = QStringLiteral("createdFolder"); + created[QStringLiteral("id")] = static_cast(folderId); + writeLine(created); +} + +void Journal::finish() +{ + QJsonObject footer; + footer[QStringLiteral("type")] = QStringLiteral("footer"); + footer[QStringLiteral("finishedAt")] = QDateTime::currentSecsSinceEpoch(); + footer[QStringLiteral("complete")] = !broken; + writeLine(footer); + + file.close(); +} + +QString Journal::latestPath(const QString &libraryPath) +{ + QDir folder(directory(libraryPath)); + const auto entries = folder.entryList({ QStringLiteral("*.jsonl") }, QDir::Files, QDir::Name); + + if (entries.isEmpty()) + return QString(); + + return folder.filePath(entries.last()); +} + +bool Journal::read(const QString &libraryPath, const QString &filePath, JournalData *data) +{ + QFile input(filePath); + if (!input.open(QIODevice::ReadOnly | QIODevice::Text)) + return false; + + data->filePath = filePath; + data->moves.clear(); + data->removedDirectories.clear(); + data->createdDirectories.clear(); + data->removedFolders.clear(); + data->createdFolders.clear(); + data->complete = false; + + while (!input.atEnd()) { + const QByteArray line = input.readLine().trimmed(); + if (line.isEmpty()) + continue; + + const auto object = QJsonDocument::fromJson(line).object(); + const QString type = object.value(QStringLiteral("type")).toString(); + + if (type == QLatin1String("header")) { + data->startedAt = static_cast(object.value(QStringLiteral("startedAt")).toDouble()); + data->base = absoluteFromRelative(libraryPath, object.value(QStringLiteral("base")).toString()); + } else if (type == QLatin1String("move")) { + JournalMove move; + move.comicId = static_cast(object.value(QStringLiteral("comicId")).toDouble()); + move.from = object.value(QStringLiteral("from")).toString(); + move.to = object.value(QStringLiteral("to")).toString(); + data->moves.append(move); + } else if (type == QLatin1String("removedDir")) { + data->removedDirectories.append(object.value(QStringLiteral("path")).toString()); + } else if (type == QLatin1String("createdDir")) { + data->createdDirectories.append(object.value(QStringLiteral("path")).toString()); + } else if (type == QLatin1String("removedFolder")) { + data->removedFolders.append(object.value(QStringLiteral("row")).toObject().toVariantMap()); + } else if (type == QLatin1String("createdFolder")) { + data->createdFolders.append(static_cast(object.value(QStringLiteral("id")).toDouble())); + } else if (type == QLatin1String("footer")) { + data->complete = object.value(QStringLiteral("complete")).toBool(); + } + } + + return true; +} + +void Journal::prune(const QString &libraryPath, int keep) +{ + QDir folder(directory(libraryPath)); + const auto entries = folder.entryList({ QStringLiteral("*.jsonl") }, QDir::Files, QDir::Name); + + for (int i = 0; i < entries.size() - keep; ++i) + QFile::remove(folder.filePath(entries.at(i))); +} + +} diff --git a/YACReaderLibrary/organize_files/organize_files_journal.h b/YACReaderLibrary/organize_files/organize_files_journal.h new file mode 100644 index 000000000..982366f17 --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_journal.h @@ -0,0 +1,78 @@ +#ifndef ORGANIZE_FILES_JOURNAL_H +#define ORGANIZE_FILES_JOURNAL_H + +#include +#include +#include +#include +#include + +class QJsonObject; + +namespace OrganizeFiles { + +struct JournalMove { + qulonglong comicId = 0; + QString from; + QString to; +}; + +struct JournalData { + QString filePath; + QString base; + qint64 startedAt = 0; + bool complete = false; + QList moves; + QStringList removedDirectories; + // Undo may remove these and nothing else; a pre-existing directory is not the + // run's to delete. + QStringList createdDirectories; + // Full rows, so undo can restore them with the same ids (covers are keyed by id). + QList removedFolders; + // Parents before children; undo deletes the ones that are empty again. + QList createdFolders; +}; + +class Journal +{ +public: + explicit Journal(const QString &libraryPath); + + bool begin(const QString &base); + // Reopens to append: the database work runs after the moves and has to land in + // the same record. + bool reopen(const QString &filePath); + + void appendMove(qulonglong comicId, const QString &fromAbsolute, const QString &toAbsolute); + void appendRemovedDirectory(const QString &absolutePath); + void appendCreatedDirectory(const QString &absolutePath); + void appendRemovedFolder(const QVariantMap &row); + void appendCreatedFolder(qulonglong folderId); + void finish(); + + QString filePath() const; + // False once a line failed to reach the disk; the caller must stop moving files. + bool healthy() const; + QString errorString() const; + + static QString directory(const QString &libraryPath); + static QString latestPath(const QString &libraryPath); + static bool read(const QString &libraryPath, const QString &filePath, JournalData *data); + static void prune(const QString &libraryPath, int keep); + +private: + bool writeLine(const QJsonObject &object); + QString toRelative(const QString &absolutePath) const; + + QString libraryPath; + QString path; + QFile file; + bool broken = false; + QString error; +}; + +QString absoluteFromRelative(const QString &libraryPath, const QString &relativePath); + +} + +#endif // ORGANIZE_FILES_JOURNAL_H diff --git a/YACReaderLibrary/organize_files/organize_files_plan.cpp b/YACReaderLibrary/organize_files/organize_files_plan.cpp new file mode 100644 index 000000000..b8d890b3e --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_plan.cpp @@ -0,0 +1,525 @@ +#include "organize_files_plan.h" + +#include +#include +#include +#include + +namespace { + +using OrganizeFiles::ComicEntry; + +QString translated(const char *text) +{ + return QCoreApplication::translate("OrganizeFiles", text); +} + +bool isReservedDeviceName(const QString &segment) +{ + static const QStringList reserved = { + QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"), QStringLiteral("NUL"), + QStringLiteral("COM1"), QStringLiteral("COM2"), QStringLiteral("COM3"), QStringLiteral("COM4"), + QStringLiteral("COM5"), QStringLiteral("COM6"), QStringLiteral("COM7"), QStringLiteral("COM8"), + QStringLiteral("COM9"), QStringLiteral("LPT1"), QStringLiteral("LPT2"), QStringLiteral("LPT3"), + QStringLiteral("LPT4"), QStringLiteral("LPT5"), QStringLiteral("LPT6"), QStringLiteral("LPT7"), + QStringLiteral("LPT8"), QStringLiteral("LPT9") + }; + + const QString stem = segment.section(QLatin1Char('.'), 0, 0); + return reserved.contains(stem, Qt::CaseInsensitive); +} + +QString rawValue(const QString &name, const ComicEntry &entry) +{ + if (name == QLatin1String("publisher")) + return entry.publisher; + if (name == QLatin1String("imprint")) + return entry.imprint; + if (name == QLatin1String("series")) + return entry.series; + if (name == QLatin1String("volume")) + return entry.volume; + if (name == QLatin1String("number")) + return entry.number; + if (name == QLatin1String("count")) + return entry.count; + if (name == QLatin1String("title")) + return entry.title; + if (name == QLatin1String("year")) + return entry.year; + if (name == QLatin1String("month")) + return entry.month; + if (name == QLatin1String("storyArc")) + return entry.storyArc; + if (name == QLatin1String("arcNumber")) + return entry.arcNumber; + if (name == QLatin1String("writer")) + return entry.writer; + if (name == QLatin1String("filename")) + return entry.baseName; + + return QString(); +} + +bool acceptsPadding(const QString &name) +{ + return name == QLatin1String("number") || name == QLatin1String("count") || name == QLatin1String("arcNumber"); +} + +int paddingWidth(const QString &spec) +{ + if (spec.isEmpty()) + return 0; + + for (const QChar c : spec) { + if (c != QLatin1Char('0')) + return 0; + } + + return spec.size(); +} + +QString resolveToken(const QString &name, + const QString &spec, + const ComicEntry &entry, + bool insideGroup, + bool *empty, + QStringList *fallbackFields) +{ + QString value = rawValue(name, entry).trimmed(); + + if (value.isEmpty() && !insideGroup) { + if (name == QLatin1String("series")) { + value = translated("Unknown Series"); + if (fallbackFields != nullptr) + *fallbackFields << translated("series"); + } else if (name == QLatin1String("publisher")) { + value = translated("Unknown Publisher"); + if (fallbackFields != nullptr) + *fallbackFields << translated("publisher"); + } else if (name == QLatin1String("title")) { + value = entry.series.trimmed().isEmpty() ? translated("Unknown Series") : entry.series.trimmed(); + if (fallbackFields != nullptr) + *fallbackFields << translated("title"); + } + } + + *empty = value.isEmpty(); + + if (acceptsPadding(name)) + value = OrganizeFiles::padNumber(value, paddingWidth(spec)); + + return value; +} + +QString expandTokens(const QString &text, + const ComicEntry &entry, + bool insideGroup, + bool *anyToken, + bool *allEmpty, + QStringList *fallbackFields) +{ + QString result; + int i = 0; + + while (i < text.size()) { + if (text.at(i) != QLatin1Char('{')) { + result += text.at(i); + ++i; + continue; + } + + const int close = text.indexOf(QLatin1Char('}'), i + 1); + if (close < 0) { + result += text.mid(i); + break; + } + + const QString content = text.mid(i + 1, close - i - 1); + const QString name = content.section(QLatin1Char(':'), 0, 0); + const QString spec = content.section(QLatin1Char(':'), 1); + + bool empty = true; + result += resolveToken(name, spec, entry, insideGroup, &empty, fallbackFields); + + if (anyToken != nullptr) + *anyToken = true; + if (allEmpty != nullptr && !empty) + *allEmpty = false; + + i = close + 1; + } + + return result; +} + +} + +namespace OrganizeFiles { + +QStringList knownTokens() +{ + return { QStringLiteral("publisher"), QStringLiteral("imprint"), QStringLiteral("series"), + QStringLiteral("volume"), QStringLiteral("number"), QStringLiteral("count"), + QStringLiteral("title"), QStringLiteral("year"), QStringLiteral("month"), + QStringLiteral("storyArc"), QStringLiteral("arcNumber"), QStringLiteral("writer"), + QStringLiteral("filename") }; +} + +QStringList invalidTokens(const QString &pattern) +{ + QStringList invalid; + + static const QRegularExpression tokenExpression(QStringLiteral("\\{([^{}]*)\\}")); + auto it = tokenExpression.globalMatch(pattern); + while (it.hasNext()) { + const auto match = it.next(); + const QString content = match.captured(1); + const QString name = content.section(QLatin1Char(':'), 0, 0); + const QString spec = content.section(QLatin1Char(':'), 1); + + const bool nameIsKnown = knownTokens().contains(name); + const bool specIsValid = spec.isEmpty() ? true : (acceptsPadding(name) && paddingWidth(spec) > 0); + + if (!nameIsKnown || !specIsValid) + invalid << match.captured(0); + } + + if (pattern.count(QLatin1Char('{')) != pattern.count(QLatin1Char('}'))) + invalid << QStringLiteral("{"); + + if (pattern.count(QLatin1Char('<')) != pattern.count(QLatin1Char('>'))) + invalid << QStringLiteral("<"); + + return invalid; +} + +bool patternCreatesFolders(const QString &pattern) +{ +#ifdef Q_OS_WIN + return pattern.contains(QLatin1Char('/')) || pattern.contains(QLatin1Char('\\')); +#else + return pattern.contains(QLatin1Char('/')); +#endif +} + +QString pathKey(const QString &path) +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + return path.toLower(); +#else + return path; +#endif +} + +QString sanitizeSegment(QString segment) +{ + static const QString invalid = QStringLiteral("<>:\"/\\|?*"); + for (QChar &c : segment) { + if (invalid.contains(c) || c < QChar(0x20)) + c = QLatin1Char('_'); + } + + segment = segment.simplified(); + + while (segment.endsWith(QLatin1Char('.')) || segment.endsWith(QLatin1Char(' '))) + segment.chop(1); + + while (segment.startsWith(QLatin1Char('-')) || segment.startsWith(QLatin1Char('_')) || segment.startsWith(QLatin1Char('.')) || segment.startsWith(QLatin1Char(' '))) + segment.remove(0, 1); + + while (segment.endsWith(QLatin1Char('-')) || segment.endsWith(QLatin1Char('_'))) + segment.chop(1); + + segment = segment.trimmed(); + + if (!segment.isEmpty() && isReservedDeviceName(segment)) + segment.append(QLatin1Char('_')); + + return segment; +} + +QString padNumber(const QString &number, int width) +{ + const QString trimmed = number.trimmed(); + if (width <= 0 || trimmed.isEmpty()) + return trimmed; + + int digits = 0; + while (digits < trimmed.size() && trimmed.at(digits).isDigit()) + ++digits; + + if (digits == 0) + return trimmed; + + QString leading = trimmed.left(digits); + while (leading.size() < width) + leading.prepend(QLatin1Char('0')); + + return leading + trimmed.mid(digits); +} + +QString buildRelativePath(const QString &pattern, const ComicEntry &entry, QStringList *fallbackFields) +{ + QString expanded; + int i = 0; + + while (i < pattern.size()) { + if (pattern.at(i) == QLatin1Char('<')) { + const int close = pattern.indexOf(QLatin1Char('>'), i + 1); + if (close < 0) { + expanded += expandTokens(pattern.mid(i + 1), entry, false, nullptr, nullptr, fallbackFields); + break; + } + + bool anyToken = false; + bool allEmpty = true; + const QString group = expandTokens(pattern.mid(i + 1, close - i - 1), entry, true, &anyToken, &allEmpty, nullptr); + + if (!anyToken || !allEmpty) + expanded += group; + + i = close + 1; + continue; + } + + const int nextGroup = pattern.indexOf(QLatin1Char('<'), i); + const QString chunk = nextGroup < 0 ? pattern.mid(i) : pattern.mid(i, nextGroup - i); + expanded += expandTokens(chunk, entry, false, nullptr, nullptr, fallbackFields); + i = nextGroup < 0 ? pattern.size() : nextGroup; + } + +#ifdef Q_OS_WIN + expanded.replace(QLatin1Char('\\'), QLatin1Char('/')); +#endif + + const auto rawSegments = expanded.split(QLatin1Char('/'), Qt::KeepEmptyParts); + + QStringList segments; + for (const QString &raw : rawSegments) { + const QString clean = sanitizeSegment(raw); + if (!clean.isEmpty()) + segments << clean; + } + + // An empty last segment would turn the deepest folder into the file, so the + // original file name takes its place instead. + const bool fileSegmentIsEmpty = rawSegments.isEmpty() || sanitizeSegment(rawSegments.last()).isEmpty(); + if (segments.isEmpty() || fileSegmentIsEmpty) { + QString fallback = sanitizeSegment(entry.baseName); + if (fallback.isEmpty()) + fallback = sanitizeSegment(entry.title); + if (fallback.isEmpty()) + fallback = translated("Unknown Comic"); + + segments << fallback; + } + + return segments.join(QLatin1Char('/')) + entry.extension; +} + +QString defaultPattern(Mode mode) +{ + if (mode == Mode::Rename) + return QStringLiteral("{series}< #{number:000}>< - {title}>"); + + return QStringLiteral("{publisher}/{series}/{number:000}< - {title}>"); +} + +QList> presets(Mode mode) +{ + if (mode == Mode::Rename) { + return { + { translated("Series #Number - Title"), QStringLiteral("{series}< #{number:000}>< - {title}>") }, + { translated("Series #Number"), QStringLiteral("{series} #{number:000}") }, + { translated("Number - Title"), QStringLiteral("{number:000}< - {title}>") }, + { translated("Series (Year) #Number"), QStringLiteral("{series}< ({year})> #{number:000}") } + }; + } + + return { + { translated("Publisher / Series / Number - Title"), QStringLiteral("{publisher}/{series}/{number:000}< - {title}>") }, + { translated("Series / Series #Number"), QStringLiteral("{series}/{series} #{number:000}") }, + { translated("Publisher / Series (Year) / Number"), QStringLiteral("{publisher}/{series}< ({year})>/{number:000}") }, + { translated("Series / original file name"), QStringLiteral("{series}/{filename}") } + }; +} + +PlanBuilder::PlanBuilder(const QList &entries, const QString &base, Mode mode) + : entries(entries), base(QDir::cleanPath(base)), mode(mode) +{ + for (const auto &entry : entries) + sourcePaths.insert(pathKey(entry.sourceAbsolute)); +} + +void PlanBuilder::setBase(const QString &base) +{ + this->base = QDir::cleanPath(base); +} + +const QHash &PlanBuilder::namesIn(const QString &absoluteDirectory) +{ + const QString key = pathKey(absoluteDirectory); + + auto it = directoryNames.find(key); + if (it != directoryNames.end()) + return it.value(); + + QHash names; + const auto entryList = QDir(absoluteDirectory).entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &name : entryList) + names.insert(pathKey(name), name); + + return directoryNames.insert(key, names).value(); +} + +// The casing this directory will actually have on disk — mkpath() never re-cases +// an existing one. The database rows are written from these strings, so they must match. +QString PlanBuilder::canonicalDirectory(const QString &absoluteDirectory) +{ + const QString clean = QDir::cleanPath(absoluteDirectory); + const QString folded = pathKey(clean); + + auto it = canonicalDirectories.find(folded); + if (it != canonicalDirectories.end()) + return it.value(); + + QString result = clean; + if (folded != pathKey(base) && folded.startsWith(pathKey(base) + QLatin1Char('/'))) { + const QString parent = canonicalDirectory(QFileInfo(clean).absolutePath()); + const QString name = QFileInfo(clean).fileName(); + result = parent + QLatin1Char('/') + namesIn(parent).value(pathKey(name), name); + } + + return canonicalDirectories.insert(folded, result).value(); +} + +QList PlanBuilder::build(const QString &pattern, const Overrides &overrides) +{ + const QDir baseDir(base); + + // A new directory's casing follows its first appearance in the current plan. + canonicalDirectories.clear(); + + // Two passes: entries that stay put claim their paths first, so placement + // cannot depend on the order of the entries. + struct Draft { + PlannedMove move; + QString destination; + QStringList fallbackFields; + bool needsPlacement = false; + }; + + QList drafts; + drafts.reserve(entries.size()); + + QSet claimed; + + for (const auto &original : entries) { + ComicEntry entry = original; + + // Resolved here and not when the entry is built, because the base can + // change while the dialog is open. + const QString relativeDirectory = baseDir.relativeFilePath(QFileInfo(entry.sourceAbsolute).absolutePath()); + if (relativeDirectory != QLatin1String(".") && !relativeDirectory.startsWith(QLatin1String(".."))) + entry.folderRelative = relativeDirectory; + + PlannedMove move; + move.comicId = entry.comicId; + move.sourceAbsolute = entry.sourceAbsolute; + move.edited = !overrides.value(entry.sourceAbsolute).destinationRelative.isEmpty(); + + if (entry.missing) { + move.status = PlannedMove::Status::Missing; + move.destinationRelative = baseDir.relativeFilePath(entry.sourceAbsolute); + drafts.append({ move, QString(), { }, false }); + continue; + } + + const auto entryOverride = overrides.value(entry.sourceAbsolute); + + QStringList fallbackFields; + QString patterned = entryOverride.destinationRelative.isEmpty() + ? buildRelativePath(pattern, entry, &fallbackFields) + : entryOverride.destinationRelative; + + if (mode == Mode::Rename && entryOverride.destinationRelative.isEmpty()) { + const QString name = patterned.section(QLatin1Char('/'), -1); + patterned = entry.folderRelative.isEmpty() ? name : entry.folderRelative + QLatin1Char('/') + name; + } + + if (entryOverride.excluded) { + move.status = PlannedMove::Status::Excluded; + move.destinationRelative = patterned; + // The file stays where it is, so nothing else may be placed on it. + claimed.insert(pathKey(entry.sourceAbsolute)); + drafts.append({ move, QString(), { }, false }); + continue; + } + + // Only the directory part is bent to on-disk casing; a file, unlike a + // directory, really is renamed to its planned casing. + const QFileInfo plannedInfo(QDir::cleanPath(base + QLatin1Char('/') + patterned)); + const QString destination = canonicalDirectory(plannedInfo.absolutePath()) + QLatin1Char('/') + plannedInfo.fileName(); + + // Compared with case: a capitalisation fix is a real move, and pathKey() + // would call it unchanged. + if (destination == entry.sourceAbsolute) { + move.status = PlannedMove::Status::Unchanged; + move.destinationRelative = baseDir.relativeFilePath(destination); + claimed.insert(pathKey(destination)); + drafts.append({ move, QString(), { }, false }); + continue; + } + + drafts.append({ move, destination, fallbackFields, true }); + } + + QList moves; + moves.reserve(drafts.size()); + + for (auto &draft : drafts) { + if (!draft.needsPlacement) { + moves.append(draft.move); + continue; + } + + PlannedMove &move = draft.move; + const QString &destination = draft.destination; + const QStringList &fallbackFields = draft.fallbackFields; + + const QFileInfo destinationInfo(destination); + const QString directory = destinationInfo.absolutePath(); + const QString stem = destinationInfo.completeBaseName(); + const QString suffix = destinationInfo.suffix().isEmpty() ? QString() : QLatin1Char('.') + destinationInfo.suffix(); + + QString candidate = destination; + int counter = 1; + while (true) { + const bool takenInPlan = claimed.contains(pathKey(candidate)); + const bool takenOnDisk = namesIn(directory).contains(pathKey(QFileInfo(candidate).fileName())) && !sourcePaths.contains(pathKey(candidate)); + + if (!takenInPlan && !takenOnDisk) + break; + + candidate = QDir::cleanPath(directory + QLatin1Char('/') + stem + QStringLiteral(" (") + QString::number(counter++) + QLatin1Char(')') + suffix); + } + + claimed.insert(pathKey(candidate)); + move.destinationRelative = baseDir.relativeFilePath(candidate); + + if (candidate != destination) { + move.status = PlannedMove::Status::Renamed; + move.note = QCoreApplication::translate("OrganizeFiles", "Renamed, %1 is already in use").arg(destinationInfo.fileName()); + } else if (!fallbackFields.isEmpty()) { + move.status = PlannedMove::Status::Incomplete; + move.note = QCoreApplication::translate("OrganizeFiles", "Missing metadata: %1").arg(fallbackFields.join(QStringLiteral(", "))); + } + + moves.append(move); + } + + return moves; +} + +} diff --git a/YACReaderLibrary/organize_files/organize_files_plan.h b/YACReaderLibrary/organize_files/organize_files_plan.h new file mode 100644 index 000000000..cdc2f9dcd --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_plan.h @@ -0,0 +1,111 @@ +#ifndef ORGANIZE_FILES_PLAN_H +#define ORGANIZE_FILES_PLAN_H + +#include +#include +#include +#include +#include +#include +#include + +namespace OrganizeFiles { + +// Rename keeps every comic in its own folder and only changes the file name. +// Organize may move files and create folders. +enum class Mode { + Rename, + Organize +}; + +struct ComicEntry { + qulonglong comicId = 0; + QString sourceAbsolute; + QString baseName; + QString extension; + QString folderRelative; + bool missing = false; + + QString publisher; + QString imprint; + QString series; + QString volume; + QString number; + QString count; + QString title; + QString year; + QString month; + QString storyArc; + QString arcNumber; + QString writer; +}; + +struct PlannedMove { + enum class Status { + Move, + Unchanged, + Renamed, + Incomplete, + Missing, + Excluded + }; + + qulonglong comicId = 0; + QString sourceAbsolute; + QString destinationRelative; + Status status = Status::Move; + bool edited = false; + QString note; +}; + +struct Override { + bool excluded = false; + QString destinationRelative; +}; + +using Overrides = QHash; + +QStringList knownTokens(); +QStringList invalidTokens(const QString &pattern); +bool patternCreatesFolders(const QString &pattern); + +// Folds a path the way the local file system compares them. Use it to decide +// whether two paths are the same file, never to decide whether a name changed. +QString pathKey(const QString &path); + +QString sanitizeSegment(QString segment); +QString padNumber(const QString &number, int width); +QString buildRelativePath(const QString &pattern, const ComicEntry &entry, QStringList *fallbackFields = nullptr); + +QString defaultPattern(Mode mode); +QList> presets(Mode mode); + +class PlanBuilder +{ +public: + PlanBuilder(const QList &entries, const QString &base, Mode mode); + + void setBase(const QString &base); + QList build(const QString &pattern, const Overrides &overrides); + +private: + const QHash &namesIn(const QString &absoluteDirectory); + QString canonicalDirectory(const QString &absoluteDirectory); + + QList entries; + QString base; + Mode mode; + QSet sourcePaths; + // Per directory: the names it holds on disk, folded key to actual casing. + QHash> directoryNames; + // Folded path to the casing the run will produce; rebuilt on every build(). + QHash canonicalDirectories; +}; + +} + +Q_DECLARE_METATYPE(OrganizeFiles::PlannedMove) +Q_DECLARE_METATYPE(QList) +Q_DECLARE_METATYPE(OrganizeFiles::Overrides) + +#endif // ORGANIZE_FILES_PLAN_H diff --git a/YACReaderLibrary/organize_files/organize_files_worker.cpp b/YACReaderLibrary/organize_files/organize_files_worker.cpp new file mode 100644 index 000000000..2b16c93c3 --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_worker.cpp @@ -0,0 +1,433 @@ +#include "organize_files_worker.h" + +#include "organize_files_journal.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +QString translated(const char *text) +{ + return QCoreApplication::translate("OrganizeFiles", text); +} + +QString temporaryNameFor(const QString &source) +{ + QString candidate = source + QStringLiteral(".yacreader-organize"); + int counter = 1; + while (QFileInfo::exists(candidate)) + candidate = source + QStringLiteral(".yacreader-organize-") + QString::number(counter++); + + return candidate; +} + +bool renameThroughTemporary(const QString &source, const QString &destination, QString *reason) +{ + const QString temporary = temporaryNameFor(source); + + QFile sourceFile(source); + if (!sourceFile.rename(temporary)) { + *reason = sourceFile.errorString(); + return false; + } + + QFile temporaryFile(temporary); + if (!temporaryFile.rename(destination)) { + *reason = temporaryFile.errorString(); + // Put it back, so a failure leaves nothing behind under a name the library + // does not know about. + temporaryFile.rename(source); + return false; + } + + return true; +} + +} + +namespace OrganizeFiles { + +bool moveFile(const QString &source, const QString &destination, QString *reason) +{ + if (source == destination) + return true; + + // A rename that only changes the capitalisation of the name has a destination + // that "already exists" on Windows and macOS, so it has to go around. + if (source.compare(destination, Qt::CaseInsensitive) == 0) + return renameThroughTemporary(source, destination, reason); + + QFile sourceFile(source); + if (sourceFile.rename(destination)) + return true; + + const QString renameError = sourceFile.errorString(); + + // Only a cross-volume rename is worth a copy. When the destination is taken, + // the copy fails for the same reason and the rename error is the useful one. + if (QFileInfo::exists(destination)) { + *reason = renameError; + return false; + } + + if (!QFile::copy(source, destination)) { + QFile copyTarget(destination); + *reason = translated("%1 (copy also failed: %2)").arg(renameError, copyTarget.errorString()); + return false; + } + + if (QFileInfo(destination).size() != QFileInfo(source).size()) { + QFile::remove(destination); + *reason = translated("the copy did not have the same size as the original"); + return false; + } + + if (!QFile::remove(source)) { + QFile::remove(destination); + *reason = translated("the original could not be deleted after it was copied"); + return false; + } + + return true; +} + +QList orderMoves(const QList &moves) +{ + // Sources and destinations are unique, so every move has at most one blocker + // and the graph is a set of chains and simple cycles. + QHash ownerOfSource; + for (int i = 0; i < moves.size(); ++i) + ownerOfSource.insert(pathKey(moves.at(i).source), i); + + QList blocker(moves.size(), -1); + QList blocked(moves.size(), -1); + + for (int i = 0; i < moves.size(); ++i) { + const int owner = ownerOfSource.value(pathKey(moves.at(i).destination), -1); + if (owner < 0 || owner == i) + continue; + + blocker[i] = owner; + blocked[owner] = i; + } + + QList ordered; + ordered.reserve(moves.size()); + + QList emitted(moves.size(), false); + + const auto emitChainFrom = [&](int start) { + for (int i = start; i >= 0 && !emitted.at(i); i = blocked.at(i)) { + emitted[i] = true; + ordered.append({ moves.at(i), false }); + } + }; + + // Chains first: an unblocked move goes straight away, its waiters follow. + for (int i = 0; i < moves.size(); ++i) { + if (blocker.at(i) < 0) + emitChainFrom(i); + } + + // Whatever is left is a cycle. Parking one member under a temporary name first + // frees its source for the rest of the ring. + for (int i = 0; i < moves.size(); ++i) { + if (emitted.at(i)) + continue; + + emitted[i] = true; + ordered.append({ moves.at(i), true }); + + emitChainFrom(blocked.at(i)); + } + + return ordered; +} + +QStringList removeEmptyDirectories(const QStringList &directories, const QString &boundary) +{ + const QString boundaryKey = pathKey(QDir::cleanPath(boundary)); + + QStringList ordered = directories; + ordered.removeDuplicates(); + std::sort(ordered.begin(), ordered.end(), [](const QString &a, const QString &b) { + return a.count(QLatin1Char('/')) > b.count(QLatin1Char('/')); + }); + + QStringList removed; + + for (const QString &candidate : std::as_const(ordered)) { + QString directory = QDir::cleanPath(candidate); + + // pathKey: base and directory can arrive with different capitalisation on + // Windows, and a plain prefix test would then remove nothing at all. + while (pathKey(directory).startsWith(boundaryKey + QLatin1Char('/'))) { + // Hidden entries count: a directory holding only a desktop.ini is not empty. + if (!QDir(directory).isEmpty(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot)) + break; + + const QString parent = QFileInfo(directory).absolutePath(); + if (!QDir().rmdir(directory)) + break; + + removed.append(directory); + directory = parent; + } + } + + return removed; +} + +QStringList removeCreatedDirectories(const QStringList &directories) +{ + QStringList ordered = directories; + ordered.removeDuplicates(); + // Deepest first, so a parent is already empty by the time its turn comes. + std::sort(ordered.begin(), ordered.end(), [](const QString &a, const QString &b) { + return a.count(QLatin1Char('/')) > b.count(QLatin1Char('/')); + }); + + QStringList removed; + + for (const QString &directory : std::as_const(ordered)) { + if (!QDir(directory).exists()) + continue; + + if (!QDir(directory).isEmpty(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot)) + continue; + + if (QDir().rmdir(directory)) + removed.append(directory); + } + + return removed; +} + +MoveWorker::MoveWorker(const QString &libraryPath, const QString &base, const QList &moves, bool removeEmptyFolders) + : libraryPath(QDir::cleanPath(libraryPath)), base(QDir::cleanPath(base)), moves(moves), removeEmptyFolders(removeEmptyFolders) +{ +} + +void MoveWorker::setApplier(Applier applier) +{ + this->applier = std::move(applier); +} + +QList MoveWorker::completedMoves() const +{ + return completed; +} + +QList MoveWorker::failures() const +{ + return failed; +} + +QStringList MoveWorker::removedDirectories() const +{ + return removed; +} + +QString MoveWorker::journalPath() const +{ + return journal; +} + +QString MoveWorker::startError() const +{ + return journalError; +} + +QString MoveWorker::recordError() const +{ + return journalBreak; +} + +int MoveWorker::notAttempted() const +{ + return moves.size() - completed.size() - failed.size(); +} + +bool MoveWorker::databaseUpdated() const +{ + return applied; +} + +QString MoveWorker::databaseError() const +{ + return applyError; +} + +void MoveWorker::process() +{ + Journal journalFile(libraryPath); + + // A run with no record cannot be undone, so it must not start. + if (!journalFile.begin(base)) { + journalError = journalFile.errorString(); + emit finished(); + return; + } + + journal = journalFile.filePath(); + + const auto ordered = orderMoves(moves); + const int total = ordered.size(); + int done = 0; + + QHash> deferred; + QSet createdDirectories; + + for (int i = 0; i < ordered.size(); ++i) { + const auto &step = ordered.at(i); + const auto &move = step.move; + + const QString target = step.viaTemporary ? temporaryNameFor(move.source) : move.destination; + const QString targetDirectory = QFileInfo(target).absolutePath(); + + // Noted before mkpath; afterwards there is no way to tell what the run made + // from what was already there. + QStringList aboutToCreate; + for (QString level = targetDirectory; + !level.isEmpty() && !QFileInfo::exists(level) && pathKey(level).startsWith(pathKey(base) + QLatin1Char('/')); + level = QFileInfo(level).absolutePath()) { + aboutToCreate.prepend(level); + } + + if (!QDir().mkpath(targetDirectory)) { + failed.append({ move.source, translated("The destination folder could not be created.") }); + } else { + for (const QString &level : std::as_const(aboutToCreate)) { + if (!createdDirectories.contains(level)) { + createdDirectories.insert(level); + journalFile.appendCreatedDirectory(level); + } + } + + // Stop before the move, not after it: a file moved with no record + // could never come back. + if (!journalFile.healthy()) { + journalBreak = journalFile.errorString(); + break; + } + + QString reason; + if (moveFile(move.source, target, &reason)) { + journalFile.appendMove(move.comicId, move.source, target); + + if (step.viaTemporary) + deferred.insert(i, { move, target }); + else + completed.append(move); + } else { + failed.append({ move.source, reason }); + } + } + + if (!journalFile.healthy()) { + journalBreak = journalFile.errorString(); + break; + } + + emit progress(++done, total, QDir(base).relativeFilePath(move.destination)); + } + + // The parked cycle members reach their destinations. Runs even after a journal + // break: a file must not survive the run under a temporary name. + for (auto it = deferred.constBegin(); it != deferred.constEnd(); ++it) { + const auto &move = it.value().first; + const QString &temporary = it.value().second; + + QString reason; + if (moveFile(temporary, move.destination, &reason)) { + journalFile.appendMove(move.comicId, temporary, move.destination); + completed.append(move); + } else { + // Put it back: at its source the file reads as already restored during + // an undo; at the temporary name it would be lost to the library. + QString backReason; + if (moveFile(temporary, move.source, &backReason)) + failed.append({ move.source, reason }); + else + failed.append({ move.source, translated("%1 (the file was left at %2)").arg(reason, QDir::toNativeSeparators(temporary)) }); + } + } + + if (removeEmptyFolders) { + QStringList sourceDirectories; + for (const auto &move : std::as_const(completed)) + sourceDirectories << QFileInfo(move.source).absolutePath(); + + removed = removeEmptyDirectories(sourceDirectories, base); + } + + for (const QString &directory : std::as_const(removed)) + journalFile.appendRemovedDirectory(directory); + + // A directory created for a move that then failed is empty and unknown to the + // database; only the empty ones are deleted, so used directories are untouched. + removeCreatedDirectories(createdDirectories.values()); + + // The database work reopens this file to append the folder rows it changes. + journalFile.finish(); + Journal::prune(libraryPath, 10); + + if (applier && !completed.isEmpty()) { + emit updatingLibrary(); + applied = applier(completed, removed, journal, &applyError); + } + + emit finished(); +} + +UndoWorker::UndoWorker(Runner runner) + : runner(std::move(runner)) +{ +} + +bool UndoWorker::succeeded() const +{ + return success; +} + +QList UndoWorker::failures() const +{ + return failed; +} + +QString UndoWorker::errorString() const +{ + return error; +} + +void UndoWorker::process() +{ + success = runner( + &failed, &error, + [this](int done, int total, const QString ¤tFile) { emit progress(done, total, currentFile); }, + [this] { emit updatingLibrary(); }); + + emit finished(); +} + +PlanWorker::PlanWorker(const QList &entries, const QString &base, Mode mode) + : builder(entries, base, mode) +{ +} + +void PlanWorker::build(const QString &pattern, const QString &base, const OrganizeFiles::Overrides &overrides, quint64 generation) +{ + builder.setBase(base); + emit built(builder.build(pattern, overrides), generation); +} + +} diff --git a/YACReaderLibrary/organize_files/organize_files_worker.h b/YACReaderLibrary/organize_files/organize_files_worker.h new file mode 100644 index 000000000..32140c79e --- /dev/null +++ b/YACReaderLibrary/organize_files/organize_files_worker.h @@ -0,0 +1,143 @@ +#ifndef ORGANIZE_FILES_WORKER_H +#define ORGANIZE_FILES_WORKER_H + +#include "organize_files_plan.h" + +#include +#include +#include +#include + +#include + +namespace OrganizeFiles { + +struct FileMove { + qulonglong comicId = 0; + QString source; + QString destination; +}; + +struct FileFailure { + QString path; + QString reason; +}; + +// Renames when it can, falls back to copy+verify+delete for cross-volume moves. +// Undo needs the same fallback, or a file that crossed a volume cannot get back. +bool moveFile(const QString &source, const QString &destination, QString *reason); + +QStringList removeEmptyDirectories(const QStringList &directories, const QString &boundary); + +// Removes exactly the listed directories, deepest first, only while each is empty. +QStringList removeCreatedDirectories(const QStringList &directories); + +// Orders the moves so a path is vacated before another file is moved onto it. +struct OrderedMove { + FileMove move; + // Part of a cycle: parked under a temporary name, finished at the end. + bool viaTemporary = false; +}; + +QList orderMoves(const QList &moves); + +class MoveWorker : public QObject +{ + Q_OBJECT +public: + // Runs on the worker thread; must not touch the GUI. + using Applier = std::function &completed, const QStringList &removedDirectories, const QString &journalPath, QString *error)>; + + MoveWorker(const QString &libraryPath, const QString &base, const QList &moves, bool removeEmptyFolders); + + void setApplier(Applier applier); + + QList completedMoves() const; + QList failures() const; + QStringList removedDirectories() const; + QString journalPath() const; + // Set when the run never started: the journal could not be written. + QString startError() const; + // Set when the journal broke mid-run and stopped it. + QString recordError() const; + // Files the run never reached because the journal broke. + int notAttempted() const; + + bool databaseUpdated() const; + QString databaseError() const; + +public slots: + void process(); + +signals: + void progress(int done, int total, const QString ¤tFile); + void updatingLibrary(); + void finished(); + +private: + QString libraryPath; + QString base; + QList moves; + bool removeEmptyFolders; + Applier applier; + QList completed; + QList failed; + QStringList removed; + QString journal; + QString journalError; + QString journalBreak; + bool applied = true; + QString applyError; +}; + +// Runs the undo callback off the GUI thread, with progress and a failure list. +class UndoWorker : public QObject +{ + Q_OBJECT +public: + using Runner = std::function *failures, QString *error, + const std::function &fileProgress, + const std::function &databasePhase)>; + + explicit UndoWorker(Runner runner); + + bool succeeded() const; + QList failures() const; + QString errorString() const; + +public slots: + void process(); + +signals: + void progress(int done, int total, const QString ¤tFile); + void updatingLibrary(); + void finished(); + +private: + Runner runner; + bool success = false; + QList failed; + QString error; +}; + +class PlanWorker : public QObject +{ + Q_OBJECT +public: + PlanWorker(const QList &entries, const QString &base, Mode mode); + +public slots: + void build(const QString &pattern, const QString &base, const OrganizeFiles::Overrides &overrides, quint64 generation); + +signals: + void built(const QList &moves, quint64 generation); + +private: + PlanBuilder builder; +}; + +} + +Q_DECLARE_METATYPE(OrganizeFiles::FileMove) + +#endif // ORGANIZE_FILES_WORKER_H diff --git a/YACReaderLibrary/organize_files_coordinator.cpp b/YACReaderLibrary/organize_files_coordinator.cpp deleted file mode 100644 index fa5d67660..000000000 --- a/YACReaderLibrary/organize_files_coordinator.cpp +++ /dev/null @@ -1,241 +0,0 @@ -#include "organize_files_coordinator.h" - -#include "comic_model.h" -#include "db_helper.h" -#include "folder_model.h" -#include "organize_files_dialog.h" -#include "organize_files_preview_dialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace { -void collectComicsRecursively(qulonglong libraryId, qulonglong folderId, QList &out) -{ - const auto comics = DBHelper::getFolderComicsFromLibrary(libraryId, folderId); - for (auto *item : comics) { - if (auto *comic = static_cast(item)) - out.append(*comic); - } - qDeleteAll(comics); - - const auto subfolders = DBHelper::getFolderSubfoldersFromLibrary(libraryId, folderId); - for (auto *item : subfolders) { - collectComicsRecursively(libraryId, item->id, out); - } - qDeleteAll(subfolders); -} - -void removeEmptyDirs(const QString &basePath) -{ - QDir base(basePath); - const auto entries = base.entryList(QDir::Dirs | QDir::NoDotAndDotDot); - for (const QString &entry : entries) { - const QString childPath = base.absoluteFilePath(entry); - removeEmptyDirs(childPath); - QDir().rmdir(childPath); - } -} - -QString uniqueDestination(const QString &destination, const QSet &taken) -{ - if (!QFileInfo::exists(destination) && !taken.contains(destination)) - return destination; - - const QFileInfo destInfo(destination); - const QString dir = destInfo.absolutePath(); - const QString base = destInfo.completeBaseName(); - const QString suffix = destInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + destInfo.suffix(); - int counter = 1; - QString candidate; - do { - candidate = QDir::cleanPath(dir + QStringLiteral("/") + base + QStringLiteral(" (") + QString::number(counter++) + QStringLiteral(")") + suffix); - } while (QFileInfo::exists(candidate) || taken.contains(candidate)); - return candidate; -} -} - -OrganizeFilesCoordinator::OrganizeFilesCoordinator(QSettings *settings, - QWidget *window, - ComicModel *comicsModel, - FolderModel *foldersModel, - SelectionProvider selectionProvider, - CurrentFolderProvider currentFolderProvider, - CurrentLibraryProvider currentLibraryProvider) - : QObject(window), settings(settings), window(window), comicsModel(comicsModel), foldersModel(foldersModel), selectionProvider(std::move(selectionProvider)), currentFolderProvider(std::move(currentFolderProvider)), currentLibraryProvider(std::move(currentLibraryProvider)) -{ -} - -void OrganizeFilesCoordinator::organizeCurrentFolder() -{ - const auto folderIndex = currentFolderProvider(); - if (!folderIndex.isValid()) - return; - - const auto library = currentLibraryProvider(); - const auto folder = foldersModel->getFolder(folderIndex); - const auto folderPath = QDir::cleanPath(library.rootPath + foldersModel->getFolderPath(folderIndex)); - - if (organizeFolder(library.id, folder.id, library.rootPath, folderPath)) - emit folderRefreshRequested(folderIndex); -} - -void OrganizeFilesCoordinator::organizeSelectedComics() -{ - const auto selection = selectionProvider(); - if (selection.isEmpty()) - return; - - const auto comics = comicsModel->getComics(selection); - if (comics.isEmpty()) - return; - - const auto folderIndex = currentFolderProvider(); - const auto library = currentLibraryProvider(); - const auto cleanupPath = folderIndex.isValid() - ? QDir::cleanPath(library.rootPath + foldersModel->getFolderPath(folderIndex)) - : QDir::cleanPath(library.rootPath); - - if (!organizeComics(comics, library.rootPath, cleanupPath)) - return; - - if (folderIndex.isValid()) - emit folderRefreshRequested(folderIndex); - else - emit currentSourceReloadRequested(); -} - -bool OrganizeFilesCoordinator::organizeFolder(qulonglong libraryId, - qulonglong folderId, - const QString &libraryRoot, - const QString &folderPath) -{ - QList comics; - collectComicsRecursively(libraryId, folderId, comics); - - if (comics.isEmpty()) { - QMessageBox::information(window, tr("Organize files"), tr("This folder does not contain any comics to organize.")); - return false; - } - - return organizeComics(comics, libraryRoot, folderPath); -} - -bool OrganizeFilesCoordinator::organizeComics(const QList &comics, - const QString &libraryRoot, - const QString &cleanupPath) -{ - const QString cleanLibraryRoot = QDir::cleanPath(libraryRoot); - - OrganizeFilesDialog dialog(cleanLibraryRoot, cleanupPath, settings, window); - if (dialog.exec() != QDialog::Accepted) - return false; - - const QString pattern = dialog.formatPattern(); - if (pattern.trimmed().isEmpty()) - return false; - - using Move = OrganizeFilesPreviewDialog::Move; - QList moves; - QSet takenDestinations; - const QDir destinationRoot(dialog.relativeToRoot() ? cleanLibraryRoot : cleanupPath); - - QHash seriesNumberWidth; - for (const ComicDB &comic : comics) { - const QString series = comic.info.series.toString().trimmed(); - bool ok = false; - const int value = comic.info.number.toString().trimmed().toInt(&ok); - if (!ok) - continue; - const int width = QString::number(value).size(); - int ¤t = seriesNumberWidth[series]; - current = std::max(current, width); - } - - for (const ComicDB &comic : comics) { - const QString source = QDir::cleanPath(cleanLibraryRoot + comic.path); - const QFileInfo sourceInfo(source); - if (!sourceInfo.exists()) - continue; - - const QString extension = sourceInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + sourceInfo.suffix(); - - const int numberPadding = seriesNumberWidth.value(comic.info.series.toString().trimmed(), 0); - - const QString relative = OrganizeFilesDialog::buildRelativePath(pattern, - comic.info.publisher.toString(), - comic.info.series.toString(), - comic.info.number.toString(), - comic.info.title.toString(), - comic.info.volume.toString(), - comic.info.year.toString(), - extension, - numberPadding); - - QString destination = QDir::cleanPath(destinationRoot.absoluteFilePath(relative)); - if (destination == QDir::cleanPath(source)) - continue; - - destination = uniqueDestination(destination, takenDestinations); - takenDestinations.insert(destination); - - moves.append({ source, destination }); - } - - if (moves.isEmpty()) { - QMessageBox::information(window, tr("Organize files"), tr("All files are already organized according to this format.")); - return false; - } - - OrganizeFilesPreviewDialog preview(destinationRoot.absolutePath(), cleanLibraryRoot, moves, window); - if (preview.exec() != QDialog::Accepted) - return false; - - QList finalMoves; - QSet finalTaken; - for (const Move &move : preview.moves()) { - if (QDir::cleanPath(move.destination) == QDir::cleanPath(move.source)) - continue; - const QString destination = uniqueDestination(move.destination, finalTaken); - finalTaken.insert(destination); - finalMoves.append({ move.source, destination }); - } - - if (finalMoves.isEmpty()) - return false; - - int moved = 0; - QStringList failures; - for (const Move &move : finalMoves) { - const QString targetDir = QFileInfo(move.destination).absolutePath(); - if (!QDir().mkpath(targetDir)) { - failures << move.source; - continue; - } - if (QFile::rename(move.source, move.destination)) - moved++; - else - failures << move.source; - } - - removeEmptyDirs(cleanupPath); - - if (!failures.isEmpty()) { - QMessageBox::warning(window, tr("Organize files"), - tr("%1 of %2 file(s) were moved. %3 file(s) could not be moved.") - .arg(moved) - .arg(finalMoves.size()) - .arg(failures.size())); - } - - return moved > 0; -} diff --git a/YACReaderLibrary/organize_files_dialog.cpp b/YACReaderLibrary/organize_files_dialog.cpp deleted file mode 100644 index 388b3f2fc..000000000 --- a/YACReaderLibrary/organize_files_dialog.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "organize_files_dialog.h" - -#include "yacreader_global.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -OrganizeFilesDialog::OrganizeFilesDialog(const QString &libraryRoot, - const QString &selectedFolderPath, - QSettings *settings, - QWidget *parent) - : QDialog(parent), libraryRoot(libraryRoot), selectedFolderPath(selectedFolderPath), settings(settings) -{ - setupUI(); -} - -QString OrganizeFilesDialog::defaultPattern() -{ - return QStringLiteral("{publisher}/{series}/#{number} {title}"); -} - -void OrganizeFilesDialog::setupUI() -{ - auto description = new QLabel(tr("Files will be moved into subfolders following the format below. " - "Each part separated by \"/\" becomes a folder, except the last one which becomes the file name.")); - description->setWordWrap(true); - - auto tokensLabel = new QLabel(tr("Available tokens: %1") - .arg(QStringLiteral("{publisher} {series} {number} {title} {volume} {year}"))); - tokensLabel->setWordWrap(true); - - auto hintLabel = new QLabel(tr("{title} falls back to the series name when the comic has no title.")); - hintLabel->setWordWrap(true); - - patternEdit = new QLineEdit(defaultPattern()); - connect(patternEdit, &QLineEdit::textChanged, this, &OrganizeFilesDialog::updatePreview); - - relativeToRootCheck = new QCheckBox(tr("Place folders relative to the library root")); - relativeToRootCheck->setToolTip(tr("When enabled, the format is applied from the library root instead of the " - "selected folder, so it is not nested inside the folder being organized.")); - const bool relativeToRoot = settings ? settings->value(ORGANIZE_FILES_RELATIVE_TO_ROOT, true).toBool() : true; - relativeToRootCheck->setChecked(relativeToRoot); - connect(relativeToRootCheck, &QCheckBox::toggled, this, [this](bool checked) { - if (settings) - settings->setValue(ORGANIZE_FILES_RELATIVE_TO_ROOT, checked); - updatePreview(); - }); - - previewLabel = new QLabel; - previewLabel->setWordWrap(true); - previewLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - - auto mainLayout = new QVBoxLayout; - mainLayout->addWidget(description); - mainLayout->addWidget(new QLabel(tr("Format:"))); - mainLayout->addWidget(patternEdit); - mainLayout->addWidget(relativeToRootCheck); - mainLayout->addWidget(tokensLabel); - mainLayout->addWidget(hintLabel); - mainLayout->addSpacing(8); - mainLayout->addWidget(previewLabel); - mainLayout->addStretch(); - mainLayout->addWidget(buttonBox); - - setLayout(mainLayout); - setModal(true); - setWindowTitle(tr("Organize files")); - resize(480, sizeHint().height()); - - updatePreview(); -} - -QString OrganizeFilesDialog::formatPattern() const -{ - return patternEdit->text(); -} - -bool OrganizeFilesDialog::relativeToRoot() const -{ - return relativeToRootCheck->isChecked(); -} - -void OrganizeFilesDialog::updatePreview() -{ - const QString relative = buildRelativePath(patternEdit->text(), - QStringLiteral("Marvel"), - QStringLiteral("The Amazing Spider-Man"), - QStringLiteral("42"), - QStringLiteral("The Sinister Six"), - QStringLiteral("1"), - QStringLiteral("2018"), - QStringLiteral(".cbz")); - - const QString base = relativeToRootCheck->isChecked() ? libraryRoot : selectedFolderPath; - const QString example = base.isEmpty() ? relative : QDir::cleanPath(base + QLatin1Char('/') + relative); - previewLabel->setText(tr("Example: %1").arg(example)); -} - -QString OrganizeFilesDialog::sanitizeSegment(QString segment) -{ - static const QString invalid = QStringLiteral("<>:\"/\\|?*"); - for (QChar &c : segment) { - if (invalid.contains(c) || c < QChar(0x20)) - c = QLatin1Char('_'); - } - segment = segment.simplified(); - while (segment.endsWith(QLatin1Char('.')) || segment.endsWith(QLatin1Char(' '))) - segment.chop(1); - return segment; -} - -QString OrganizeFilesDialog::padNumber(const QString &number, int width) -{ - const QString trimmed = number.trimmed(); - if (width <= 0 || trimmed.isEmpty()) - return trimmed; - - int digits = 0; - while (digits < trimmed.size() && trimmed.at(digits).isDigit()) - ++digits; - - if (digits == 0) - return trimmed; - - QString leading = trimmed.left(digits); - while (leading.size() < width) - leading.prepend(QLatin1Char('0')); - - return leading + trimmed.mid(digits); -} - -QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, - const QString &publisher, - const QString &series, - const QString &number, - const QString &title, - const QString &volume, - const QString &year, - const QString &extension, - int numberPadding) -{ - const QString safeSeries = series.trimmed().isEmpty() ? tr("Unknown Series") : series.trimmed(); - const QString safePublisher = publisher.trimmed().isEmpty() ? tr("Unknown Publisher") : publisher.trimmed(); - const QString effectiveTitle = title.trimmed().isEmpty() ? safeSeries : title.trimmed(); - - QString result = pattern; - result.replace(QStringLiteral("{publisher}"), safePublisher); - result.replace(QStringLiteral("{series}"), safeSeries); - result.replace(QStringLiteral("{number}"), padNumber(number, numberPadding)); - result.replace(QStringLiteral("{title}"), effectiveTitle); - result.replace(QStringLiteral("{volume}"), volume.trimmed()); - result.replace(QStringLiteral("{year}"), year.trimmed()); - - const QStringList rawSegments = result.split(QLatin1Char('/'), Qt::SkipEmptyParts); - QStringList segments; - for (const QString &raw : rawSegments) { - const QString clean = sanitizeSegment(raw); - if (!clean.isEmpty()) - segments << clean; - } - - if (segments.isEmpty()) - segments << sanitizeSegment(effectiveTitle); - - QString relativePath = segments.join(QLatin1Char('/')); - if (!extension.isEmpty()) - relativePath += extension; - return relativePath; -} diff --git a/YACReaderLibrary/organize_files_dialog.h b/YACReaderLibrary/organize_files_dialog.h deleted file mode 100644 index e87e5320b..000000000 --- a/YACReaderLibrary/organize_files_dialog.h +++ /dev/null @@ -1,77 +0,0 @@ -#ifndef ORGANIZE_FILES_DIALOG_H -#define ORGANIZE_FILES_DIALOG_H - -#include - -class QLineEdit; -class QLabel; -class QCheckBox; -class QSettings; - -// Dialog that lets the user define the path/name format used to organize comic -// files on disk. The format is a path template where each path segment becomes a -// directory, except the last one which becomes the file name (the original -// extension is kept). -// -// Supported tokens: {publisher} {series} {number} {title} {volume} {year} -// {title} falls back to {series} when the comic has no title. -class OrganizeFilesDialog : public QDialog -{ - Q_OBJECT -public: - // libraryRoot and selectedFolderPath are absolute paths used to render a - // realistic preview and to reflect the "relative to library root" toggle. - // settings persists that toggle across runs (may be null). - explicit OrganizeFilesDialog(const QString &libraryRoot, - const QString &selectedFolderPath, - QSettings *settings = nullptr, - QWidget *parent = nullptr); - - // Returns the format pattern entered by the user. - QString formatPattern() const; - - // Whether the destination should be rooted at the library root (true) or at - // the currently selected folder (false). - bool relativeToRoot() const; - - // Default format used when none has been configured yet. - static QString defaultPattern(); - - // Builds the relative destination path (directories + file name, including - // the given extension) for a comic, applying token substitution and - // sanitizing every path segment. The extension should include the leading - // dot (e.g. ".cbz"); pass an empty string for none. When numberPadding is - // greater than zero the {number} token is zero-padded to that width. - static QString buildRelativePath(const QString &pattern, - const QString &publisher, - const QString &series, - const QString &number, - const QString &title, - const QString &volume, - const QString &year, - const QString &extension, - int numberPadding = 0); - - // Replaces characters that are invalid in a path segment and trims it. - static QString sanitizeSegment(QString segment); - - // Zero-pads the leading digits of an issue number to at least "width" - // characters (e.g. "1" -> "01"). Non-numeric prefixes are left untouched. - static QString padNumber(const QString &number, int width); - -private slots: - void updatePreview(); - -private: - QLineEdit *patternEdit; - QLabel *previewLabel; - QCheckBox *relativeToRootCheck; - - QString libraryRoot; - QString selectedFolderPath; - QSettings *settings; - - void setupUI(); -}; - -#endif // ORGANIZE_FILES_DIALOG_H diff --git a/YACReaderLibrary/organize_files_preview_dialog.cpp b/YACReaderLibrary/organize_files_preview_dialog.cpp deleted file mode 100644 index a94900616..000000000 --- a/YACReaderLibrary/organize_files_preview_dialog.cpp +++ /dev/null @@ -1,253 +0,0 @@ -#include "organize_files_preview_dialog.h" - -#include "organize_files_dialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -// Only the "New location" column (0) may be edited; the source column is -// informational and must stay read-only. -class FirstColumnEditableDelegate : public QStyledItemDelegate -{ -public: - using QStyledItemDelegate::QStyledItemDelegate; - - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override - { - if (index.column() != 0) - return nullptr; - return QStyledItemDelegate::createEditor(parent, option, index); - } -}; -} - -OrganizeFilesPreviewDialog::OrganizeFilesPreviewDialog(const QString &baseRoot, - const QString &libraryRoot, - const QList &moves, - QWidget *parent) - : QDialog(parent), baseRoot(QDir::cleanPath(baseRoot)), libraryRoot(QDir::cleanPath(libraryRoot)) -{ - setupUI(moves); -} - -void OrganizeFilesPreviewDialog::setupUI(const QList &moves) -{ - auto description = new QLabel(tr("%n file(s) will be moved as shown below. Double-click an item in the " - "\"New location\" column to rename a folder or file, or remove items to leave " - "them where they are, before applying the changes.", - "", moves.size())); - description->setWordWrap(true); - - tree = new QTreeWidget; - tree->setColumnCount(2); - tree->setHeaderLabels({ tr("New location"), tr("Current location") }); - tree->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::SelectedClicked | QAbstractItemView::EditKeyPressed); - tree->setItemDelegate(new FirstColumnEditableDelegate(tree)); - tree->setUniformRowHeights(true); - tree->setAlternatingRowColors(true); - tree->setSelectionMode(QAbstractItemView::ExtendedSelection); - - removeAction = new QAction(tr("Remove from list"), this); - removeAction->setShortcut(QKeySequence::Delete); - removeAction->setShortcutContext(Qt::WidgetShortcut); - connect(removeAction, &QAction::triggered, this, &OrganizeFilesPreviewDialog::removeSelectedItems); - tree->addAction(removeAction); - tree->setContextMenuPolicy(Qt::ActionsContextMenu); - connect(tree, &QTreeWidget::itemSelectionChanged, this, &OrganizeFilesPreviewDialog::updateActionsState); - - buildTree(moves); - - tree->expandAll(); - tree->resizeColumnToContents(0); - tree->header()->setStretchLastSection(true); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - okButton = buttonBox->button(QDialogButtonBox::Ok); - okButton->setText(tr("Move files")); - removeButton = buttonBox->addButton(tr("Remove selected"), QDialogButtonBox::ActionRole); - connect(removeButton, &QPushButton::clicked, this, &OrganizeFilesPreviewDialog::removeSelectedItems); - connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - - auto mainLayout = new QVBoxLayout; - mainLayout->addWidget(description); - mainLayout->addWidget(tree); - mainLayout->addWidget(buttonBox); - - setLayout(mainLayout); - setModal(true); - setWindowTitle(tr("Organize files")); - resize(680, 520); - - updateActionsState(); -} - -void OrganizeFilesPreviewDialog::buildTree(const QList &moves) -{ - const QDir base(baseRoot); - const QIcon folderIcon = qApp->style()->standardIcon(QStyle::SP_DirIcon); - const QIcon fileIcon = qApp->style()->standardIcon(QStyle::SP_FileIcon); - - // Sort moves by destination so the tree is built in a stable, readable order. - QList sortedMoves = moves; - std::sort(sortedMoves.begin(), sortedMoves.end(), [&base](const Move &a, const Move &b) { - return base.relativeFilePath(a.destination).compare(base.relativeFilePath(b.destination), Qt::CaseInsensitive) < 0; - }); - - // Maps a cumulative relative directory path to its folder item. - QHash folders; - - for (const Move &move : sortedMoves) { - const QString relative = base.relativeFilePath(move.destination); - const QStringList segments = relative.split(QLatin1Char('/'), Qt::SkipEmptyParts); - if (segments.isEmpty()) - continue; - - QTreeWidgetItem *parent = nullptr; - QString cumulative; - // Build/reuse the folder nodes for every segment except the last (the file). - for (int i = 0; i < segments.size() - 1; ++i) { - cumulative += (cumulative.isEmpty() ? QString() : QStringLiteral("/")) + segments.at(i); - QTreeWidgetItem *&folderItem = folders[cumulative]; - if (folderItem == nullptr) { - folderItem = parent ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree); - folderItem->setText(0, segments.at(i)); - folderItem->setIcon(0, folderIcon); - folderItem->setFlags(folderItem->flags() | Qt::ItemIsEditable); - } - parent = folderItem; - } - - QTreeWidgetItem *fileItem = parent ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree); - fileItem->setText(0, segments.last()); - fileItem->setIcon(0, fileIcon); - fileItem->setFlags(fileItem->flags() | Qt::ItemIsEditable); - fileItem->setData(0, SourceRole, move.source); - - const QString sourceRelative = libraryRoot.isEmpty() ? move.source : QDir(libraryRoot).relativeFilePath(move.source); - fileItem->setText(1, sourceRelative); - fileItem->setToolTip(1, move.source); - } -} - -bool OrganizeFilesPreviewDialog::isFileItem(QTreeWidgetItem *item) const -{ - return item != nullptr && item->data(0, SourceRole).isValid(); -} - -void OrganizeFilesPreviewDialog::pruneEmptyAncestors(QTreeWidgetItem *item) -{ - // Delete folder nodes that no longer hold any files, walking up the tree. - while (item != nullptr && item->childCount() == 0 && !isFileItem(item)) { - QTreeWidgetItem *parent = item->parent(); - delete item; - item = parent; - } -} - -void OrganizeFilesPreviewDialog::removeSelectedItems() -{ - const QList selected = tree->selectedItems(); - if (selected.isEmpty()) - return; - - const QSet selectedSet(selected.begin(), selected.end()); - - // Only delete the top-most selected items; children of an already-selected - // item would be deleted along with their parent. - QList toDelete; - QList parents; - for (QTreeWidgetItem *item : selected) { - bool ancestorSelected = false; - for (QTreeWidgetItem *ancestor = item->parent(); ancestor != nullptr; ancestor = ancestor->parent()) { - if (selectedSet.contains(ancestor)) { - ancestorSelected = true; - break; - } - } - if (!ancestorSelected) { - toDelete.append(item); - parents.append(item->parent()); - } - } - - for (QTreeWidgetItem *item : toDelete) - delete item; - - for (QTreeWidgetItem *parent : parents) - pruneEmptyAncestors(parent); - - updateActionsState(); -} - -void OrganizeFilesPreviewDialog::updateActionsState() -{ - const bool hasSelection = !tree->selectedItems().isEmpty(); - removeAction->setEnabled(hasSelection); - if (removeButton != nullptr) - removeButton->setEnabled(hasSelection); - - bool hasFiles = false; - QTreeWidgetItemIterator it(tree); - while (*it) { - if (isFileItem(*it)) { - hasFiles = true; - break; - } - ++it; - } - if (okButton != nullptr) - okButton->setEnabled(hasFiles); -} - -QString OrganizeFilesPreviewDialog::relativePathForItem(QTreeWidgetItem *item) const -{ - QStringList segments; - for (QTreeWidgetItem *node = item; node != nullptr; node = node->parent()) { - const QString clean = OrganizeFilesDialog::sanitizeSegment(node->text(0)); - if (!clean.isEmpty()) - segments.prepend(clean); - } - return segments.join(QLatin1Char('/')); -} - -QList OrganizeFilesPreviewDialog::moves() const -{ - QList result; - - QTreeWidgetItemIterator it(tree); - while (*it) { - QTreeWidgetItem *item = *it; - ++it; - - // Leaves (files) carry the source path. - if (item->childCount() != 0) - continue; - const QVariant sourceData = item->data(0, SourceRole); - if (!sourceData.isValid()) - continue; - - const QString relative = relativePathForItem(item); - if (relative.isEmpty()) - continue; - - Move move; - move.source = sourceData.toString(); - move.destination = QDir::cleanPath(baseRoot + QLatin1Char('/') + relative); - result.append(move); - } - - return result; -} diff --git a/YACReaderLibrary/organize_files_preview_dialog.h b/YACReaderLibrary/organize_files_preview_dialog.h deleted file mode 100644 index c35bf3bc4..000000000 --- a/YACReaderLibrary/organize_files_preview_dialog.h +++ /dev/null @@ -1,50 +0,0 @@ -#ifndef ORGANIZE_FILES_PREVIEW_DIALOG_H -#define ORGANIZE_FILES_PREVIEW_DIALOG_H - -#include -#include -#include - -class QAction; -class QPushButton; -class QTreeWidget; -class QTreeWidgetItem; - -class OrganizeFilesPreviewDialog : public QDialog -{ - Q_OBJECT -public: - struct Move { - QString source; - QString destination; - }; - - OrganizeFilesPreviewDialog(const QString &baseRoot, - const QString &libraryRoot, - const QList &moves, - QWidget *parent = nullptr); - - QList moves() const; - -private slots: - void removeSelectedItems(); - void updateActionsState(); - -private: - QString baseRoot; - QString libraryRoot; - QTreeWidget *tree; - QAction *removeAction; - QPushButton *removeButton; - QPushButton *okButton; - - void setupUI(const QList &moves); - void buildTree(const QList &moves); - QString relativePathForItem(QTreeWidgetItem *item) const; - bool isFileItem(QTreeWidgetItem *item) const; - void pruneEmptyAncestors(QTreeWidgetItem *item); - - static constexpr int SourceRole = Qt::UserRole + 1; -}; - -#endif // ORGANIZE_FILES_PREVIEW_DIALOG_H diff --git a/YACReaderLibrary/themes/theme.h b/YACReaderLibrary/themes/theme.h index acad7291a..445beead8 100644 --- a/YACReaderLibrary/themes/theme.h +++ b/YACReaderLibrary/themes/theme.h @@ -423,6 +423,7 @@ struct ComicsViewToolbarTheme { QIcon setAsMangaIcon; QIcon editComicIcon; QIcon getInfoIcon; + QIcon organizeIcon; QIcon assignNumberIcon; QIcon selectAllIcon; QIcon deleteIcon; diff --git a/YACReaderLibrary/themes/theme_factory.cpp b/YACReaderLibrary/themes/theme_factory.cpp index 49a80613e..84557f177 100644 --- a/YACReaderLibrary/themes/theme_factory.cpp +++ b/YACReaderLibrary/themes/theme_factory.cpp @@ -825,6 +825,7 @@ Theme makeTheme(const ThemeParams ¶ms) theme.comicsViewToolbar.setAsMangaIcon = makeComicsViewIcon(":/images/comics_view_toolbar/setManga.svg"); theme.comicsViewToolbar.editComicIcon = makeComicsViewIcon(":/images/comics_view_toolbar/editComic.svg"); theme.comicsViewToolbar.getInfoIcon = makeComicsViewIcon(":/images/comics_view_toolbar/getInfo.svg"); + theme.comicsViewToolbar.organizeIcon = makeComicsViewIcon(":/images/comics_view_toolbar/organize.svg"); theme.comicsViewToolbar.assignNumberIcon = makeComicsViewIcon(":/images/comics_view_toolbar/asignNumber.svg"); theme.comicsViewToolbar.selectAllIcon = makeComicsViewIcon(":/images/comics_view_toolbar/selectAll.svg"); theme.comicsViewToolbar.deleteIcon = makeComicsViewIcon(":/images/comics_view_toolbar/trash.svg"); diff --git a/YACReaderLibrary/yacreaderlibrary_de.ts b/YACReaderLibrary/yacreaderlibrary_de.ts index 7bd9e5876..045d50e5a 100644 --- a/YACReaderLibrary/yacreaderlibrary_de.ts +++ b/YACReaderLibrary/yacreaderlibrary_de.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + Der Ordnereintrag wurde in der Datenbank der Bibliothek nicht gefunden. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Neuen Ordner erstellen - + Folder name: Ordnername @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. Der ausgewählte Ordner enthält keine Bibliothek. @@ -1059,7 +1059,7 @@ Bibliothek '%1' ist nicht mehr verfügbar. Wollen Sie sie entfernen? - + Do you want remove Möchten Sie entfernen @@ -1079,7 +1079,7 @@ Es gab ein Problem beim Löschen der ausgewählten Comics. Überprüfen Sie bitte die Schreibberechtigung für die ausgewählten Dateien oder Ordner. - + YACReader Library YACReader Bibliothek @@ -1089,12 +1089,12 @@ Update benötigt - + Library name already exists Bibliothek-Name bereits vorhanden - + There is another library with the name '%1'. Es gibt bereits eine Bibliothek mit dem Namen '%1'. @@ -1114,28 +1114,28 @@ Alle ausgewählten Comics werden von Ihrer Festplatte gelöscht. Sind Sie sicher? - + Library not found Bibliothek nicht gefunden - + Unable to delete Löschen nicht möglich - + library? Bibliothek? - + Are you sure? Sind Sie sicher? - + Delete folder Ordner löschen @@ -1160,72 +1160,82 @@ Verschieben von Comics... - + Folder name: Ordnername - - - + + + No folder selected Kein Ordner ausgewählt - - - + + + Please, select a folder first Bitte wählen Sie zuerst einen Ordner aus - + Error in path Fehler im Pfad - + There was an error accessing the folder's path Beim Aufrufen des Ordnerpfades kam es zu einem Fehler - + The selected folder and all its contents will be deleted from your disk. Are you sure? Der ausgewählte Ordner und sein gesamter Inhalt wird von Ihrer Festplatte gelöscht. Sind Sie sicher? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Beim Löschen des ausgewählten Ordners ist ein Problem aufgetreten. Bitte überprüfen Sie die Schreibrechte und stellen Sie sicher, dass keine Anwendung diese Ordner oder die darin enthaltenen Dateien verwendet. - + + Rename or organize files + Dateien umbenennen oder organisieren + + + + Set the type of the selected comics + Typ der ausgewählten Comics festlegen + + + Search filters Suchfilter - + Unread Ungelesen - + In progress In Bearbeitung - + Highly rated Hoch bewertet - + Recently added Kürzlich hinzugefügt - + Search syntax… Suchsyntax… @@ -1250,14 +1260,14 @@ Wenn Sie sicher sind, dass keine andere Reparatur läuft, kann die Sperre entfernt werden. Sperre entfernen und fortfahren? - + Package operation failed - + Paketvorgang fehlgeschlagen - + The covers package operation could not be completed. - + Der Vorgang mit dem Cover-Paket konnte nicht abgeschlossen werden. @@ -1265,48 +1275,50 @@ Wiederherstellung nach Abbruch fehlgeschlagen - + Rename folder Ordner umbenennen - + Invalid folder name - + Ungültiger Ordnername - + The folder name is empty or contains characters that are not supported. - + Der Ordnername ist leer oder enthält nicht unterstützte Zeichen. - - - + + + Unable to rename folder - + Ordner kann nicht umbenannt werden - + A file or folder named '%1' already exists. - + Eine Datei oder ein Ordner mit dem Namen „%1“ existiert bereits. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + Der Ordner konnte auf dem Datenträger nicht umbenannt werden. Bitte prüfen Sie den Ordnernamen und die Schreibrechte. + +Ordner: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + Die Datenbank der Bibliothek konnte nicht aktualisiert werden. Die Umbenennung des Ordners auf dem Datenträger wurde rückgängig gemacht. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + Die Datenbank der Bibliothek konnte nicht aktualisiert werden, und die Umbenennung des Ordners auf dem Datenträger konnte nicht rückgängig gemacht werden. Die Bibliothek muss jetzt manuell aktualisiert werden. @@ -1314,12 +1326,12 @@ Folder: %1 Titelbilder speichern - + You are adding too many libraries. Sie fügen zu viele Bibliotheken hinzu. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1484,12 +1496,12 @@ You can restore a backup from the Library menu or recreate the library. Sie können über das Bibliotheksmenü eine Sicherung wiederherstellen oder die Bibliothek neu erstellen. - + Remove and delete metadata and backups Metadaten und Sicherungen entfernen und löschen - + Library info Informationen zur Bibliothek @@ -1504,22 +1516,22 @@ Sie können über das Bibliotheksmenü eine Sicherung wiederherstellen oder die Nummern zuweisen, beginnend mit: - + Invalid image Ungültiges Bild - + The selected file is not a valid image. Die ausgewählte Datei ist kein gültiges Bild. - + Error saving cover Fehler beim Speichern des Covers - + There was an error saving the cover image. Beim Speichern des Titelbildes ist ein Fehler aufgetreten. @@ -1700,7 +1712,7 @@ Fehlende Dateien: %3 - + Set as read Als gelesen markieren @@ -1711,7 +1723,7 @@ Fehlende Dateien: %3 - + Set as unread Als ungelesen markieren @@ -1722,7 +1734,7 @@ Fehlende Dateien: %3 - + manga Manga @@ -1733,7 +1745,7 @@ Fehlende Dateien: %3 - + comic komisch @@ -1754,7 +1766,7 @@ Fehlende Dateien: %3 - + web comic Webcomic @@ -1765,7 +1777,7 @@ Fehlende Dateien: %3 - + yonkoma Yonkoma @@ -1823,7 +1835,7 @@ Fehlende Dateien: %3 Rename the current folder on disk and in the library - + Den aktuellen Ordner auf dem Datenträger und in der Bibliothek umbenennen @@ -1873,37 +1885,44 @@ Fehlende Dateien: %3 - - Organize files - + + Rename files... + Organize files + Dateien umbenennen... + + + + + Organize into folders... + In Ordner organisieren... - + Set as uncompleted Als nicht gelesen markieren - + Set as completed Als gelesen markieren - + Set custom cover Legen Sie ein benutzerdefiniertes Cover fest - + Delete custom cover Benutzerdefiniertes Cover löschen - + western manga (left to right) Western-Manga (von links nach rechts) - + Open containing folder... Öffne aktuellen Ordner... @@ -1912,133 +1931,133 @@ Fehlende Dateien: %3 Comic-Bewertung zurücksetzen - + Select all comics Alle Comics auswählen - + Edit Bearbeiten - + Assign current order to comics Aktuele Sortierung auf Comics anwenden - + Update cover Titelbild updaten - + Delete selected comics Ausgewählte Comics löschen - + Delete metadata from selected comics Metadaten aus ausgewählten Comics löschen - + Download tags from Comic Vine Tags von Comic Vine herunterladen - + Focus search line Suchzeile fokussieren - + Focus comics view Fokus-Comic-Ansicht - + Edit shortcuts Kürzel bearbeiten - + &Quit &Schließen - + Update folder Ordner aktualisieren - + Update current folder Aktuellen Ordner aktualisieren - + Scan legacy XML metadata Scannen Sie ältere XML-Metadaten - + Add new reading list Neue Leseliste hinzufügen - + Add a new reading list to the current library Neue Leseliste zur aktuellen Bibliothek hinzufügen - + Remove reading list Leseliste entfernen - + Remove current reading list from the library Aktuelle Leseliste von der Bibliothek entfernen - + Add new label Neues Label hinzufügen - + Add a new label to this library Neues Label zu dieser Bibliothek hinzufügen - + Rename selected list Ausgewählte Liste umbenennen - + Rename any selected labels or lists Ausgewählte Labels oder Listen umbenennen - + Add to... Hinzufügen zu... - + Favorites Favoriten - + Add selected comics to favorites list Ausgewählte Comics zu Favoriten hinzufügen - + Reset rating Bewertung zurücksetzen @@ -2073,8 +2092,8 @@ Fehlende Dateien: %3 - - + + Set type Typ festlegen @@ -2094,53 +2113,53 @@ Fehlende Dateien: %3 Comic - + Open folder... Öffne Ordner... - + Update folder Ordner aktualisieren - + Rename folder Ordner umbenennen - + Rescan library for XML info Durchsuchen Sie die Bibliothek erneut nach XML-Informationen - + Set as uncompleted Als nicht gelesen markieren - + Set as completed Als gelesen markieren - + Set as read Als gelesen markieren - - + + Set as unread Als ungelesen markieren - + Set custom cover Legen Sie ein benutzerdefiniertes Cover fest - + Delete custom cover Benutzerdefiniertes Cover löschen @@ -2476,123 +2495,547 @@ Um eine automatische Aktualisierung zu stoppen, tippen Sie auf die Ladeanzeige n Neustart erforderlich + + OrganizeFiles + + + Renamed, %1 is already in use + Umbenannt, %1 wird bereits verwendet + + + + Missing metadata: %1 + Fehlende Metadaten: %1 + + + + %1 could not be created + %1 konnte nicht erstellt werden + + OrganizeFilesCoordinator - - - + + Organize files - + Dateien organisieren + + + + This folder does not contain any comics. + Dieser Ordner enthält keine Comics. + + + + This library is busy: %1 + Diese Bibliothek ist belegt: %1 + + + + the library database could not be opened + die Datenbank der Bibliothek konnte nicht geöffnet werden + + + + the library database could not be locked for writing + die Datenbank der Bibliothek konnte nicht zum Schreiben gesperrt werden + + + + a folder entry could not be restored + ein Ordnereintrag konnte nicht wiederhergestellt werden + + + + a comic entry could not be updated + ein Comic-Eintrag konnte nicht aktualisiert werden - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + die Datenbank der Bibliothek konnte nicht gespeichert werden: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + die Aufzeichnung des letzten Organisierens konnte nicht gelesen werden - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + der Ordner %1 konnte nicht erstellt werden + + + + %n file(s) could not be moved back + + %n Datei konnte nicht zurückverschoben werden + %n Dateien konnten nicht zurückverschoben werden + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Formatangabe: - - Available tokens: %1 - + + Organize files + Dateien organisieren - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Dateien umbenennen - - Place folders relative to the library root - + + Preparing the preview... + Vorschau wird vorbereitet... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Dateinamenformat: - - Format: - Formatangabe: + + &Path format: + &Pfadformat: - - Organize files - + + Filename format + Dateinamenformat - - Example: %1 - + + Path format + Pfadformat - - Unknown Series - + + Presets + Vorlagen - - Unknown Publisher - + + Insert + Einfügen - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Optionaler Teil < > + + + + Disappears completely when the fields inside it are empty. + Verschwindet vollständig, wenn die Felder darin leer sind. - + + Padded number {number:000} + Nummer mit führenden Nullen {number:000} + + + + Format help... + Hilfe zum Format... + + + + selected folder + ausgewählter Ordner + + + + library root + Wurzel der Bibliothek + + + + Move into + Verschieben nach + + + + Reset changes + Änderungen zurücksetzen + + + + Remove selected + Ausgewählte entfernen + + + + Show unchanged + Unveränderte anzeigen + + + + New name + Neuer Name + + + + Renamed from + Vorheriger Name + + + New location - + Neuer Speicherort - - Current location - + + Moved from + Vorheriger Speicherort - + Remove from list - + Aus der Liste entfernen - + Move files - + Dateien verschieben - - Remove selected - + + Cancel + Abbrechen - - Organize files - + + Copy the list + Liste kopieren + + + + Undo + Rückgängig + + + + Close + Schließen + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Ein Dateinamenformat darf kein "/" enthalten. Verwenden Sie Dateien organisieren, um Comics in Ordner zu verschieben. + + + + This format cannot be used: %1 + Dieses Format kann nicht verwendet werden: %1 + + + + new folder + neuer Ordner + + + + This folder does not exist yet. It will be created. + Dieser Ordner existiert noch nicht. Er wird erstellt. + + + + file not found + Datei nicht gefunden + + + + This comic is in the library but not on disk. It is skipped. + Dieser Comic ist in der Bibliothek, aber nicht auf dem Datenträger. Er wird übersprungen. + + + + name in use + Name belegt + + + + no metadata + keine Metadaten + + + + already here + schon hier + + + + This file is already in the right place. + Diese Datei ist bereits am richtigen Ort. + + + + edited + bearbeitet + + + + %n will be renamed + + %n wird umbenannt + %n werden umbenannt + + + + + %n will move + + %n wird verschoben + %n werden verschoben + + + + + %n unchanged + + %n unverändert + %n unverändert + + + + + %n renamed + + %n umbenannt + %n umbenannt + + + + + %n removed + + %n entfernt + %n entfernt + + + + + %n missing + + %n fehlt + %n fehlen + + + + + %n new folder(s) + + %n neuer Ordner + %n neue Ordner + + + + + %n manual change(s) kept + + %n manuelle Änderung beibehalten + %n manuelle Änderungen beibehalten + + + + + Nothing would be renamed with this format. + Mit diesem Format würde nichts umbenannt. + + + + Nothing would move with this format. + Mit diesem Format würde nichts verschoben. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n Datei wird umbenannt. Die Ordner ändern sich nicht. Sie können das danach rückgängig machen. + %n Dateien werden umbenannt. Die Ordner ändern sich nicht. Sie können das danach rückgängig machen. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n Datei wird nach %1 verschoben. Das ändert Ihre Dateien auf dem Datenträger. Sie können das danach rückgängig machen. + %n Dateien werden nach %1 verschoben. Das ändert Ihre Dateien auf dem Datenträger. Sie können das danach rückgängig machen. + + + + + Moving %1 of %2 +%3 + %1 von %2 wird verschoben +%3 + + + + Updating the library... + Bibliothek wird aktualisiert... + + + + Nothing was moved. + Es wurde nichts verschoben. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + Die Aufzeichnung, mit der dieser Vorgang rückgängig gemacht werden könnte, konnte nicht geschrieben werden. Der Vorgang wurde daher nicht gestartet: %1 + + + + %n file(s) renamed. + + %n Datei umbenannt. + %n Dateien umbenannt. + + + + + %n file(s) moved into %1. + + %n Datei nach %1 verschoben. + %n Dateien nach %1 verschoben. + + + + + The record of this run stopped early, so the run stopped with it: %1 + Die Aufzeichnung dieses Vorgangs endete vorzeitig, deshalb wurde der Vorgang mit ihr beendet: %1 + + + + %n file(s) were not moved. + + %n Datei wurde nicht verschoben. + %n Dateien wurden nicht verschoben. + + + + + The library database could not be updated: %1 + Die Datenbank der Bibliothek konnte nicht aktualisiert werden: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Verwenden Sie Rückgängig, um die Dateien zurückzuverschieben, oder aktualisieren Sie die Bibliothek, damit sie zu den Dateien passt. + + + + %n empty folder(s) were removed. + + %n leerer Ordner wurde entfernt. + %n leere Ordner wurden entfernt. + + + + + %n file(s) could not be moved. + + %n Datei konnte nicht verschoben werden. + %n Dateien konnten nicht verschoben werden. + + + + + Moving the files back... + Dateien werden zurückverschoben... + + + + Moving back %1 of %2 +%3 + %1 von %2 wird zurückverschoben +%3 + + + + Everything was moved back. + Alles wurde zurückverschoben. + + + + The undo did not finish: %1 + Das Rückgängigmachen wurde nicht abgeschlossen: %1 + + + + Format help + Hilfe zum Format + + + + Fields + Felder + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Jedes Feld wird in geschweiften Klammern geschrieben und durch die Metadaten des Comics ersetzt. Das Menü Einfügen listet alle Felder auf. + + + + {series} gives %1 + {series} ergibt %1 + + + + Optional parts + Optionale Teile + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Ein Teil zwischen den Zeichen < und > verschwindet vollständig, wenn alle Felder darin leer sind. Verwenden Sie ihn für Satzzeichen, die zu einem Feld gehören, etwa Klammern oder ein vorangestelltes Nummernzeichen. Text am Anfang oder am Ende eines Namens wird auch ohne ihn gekürzt. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) ohne Jahr ergibt %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> ohne Jahr ergibt %1 + + + + Numbers + Nummern + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Schreiben Sie einen Doppelpunkt und einige Nullen, um die Ausgabennummer aufzufüllen. So bleiben die Ausgaben in einem Dateimanager in der richtigen Reihenfolge. + + + + + Folders + Ordner + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Ein Dateinamenformat darf keinen Schrägstrich enthalten. Jeder Comic bleibt in seinem aktuellen Ordner. Verwenden Sie In Ordner organisieren, um Comics zu verschieben. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Jeder durch einen Schrägstrich getrennte Teil wird zu einem Ordner. Der letzte Teil wird zum Dateinamen. Die ursprüngliche Erweiterung bleibt immer erhalten. diff --git a/YACReaderLibrary/yacreaderlibrary_en.ts b/YACReaderLibrary/yacreaderlibrary_en.ts index b90834d48..b4d05c387 100644 --- a/YACReaderLibrary/yacreaderlibrary_en.ts +++ b/YACReaderLibrary/yacreaderlibrary_en.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + The folder entry could not be found in the library database. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Add new folder - + Folder name: Folder name: @@ -1030,22 +1030,22 @@ LibraryWindow - + Do you want remove Do you want remove - + YACReader Library YACReader Library - + Are you sure? Are you sure? - + Delete folder Delete folder @@ -1115,78 +1115,88 @@ Moving comics... - + Folder name: Folder name: - - - + + + No folder selected No folder selected - - - + + + Please, select a folder first Please, select a folder first - + Error in path Error in path - + There was an error accessing the folder's path There was an error accessing the folder's path - + The selected folder and all its contents will be deleted from your disk. Are you sure? The selected folder and all its contents will be deleted from your disk. Are you sure? - + Unable to delete Unable to delete - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. - + + Rename or organize files + Rename or organize files + + + + Set the type of the selected comics + Set the type of the selected comics + + + Search filters Search filters - + Unread Unread - + In progress In progress - + Highly rated Highly rated - + Recently added Recently added - + Search syntax… Search syntax… @@ -1211,58 +1221,60 @@ If you are sure that no other repair is running, the lock can be removed. Remove the lock and continue? - + Package operation failed - + Package operation failed - + The covers package operation could not be completed. - + The covers package operation could not be completed. - + Rename folder Rename folder - + Invalid folder name - + Invalid folder name - + The folder name is empty or contains characters that are not supported. - + The folder name is empty or contains characters that are not supported. - - - + + + Unable to rename folder - + Unable to rename folder - + A file or folder named '%1' already exists. - + A file or folder named '%1' already exists. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + The folder could not be renamed on disk. Please check the folder name and write permissions. + +Folder: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + The library database could not be updated. The folder rename on disk was reverted. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. @@ -1270,12 +1282,12 @@ Folder: %1 Save covers - + You are adding too many libraries. You are adding too many libraries. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1288,12 +1300,12 @@ You probably only need one library in your top level comics folder, you can brow YACReaderLibrary will not stop you from creating more libraries but you should keep the number of libraries low. - + Library not found Library not found - + The selected folder doesn't contain any library. The selected folder doesn't contain any library. @@ -1450,17 +1462,17 @@ You can restore a backup from the Library menu or recreate the library. You can restore a backup from the Library menu or recreate the library. - + library? library? - + Remove and delete metadata and backups Remove and delete metadata and backups - + Library info Library info @@ -1480,22 +1492,22 @@ You can restore a backup from the Library menu or recreate the library.Assign numbers starting in: - + Invalid image Invalid image - + The selected file is not a valid image. The selected file is not a valid image. - + Error saving cover Error saving cover - + There was an error saving the cover image. There was an error saving the cover image. @@ -1520,12 +1532,12 @@ You can restore a backup from the Library menu or recreate the library.Comics will only be deleted from the current label/list. Are you sure? - + Library name already exists Library name already exists - + There is another library with the name '%1'. There is another library with the name '%1'. @@ -1696,7 +1708,7 @@ Missing files: %3 - + Set as read Set as read @@ -1707,7 +1719,7 @@ Missing files: %3 - + Set as unread Set as unread @@ -1718,7 +1730,7 @@ Missing files: %3 - + manga manga @@ -1729,7 +1741,7 @@ Missing files: %3 - + comic comic @@ -1750,7 +1762,7 @@ Missing files: %3 - + web comic web comic @@ -1761,7 +1773,7 @@ Missing files: %3 - + yonkoma yonkoma @@ -1819,7 +1831,7 @@ Missing files: %3 Rename the current folder on disk and in the library - + Rename the current folder on disk and in the library @@ -1869,37 +1881,44 @@ Missing files: %3 - - Organize files - + + Rename files... + Organize files + Rename files... + + + + + Organize into folders... + Organize into folders... - + Set as uncompleted Set as uncompleted - + Set as completed Set as completed - + Set custom cover Set custom cover - + Delete custom cover Delete custom cover - + western manga (left to right) western manga (left to right) - + Open containing folder... Open containing folder... @@ -1908,133 +1927,133 @@ Missing files: %3 Reset comic rating - + Select all comics Select all comics - + Edit Edit - + Assign current order to comics Assign current order to comics - + Update cover Update cover - + Delete selected comics Delete selected comics - + Delete metadata from selected comics Delete metadata from selected comics - + Download tags from Comic Vine Download tags from Comic Vine - + Focus search line Focus search line - + Focus comics view Focus comics view - + Edit shortcuts Edit shortcuts - + &Quit &Quit - + Update folder Update folder - + Update current folder Update current folder - + Scan legacy XML metadata Scan legacy XML metadata - + Add new reading list Add new reading list - + Add a new reading list to the current library Add a new reading list to the current library - + Remove reading list Remove reading list - + Remove current reading list from the library Remove current reading list from the library - + Add new label Add new label - + Add a new label to this library Add a new label to this library - + Rename selected list Rename selected list - + Rename any selected labels or lists Rename any selected labels or lists - + Add to... Add to... - + Favorites Favorites - + Add selected comics to favorites list Add selected comics to favorites list - + Reset rating Reset rating @@ -2069,8 +2088,8 @@ Missing files: %3 - - + + Set type Set type @@ -2090,53 +2109,53 @@ Missing files: %3 Comic - + Open folder... Open folder... - + Update folder Update folder - + Rename folder Rename folder - + Rescan library for XML info Rescan library for XML info - + Set as uncompleted Set as uncompleted - + Set as completed Set as completed - + Set as read Set as read - - + + Set as unread Set as unread - + Set custom cover Set custom cover - + Delete custom cover Delete custom cover @@ -2472,123 +2491,547 @@ To stop an automatic update tap on the loading indicator next to the Libraries t Restart is needed + + OrganizeFiles + + + Renamed, %1 is already in use + Renamed, %1 is already in use + + + + Missing metadata: %1 + Missing metadata: %1 + + + + %1 could not be created + %1 could not be created + + OrganizeFilesCoordinator - - - + + Organize files - + Organize files + + + + This folder does not contain any comics. + This folder does not contain any comics. + + + + This library is busy: %1 + This library is busy: %1 + + + + the library database could not be opened + the library database could not be opened + + + + the library database could not be locked for writing + the library database could not be locked for writing + + + + a folder entry could not be restored + a folder entry could not be restored + + + + a comic entry could not be updated + a comic entry could not be updated - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + the library database could not be saved: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + the record of the last organize run could not be read - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + the folder %1 could not be created + + + + %n file(s) could not be moved back + + %n file could not be moved back + %n files could not be moved back + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Format: - - Available tokens: %1 - + + Organize files + Organize files - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Rename files - - Place folders relative to the library root - + + Preparing the preview... + Preparing the preview... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Filename format: - - Format: - Format: + + &Path format: + &Path format: - - Organize files - + + Filename format + Filename format - - Example: %1 - + + Path format + Path format - - Unknown Series - + + Presets + Presets - - Unknown Publisher - + + Insert + Insert - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Optional part < > + + + + Disappears completely when the fields inside it are empty. + Disappears completely when the fields inside it are empty. - + + Padded number {number:000} + Padded number {number:000} + + + + Format help... + Format help... + + + + selected folder + selected folder + + + + library root + library root + + + + Move into + Move into + + + + Reset changes + Reset changes + + + + Remove selected + Remove selected + + + + Show unchanged + Show unchanged + + + + New name + New name + + + + Renamed from + Renamed from + + + New location - + New location - - Current location - + + Moved from + Moved from - + Remove from list - + Remove from list - + Move files - + Move files - - Remove selected - + + Cancel + Cancel - - Organize files - + + Copy the list + Copy the list + + + + Undo + Undo + + + + Close + Close + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + A filename format cannot contain "/". Use Organize files to move comics into folders. + + + + This format cannot be used: %1 + This format cannot be used: %1 + + + + new folder + new folder + + + + This folder does not exist yet. It will be created. + This folder does not exist yet. It will be created. + + + + file not found + file not found + + + + This comic is in the library but not on disk. It is skipped. + This comic is in the library but not on disk. It is skipped. + + + + name in use + name in use + + + + no metadata + no metadata + + + + already here + already here + + + + This file is already in the right place. + This file is already in the right place. + + + + edited + edited + + + + %n will be renamed + + %n will be renamed + %n will be renamed + + + + + %n will move + + %n will move + %n will move + + + + + %n unchanged + + %n unchanged + %n unchanged + + + + + %n renamed + + %n renamed + %n renamed + + + + + %n removed + + %n removed + %n removed + + + + + %n missing + + %n missing + %n missing + + + + + %n new folder(s) + + %n new folder + %n new folders + + + + + %n manual change(s) kept + + %n manual change kept + %n manual changes kept + + + + + Nothing would be renamed with this format. + Nothing would be renamed with this format. + + + + Nothing would move with this format. + Nothing would move with this format. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n file will be renamed. The folders do not change. You can undo it afterwards. + %n files will be renamed. The folders do not change. You can undo it afterwards. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n file will move into %1. This changes your files on disk. You can undo it afterwards. + %n files will move into %1. This changes your files on disk. You can undo it afterwards. + + + + + Moving %1 of %2 +%3 + Moving %1 of %2 +%3 + + + + Updating the library... + Updating the library... + + + + Nothing was moved. + Nothing was moved. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + The record this run could be undone from could not be written, so the run did not start: %1 + + + + %n file(s) renamed. + + %n file renamed. + %n files renamed. + + + + + %n file(s) moved into %1. + + %n file moved into %1. + %n files moved into %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + The record of this run stopped early, so the run stopped with it: %1 + + + + %n file(s) were not moved. + + %n file was not moved. + %n files were not moved. + + + + + The library database could not be updated: %1 + The library database could not be updated: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Use Undo to move the files back, or update the library to make it match the files. + + + + %n empty folder(s) were removed. + + %n empty folder was removed. + %n empty folders were removed. + + + + + %n file(s) could not be moved. + + %n file could not be moved. + %n files could not be moved. + + + + + Moving the files back... + Moving the files back... + + + + Moving back %1 of %2 +%3 + Moving back %1 of %2 +%3 + + + + Everything was moved back. + Everything was moved back. + + + + The undo did not finish: %1 + The undo did not finish: %1 + + + + Format help + Format help + + + + Fields + Fields + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + + + + {series} gives %1 + {series} gives %1 + + + + Optional parts + Optional parts + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) with no year gives %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> with no year gives %1 + + + + Numbers + Numbers + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + + + + + Folders + Folders + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. diff --git a/YACReaderLibrary/yacreaderlibrary_es.ts b/YACReaderLibrary/yacreaderlibrary_es.ts index b3bb15ca6..576583bd2 100644 --- a/YACReaderLibrary/yacreaderlibrary_es.ts +++ b/YACReaderLibrary/yacreaderlibrary_es.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + No se ha encontrado la entrada de la carpeta en la base de datos de la biblioteca. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Añadir carpeta - + Folder name: Nombre de la carpeta: @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. La carpeta seleccionada no contiene ninguna biblioteca. @@ -1059,7 +1059,7 @@ La biblioteca '%1' no está disponible. ¿Deseas eliminarla? - + Do you want remove ¿Deseas eliminar la biblioteca @@ -1079,7 +1079,7 @@ Ha habido algún problema intentando borrar los cómics selecionados. Por favor, verifica los permisos de escritura en los arhicovs seleccionados o los directorios que los conienen. - + YACReader Library Biblioteca YACReader @@ -1089,12 +1089,12 @@ Se necesita actualizar - + Library name already exists Ya existe el nombre de la biblioteca - + There is another library with the name '%1'. Hay otra biblioteca con el nombre '%1'. @@ -1114,28 +1114,28 @@ Todos los cómics seleccionados serán borrados de tu disco. ¿Estás seguro? - + Library not found Biblioteca no encontrada - + Unable to delete No se ha podido borrar - + library? ? - + Are you sure? ¿Estás seguro? - + Delete folder Borrar carpeta @@ -1160,72 +1160,82 @@ Moviendo cómics... - + Folder name: Nombre de la carpeta: - - - + + + No folder selected No has selecionado ninguna carpeta - - - + + + Please, select a folder first Por favor, selecciona una carpeta primero - + Error in path Error en la ruta - + There was an error accessing the folder's path Hubo un error al acceder a la ruta de la carpeta - + The selected folder and all its contents will be deleted from your disk. Are you sure? ¿Estás seguro de que deseas eliminar la carpeta seleccionada y todo su contenido de tu disco? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Se produjo un problema al intentar eliminar las carpetas seleccionadas. Por favor, verifica los permisos de escritura y asegúrate de que no haya aplicaciones usando estas carpetas o alguno de los archivos contenidos en ellas. - + + Rename or organize files + Renombrar u organizar archivos + + + + Set the type of the selected comics + Establecer el tipo de los cómics seleccionados + + + Search filters Filtros de búsqueda - + Unread No leído - + In progress En curso - + Highly rated Con valoración alta - + Recently added Añadido recientemente - + Search syntax… Sintaxis de búsqueda… @@ -1250,14 +1260,14 @@ Si está seguro de que no se está ejecutando ninguna otra reparación, se puede eliminar el bloqueo. ¿Eliminar el bloqueo y continuar? - + Package operation failed - + Error en la operación de empaquetado - + The covers package operation could not be completed. - + No se ha podido completar la operación con el paquete de portadas. @@ -1265,48 +1275,50 @@ Error al recuperar la restauración - + Rename folder Renombrar carpeta - + Invalid folder name - + Nombre de carpeta no válido - + The folder name is empty or contains characters that are not supported. - + El nombre de la carpeta está vacío o contiene caracteres que no se admiten. - - - + + + Unable to rename folder - + No se ha podido renombrar la carpeta - + A file or folder named '%1' already exists. - + Ya existe un archivo o una carpeta con el nombre '%1'. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + No se ha podido renombrar la carpeta en el disco. Comprueba el nombre de la carpeta y los permisos de escritura. + +Carpeta: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + No se ha podido actualizar la base de datos de la biblioteca. Se ha deshecho el cambio de nombre de la carpeta en el disco. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + No se ha podido actualizar la base de datos de la biblioteca ni deshacer el cambio de nombre de la carpeta en el disco. Ahora hay que actualizar la biblioteca a mano. @@ -1314,12 +1326,12 @@ Folder: %1 Guardar portadas - + You are adding too many libraries. Estás añadiendo demasiadas bibliotecas. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1484,12 +1496,12 @@ You can restore a backup from the Library menu or recreate the library. Puedes restaurar una copia de seguridad desde el menú Biblioteca o volver a crear la biblioteca. - + Remove and delete metadata and backups Eliminar y borrar metadatos y copias de seguridad - + Library info Información de la biblioteca @@ -1504,22 +1516,22 @@ Puedes restaurar una copia de seguridad desde el menú Biblioteca o volver a cre Asignar números comenzando en: - + Invalid image Imagen inválida - + The selected file is not a valid image. El archivo seleccionado no es una imagen válida. - + Error saving cover Error guardando portada - + There was an error saving the cover image. Hubo un error guardando la image de portada. @@ -1700,7 +1712,7 @@ Archivos ausentes: %3 - + Set as read Marcar como leído @@ -1711,7 +1723,7 @@ Archivos ausentes: %3 - + Set as unread Marcar como no leído @@ -1722,7 +1734,7 @@ Archivos ausentes: %3 - + manga historieta manga @@ -1733,7 +1745,7 @@ Archivos ausentes: %3 - + comic cómic @@ -1754,7 +1766,7 @@ Archivos ausentes: %3 - + web comic cómic web @@ -1765,7 +1777,7 @@ Archivos ausentes: %3 - + yonkoma tira yonkoma @@ -1823,7 +1835,7 @@ Archivos ausentes: %3 Rename the current folder on disk and in the library - + Renombrar la carpeta actual en el disco y en la biblioteca @@ -1873,37 +1885,44 @@ Archivos ausentes: %3 - - Organize files - + + Rename files... + Organize files + Renombrar archivos... + + + + + Organize into folders... + Organizar en carpetas... - + Set as uncompleted Marcar como incompleto - + Set as completed Marcar como completo - + Set custom cover Establecer portada personalizada - + Delete custom cover Eliminar portada personalizada - + western manga (left to right) manga occidental (izquierda a derecha) - + Open containing folder... Abrir carpeta contenedora... @@ -1912,133 +1931,133 @@ Archivos ausentes: %3 Reseteal cómic rating - + Select all comics Seleccionar todos los cómics - + Edit Editar - + Assign current order to comics Asignar el orden actual a los cómics - + Update cover Actualizar portada - + Delete selected comics Borrar los cómics seleccionados - + Delete metadata from selected comics Borrar metadatos de los cómics seleccionados - + Download tags from Comic Vine Descargar etiquetas de Comic Vine - + Focus search line Selecionar el campo de búsqueda - + Focus comics view Selecionar la vista de cómics - + Edit shortcuts Editar atajos - + &Quit &Salir - + Update folder Actualizar carpeta - + Update current folder Actualizar carpeta actual - + Scan legacy XML metadata Escaneal metadatos XML - + Add new reading list Añadir lista de lectura - + Add a new reading list to the current library Añadir una nueva lista de lectura a la biblioteca actual - + Remove reading list Eliminar lista de lectura - + Remove current reading list from the library Eliminar la lista de lectura actual de la biblioteca - + Add new label Añadir etiqueta - + Add a new label to this library Añadir etiqueta a esta biblioteca - + Rename selected list Renombrar la lista seleccionada - + Rename any selected labels or lists Renombrar las etiquetas o listas seleccionadas - + Add to... Añadir a... - + Favorites Favoritos - + Add selected comics to favorites list Añadir cómics seleccionados a la lista de favoritos - + Reset rating Restablecer valoración @@ -2073,8 +2092,8 @@ Archivos ausentes: %3 - - + + Set type Establecer tipo @@ -2094,53 +2113,53 @@ Archivos ausentes: %3 Cómic - + Open folder... Abrir carpeta... - + Update folder Actualizar carpeta - + Rename folder Renombrar carpeta - + Rescan library for XML info Volver a escanear la biblioteca en busca de información XML - + Set as uncompleted Marcar como incompleto - + Set as completed Marcar como completo - + Set as read Marcar como leído - - + + Set as unread Marcar como no leído - + Set custom cover Establecer portada personalizada - + Delete custom cover Eliminar portada personalizada @@ -2476,123 +2495,547 @@ Para detener una actualización automática, toca en el indicador de carga junto Es necesario reiniciar + + OrganizeFiles + + + Renamed, %1 is already in use + Renombrado, %1 ya está en uso + + + + Missing metadata: %1 + Faltan metadatos: %1 + + + + %1 could not be created + No se ha podido crear %1 + + OrganizeFilesCoordinator - - - + + Organize files - + Organizar archivos + + + + This folder does not contain any comics. + Esta carpeta no contiene ningún cómic. + + + + This library is busy: %1 + Esta biblioteca está ocupada: %1 + + + + the library database could not be opened + no se ha podido abrir la base de datos de la biblioteca + + + + the library database could not be locked for writing + no se ha podido bloquear la base de datos de la biblioteca para escritura + + + + a folder entry could not be restored + no se ha podido restaurar una entrada de carpeta + + + + a comic entry could not be updated + no se ha podido actualizar una entrada de cómic - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + no se ha podido guardar la base de datos de la biblioteca: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + no se ha podido leer el registro de la última organización - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + no se ha podido crear la carpeta %1 + + + + %n file(s) could not be moved back + + no se ha podido devolver %n archivo a su sitio + no se han podido devolver %n archivos a su sitio + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Formato: - - Available tokens: %1 - + + Organize files + Organizar archivos - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Renombrar archivos - - Place folders relative to the library root - + + Preparing the preview... + Preparando la vista previa... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Formato del nombre de archivo: - - Format: - Formato: + + &Path format: + Formato de la &ruta: - - Organize files - + + Filename format + Formato del nombre de archivo - - Example: %1 - + + Path format + Formato de la ruta - - Unknown Series - + + Presets + Predefinidos - - Unknown Publisher - + + Insert + Insertar - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Parte opcional < > + + + + Disappears completely when the fields inside it are empty. + Desaparece por completo cuando los campos que contiene están vacíos. - + + Padded number {number:000} + Número con ceros {number:000} + + + + Format help... + Ayuda sobre el formato... + + + + selected folder + carpeta seleccionada + + + + library root + raíz de la biblioteca + + + + Move into + Mover a + + + + Reset changes + Descartar los cambios + + + + Remove selected + Quitar los seleccionados + + + + Show unchanged + Mostrar los que no cambian + + + + New name + Nombre nuevo + + + + Renamed from + Nombre anterior + + + New location - + Ubicación nueva - - Current location - + + Moved from + Ubicación anterior - + Remove from list - + Quitar de la lista - + Move files - + Mover los archivos - - Remove selected - + + Cancel + Cancelar - - Organize files - + + Copy the list + Copiar la lista + + + + Undo + Deshacer + + + + Close + Cerrar + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Un formato de nombre de archivo no puede contener "/". Usa Organizar archivos para mover cómics a carpetas. + + + + This format cannot be used: %1 + No se puede usar este formato: %1 + + + + new folder + carpeta nueva + + + + This folder does not exist yet. It will be created. + Esta carpeta todavía no existe. Se creará. + + + + file not found + archivo no encontrado + + + + This comic is in the library but not on disk. It is skipped. + Este cómic está en la biblioteca pero no en el disco. Se omite. + + + + name in use + nombre en uso + + + + no metadata + sin metadatos + + + + already here + ya está aquí + + + + This file is already in the right place. + Este archivo ya está en el sitio correcto. + + + + edited + editado + + + + %n will be renamed + + %n se renombrará + %n se renombrarán + + + + + %n will move + + %n se moverá + %n se moverán + + + + + %n unchanged + + %n sin cambios + %n sin cambios + + + + + %n renamed + + %n renombrado + %n renombrados + + + + + %n removed + + %n quitado + %n quitados + + + + + %n missing + + %n no encontrado + %n no encontrados + + + + + %n new folder(s) + + %n carpeta nueva + %n carpetas nuevas + + + + + %n manual change(s) kept + + Se mantiene %n cambio manual + Se mantienen %n cambios manuales + + + + + Nothing would be renamed with this format. + Con este formato no se renombraría nada. + + + + Nothing would move with this format. + Con este formato no se movería nada. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + Se renombrará %n archivo. Las carpetas no cambian. Después puedes deshacerlo. + Se renombrarán %n archivos. Las carpetas no cambian. Después puedes deshacerlo. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n archivo se moverá a %1. Esto cambia tus archivos en el disco. Después puedes deshacerlo. + %n archivos se moverán a %1. Esto cambia tus archivos en el disco. Después puedes deshacerlo. + + + + + Moving %1 of %2 +%3 + Moviendo %1 de %2 +%3 + + + + Updating the library... + Actualizando la biblioteca... + + + + Nothing was moved. + No se ha movido nada. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + No se ha podido escribir el registro con el que se podría deshacer esta operación, así que la operación no ha empezado: %1 + + + + %n file(s) renamed. + + Se ha renombrado %n archivo. + Se han renombrado %n archivos. + + + + + %n file(s) moved into %1. + + Se ha movido %n archivo a %1. + Se han movido %n archivos a %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + El registro de esta operación se ha interrumpido, así que la operación se ha detenido con él: %1 + + + + %n file(s) were not moved. + + No se ha movido %n archivo. + No se han movido %n archivos. + + + + + The library database could not be updated: %1 + No se ha podido actualizar la base de datos de la biblioteca: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Usa Deshacer para devolver los archivos a su sitio, o actualiza la biblioteca para que coincida con los archivos. + + + + %n empty folder(s) were removed. + + Se ha eliminado %n carpeta vacía. + Se han eliminado %n carpetas vacías. + + + + + %n file(s) could not be moved. + + No se ha podido mover %n archivo. + No se han podido mover %n archivos. + + + + + Moving the files back... + Devolviendo los archivos a su sitio... + + + + Moving back %1 of %2 +%3 + Devolviendo %1 de %2 +%3 + + + + Everything was moved back. + Se ha devuelto todo a su sitio. + + + + The undo did not finish: %1 + No se ha podido deshacer del todo: %1 + + + + Format help + Ayuda sobre el formato + + + + Fields + Campos + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Cada campo se escribe entre llaves y se sustituye por los metadatos del cómic. El menú Insertar los muestra todos. + + + + {series} gives %1 + {series} da %1 + + + + Optional parts + Partes opcionales + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Una parte escrita entre los signos < y > desaparece por completo cuando todos los campos que contiene están vacíos. Úsala para la puntuación que acompaña a un campo, como los paréntesis o una almohadilla inicial. El texto al principio o al final de un nombre se recorta sin ella. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) sin año da %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> sin año da %1 + + + + Numbers + Números + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Escribe dos puntos y varios ceros para rellenar el número del ejemplar. Así los ejemplares se mantienen en orden en un explorador de archivos. + + + + + Folders + Carpetas + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Un formato de nombre de archivo no puede contener una barra. Cada cómic se queda en su carpeta actual. Usa Organizar en carpetas para mover cómics. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Cada parte separada por una barra se convierte en una carpeta. La última parte es el nombre del archivo. La extensión original siempre se mantiene. diff --git a/YACReaderLibrary/yacreaderlibrary_fr.ts b/YACReaderLibrary/yacreaderlibrary_fr.ts index 1b5441d13..3828fdcb2 100644 --- a/YACReaderLibrary/yacreaderlibrary_fr.ts +++ b/YACReaderLibrary/yacreaderlibrary_fr.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + L'entrée du dossier est introuvable dans la base de données de la bibliothèque. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Ajouter un nouveau dossier - + Folder name: Nom du dossier : @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. Le dossier sélectionné ne contient aucune librairie. @@ -1069,7 +1069,7 @@ La librarie '%1' n'est plus disponible. Voulez-vous la supprimer? - + Do you want remove Voulez-vous supprimer @@ -1079,7 +1079,7 @@ La librarie '%1' a été créée avec une ancienne version de YACReaderLibrary. Elle doit être re-créée. Voulez-vous créer la librairie? - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1097,7 +1097,7 @@ YACReaderLibrary ne vous empêchera pas de créer plus de bibliothèques, mais v Librairie non disponible - + YACReader Library Librairie de YACReader @@ -1107,12 +1107,12 @@ YACReaderLibrary ne vous empêchera pas de créer plus de bibliothèques, mais v Mise à jour requise - + Library name already exists Le nom de la librairie existe déjà - + There is another library with the name '%1'. Une autre librairie a le nom '%1'. @@ -1132,22 +1132,22 @@ YACReaderLibrary ne vous empêchera pas de créer plus de bibliothèques, mais v Tous les comics sélectionnés vont être supprimés de votre disque. Êtes-vous sûr? - + Library not found Librairie introuvable - + library? la librairie? - + Are you sure? Êtes-vous sûr? - + Delete folder Supprimer le dossier @@ -1162,78 +1162,88 @@ YACReaderLibrary ne vous empêchera pas de créer plus de bibliothèques, mais v Des erreurs se sont produites lors de la mise à niveau de la bibliothèque dans : - + Folder name: Nom du dossier : - - - + + + No folder selected Aucun dossier sélectionné - - - + + + Please, select a folder first Veuillez d'abord sélectionner un dossier - + Error in path Erreur dans le chemin - + There was an error accessing the folder's path Une erreur s'est produite lors de l'accès au chemin du dossier - + The selected folder and all its contents will be deleted from your disk. Are you sure? Le dossier sélectionné et tout son contenu seront supprimés de votre disque. Es-tu sûr? - + Unable to delete Impossible de supprimer - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Un problème est survenu lors de la tentative de suppression des dossiers sélectionnés. Veuillez vérifier les autorisations d'écriture et vous assurer qu'aucune application n'utilise ces dossiers ni aucun des fichiers qu'ils contiennent. - + + Rename or organize files + Renommer ou organiser les fichiers + + + + Set the type of the selected comics + Définir le type des bandes dessinées sélectionnées + + + Search filters Filtres de recherche - + Unread Non lus - + In progress En cours - + Highly rated Très bien notés - + Recently added Ajoutés récemment - + Search syntax… Syntaxe de recherche… @@ -1258,14 +1268,14 @@ YACReaderLibrary ne vous empêchera pas de créer plus de bibliothèques, mais v Si vous êtes sûr qu'aucune autre réparation n'est en cours, le verrou peut être supprimé. Supprimer le verrou et continuer ? - + Package operation failed - + Échec de l'opération de paquet - + The covers package operation could not be completed. - + L'opération sur le paquet de couvertures n'a pas pu être terminée. @@ -1273,48 +1283,50 @@ YACReaderLibrary ne vous empêchera pas de créer plus de bibliothèques, mais v Échec de la récupération de la restauration - + Rename folder Renommer le dossier - + Invalid folder name - + Nom de dossier non valide - + The folder name is empty or contains characters that are not supported. - + Le nom du dossier est vide ou contient des caractères non pris en charge. - - - + + + Unable to rename folder - + Impossible de renommer le dossier - + A file or folder named '%1' already exists. - + Un fichier ou un dossier nommé « %1 » existe déjà. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + Le dossier n'a pas pu être renommé sur le disque. Vérifiez le nom du dossier et les droits d'écriture. + +Dossier : %1 - + The library database could not be updated. The folder rename on disk was reverted. - + La base de données de la bibliothèque n'a pas pu être mise à jour. Le renommage du dossier sur le disque a été annulé. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + La base de données de la bibliothèque n'a pas pu être mise à jour et le renommage du dossier sur le disque n'a pas pu être annulé. La bibliothèque doit maintenant être mise à jour manuellement. @@ -1322,7 +1334,7 @@ Folder: %1 Enregistrer les couvertures - + You are adding too many libraries. Vous ajoutez trop de bibliothèques. @@ -1479,12 +1491,12 @@ You can restore a backup from the Library menu or recreate the library. Vous pouvez restaurer une sauvegarde depuis le menu Bibliothèque ou recréer la bibliothèque. - + Remove and delete metadata and backups Retirer et supprimer les métadonnées et les sauvegardes - + Library info Informations sur la bibliothèque @@ -1504,22 +1516,22 @@ Vous pouvez restaurer une sauvegarde depuis le menu Bibliothèque ou recréer la Attribuez des numéros commençant par : - + Invalid image Image invalide - + The selected file is not a valid image. Le fichier sélectionné n'est pas une image valide. - + Error saving cover Erreur lors de l'enregistrement de la couverture - + There was an error saving the cover image. Une erreur s'est produite lors de l'enregistrement de l'image de couverture. @@ -1700,7 +1712,7 @@ Fichiers manquants : %3 - + Set as read Marquer comme lu @@ -1711,7 +1723,7 @@ Fichiers manquants : %3 - + Set as unread Marquer comme non-lu @@ -1722,7 +1734,7 @@ Fichiers manquants : %3 - + manga mangas @@ -1733,7 +1745,7 @@ Fichiers manquants : %3 - + comic comique @@ -1754,7 +1766,7 @@ Fichiers manquants : %3 - + web comic bande dessinée Web @@ -1765,7 +1777,7 @@ Fichiers manquants : %3 - + yonkoma Yonkoma @@ -1823,7 +1835,7 @@ Fichiers manquants : %3 Rename the current folder on disk and in the library - + Renommer le dossier actuel sur le disque et dans la bibliothèque @@ -1873,37 +1885,44 @@ Fichiers manquants : %3 - - Organize files - + + Rename files... + Organize files + Renommer les fichiers... + + + + + Organize into folders... + Organiser en dossiers... - + Set as uncompleted Marquer comme incomplet - + Set as completed Marquer comme complet - + Set custom cover Définir une couverture personnalisée - + Delete custom cover Supprimer la couverture personnalisée - + western manga (left to right) manga occidental (de gauche à droite) - + Open containing folder... Ouvrir le dossier... @@ -1912,133 +1931,133 @@ Fichiers manquants : %3 Supprimer la note d'évaluation - + Select all comics Sélectionner toutes les bandes dessinées - + Edit Editer - + Assign current order to comics Assigner l'ordre actuel aux bandes dessinées - + Update cover Mise à jour des couvertures - + Delete selected comics Supprimer la bande dessinée sélectionnée - + Delete metadata from selected comics Supprimer les métadonnées des bandes dessinées sélectionnées - + Download tags from Comic Vine Télécharger les informations de Comic Vine - + Focus search line Ligne de recherche ciblée - + Focus comics view Focus sur la vue des bandes dessinées - + Edit shortcuts Modifier les raccourcis - + &Quit &Quitter - + Update folder Mettre à jour le dossier - + Update current folder Mettre à jour ce dossier - + Scan legacy XML metadata Analyser les métadonnées XML héritées - + Add new reading list Ajouter une nouvelle liste de lecture - + Add a new reading list to the current library Ajouter une nouvelle liste de lecture à la bibliothèque actuelle - + Remove reading list Supprimer la liste de lecture - + Remove current reading list from the library Supprimer la liste de lecture actuelle de la bibliothèque - + Add new label Ajouter une nouvelle étiquette - + Add a new label to this library Ajouter une nouvelle étiquette à cette bibliothèque - + Rename selected list Renommer la liste sélectionnée - + Rename any selected labels or lists Renommer toutes les étiquettes ou listes sélectionnées - + Add to... Ajouter à... - + Favorites Favoris - + Add selected comics to favorites list Ajouter la bande dessinée sélectionnée à la liste des favoris - + Reset rating Réinitialiser la note @@ -2073,8 +2092,8 @@ Fichiers manquants : %3 - - + + Set type Définir le type @@ -2094,53 +2113,53 @@ Fichiers manquants : %3 Bande dessinée - + Open folder... Ouvrir le dossier... - + Update folder Mettre à jour le dossier - + Rename folder Renommer le dossier - + Rescan library for XML info Réanalyser la bibliothèque pour les informations XML - + Set as uncompleted Marquer comme incomplet - + Set as completed Marquer comme complet - + Set as read Marquer comme lu - - + + Set as unread Marquer comme non-lu - + Set custom cover Définir une couverture personnalisée - + Delete custom cover Supprimer la couverture personnalisée @@ -2476,123 +2495,547 @@ Pour arrêter une mise à jour automatique, appuyez sur l'indicateur de cha Redémarrage nécessaire + + OrganizeFiles + + + Renamed, %1 is already in use + Renommé, %1 est déjà utilisé + + + + Missing metadata: %1 + Métadonnées manquantes : %1 + + + + %1 could not be created + %1 n'a pas pu être créé + + OrganizeFilesCoordinator - - - + + Organize files - + Organiser les fichiers + + + + This folder does not contain any comics. + Ce dossier ne contient aucune bande dessinée. + + + + This library is busy: %1 + Cette bibliothèque est occupée : %1 + + + + the library database could not be opened + la base de données de la bibliothèque n'a pas pu être ouverte + + + + the library database could not be locked for writing + la base de données de la bibliothèque n'a pas pu être verrouillée en écriture + + + + a folder entry could not be restored + une entrée de dossier n'a pas pu être restaurée + + + + a comic entry could not be updated + une entrée de bande dessinée n'a pas pu être mise à jour - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + la base de données de la bibliothèque n'a pas pu être enregistrée : %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + l'enregistrement de la dernière organisation n'a pas pu être lu - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + le dossier %1 n'a pas pu être créé + + + + %n file(s) could not be moved back + + %n fichier n'a pas pu être remis en place + %n fichiers n'ont pas pu être remis en place + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Format : - - Available tokens: %1 - + + Organize files + Organiser les fichiers - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Renommer les fichiers - - Place folders relative to the library root - + + Preparing the preview... + Préparation de l'aperçu... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Format du nom de fichier : - - Format: - Format : + + &Path format: + Format du &chemin : - - Organize files - + + Filename format + Format du nom de fichier - - Example: %1 - + + Path format + Format du chemin - - Unknown Series - + + Presets + Préréglages - - Unknown Publisher - + + Insert + Insérer - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Partie facultative < > + + + + Disappears completely when the fields inside it are empty. + Disparaît complètement quand les champs qu'elle contient sont vides. - + + Padded number {number:000} + Numéro complété par des zéros {number:000} + + + + Format help... + Aide sur le format... + + + + selected folder + dossier sélectionné + + + + library root + racine de la bibliothèque + + + + Move into + Déplacer vers + + + + Reset changes + Réinitialiser les modifications + + + + Remove selected + Retirer la sélection + + + + Show unchanged + Afficher les inchangés + + + + New name + Nouveau nom + + + + Renamed from + Ancien nom + + + New location - + Nouvel emplacement - - Current location - + + Moved from + Ancien emplacement - + Remove from list - + Retirer de la liste - + Move files - + Déplacer les fichiers - - Remove selected - + + Cancel + Annuler - - Organize files - + + Copy the list + Copier la liste + + + + Undo + Revenir en arrière + + + + Close + Fermer + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Un format de nom de fichier ne peut pas contenir "/". Utilisez Organiser les fichiers pour déplacer des bandes dessinées dans des dossiers. + + + + This format cannot be used: %1 + Ce format ne peut pas être utilisé : %1 + + + + new folder + nouveau dossier + + + + This folder does not exist yet. It will be created. + Ce dossier n'existe pas encore. Il sera créé. + + + + file not found + fichier introuvable + + + + This comic is in the library but not on disk. It is skipped. + Cette bande dessinée est dans la bibliothèque mais pas sur le disque. Elle est ignorée. + + + + name in use + nom déjà utilisé + + + + no metadata + pas de métadonnées + + + + already here + déjà ici + + + + This file is already in the right place. + Ce fichier est déjà au bon endroit. + + + + edited + modifié + + + + %n will be renamed + + %n sera renommé + %n seront renommés + + + + + %n will move + + %n sera déplacé + %n seront déplacés + + + + + %n unchanged + + %n inchangé + %n inchangés + + + + + %n renamed + + %n renommé + %n renommés + + + + + %n removed + + %n retiré + %n retirés + + + + + %n missing + + %n introuvable + %n introuvables + + + + + %n new folder(s) + + %n nouveau dossier + %n nouveaux dossiers + + + + + %n manual change(s) kept + + %n modification manuelle conservée + %n modifications manuelles conservées + + + + + Nothing would be renamed with this format. + Avec ce format, rien ne serait renommé. + + + + Nothing would move with this format. + Avec ce format, rien ne serait déplacé. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n fichier sera renommé. Les dossiers ne changent pas. Vous pourrez revenir en arrière ensuite. + %n fichiers seront renommés. Les dossiers ne changent pas. Vous pourrez revenir en arrière ensuite. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n fichier sera déplacé vers %1. Cela modifie vos fichiers sur le disque. Vous pourrez revenir en arrière ensuite. + %n fichiers seront déplacés vers %1. Cela modifie vos fichiers sur le disque. Vous pourrez revenir en arrière ensuite. + + + + + Moving %1 of %2 +%3 + Déplacement de %1 sur %2 +%3 + + + + Updating the library... + Mise à jour de la bibliothèque... + + + + Nothing was moved. + Rien n'a été déplacé. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + L'enregistrement permettant d'annuler cette opération n'a pas pu être écrit, l'opération n'a donc pas démarré : %1 + + + + %n file(s) renamed. + + %n fichier renommé. + %n fichiers renommés. + + + + + %n file(s) moved into %1. + + %n fichier déplacé vers %1. + %n fichiers déplacés vers %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + L'enregistrement de cette opération s'est arrêté prématurément, l'opération s'est donc arrêtée avec lui : %1 + + + + %n file(s) were not moved. + + %n fichier n'a pas été déplacé. + %n fichiers n'ont pas été déplacés. + + + + + The library database could not be updated: %1 + La base de données de la bibliothèque n'a pas pu être mise à jour : %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Utilisez Revenir en arrière pour remettre les fichiers en place, ou mettez la bibliothèque à jour pour qu'elle corresponde aux fichiers. + + + + %n empty folder(s) were removed. + + %n dossier vide a été supprimé. + %n dossiers vides ont été supprimés. + + + + + %n file(s) could not be moved. + + %n fichier n'a pas pu être déplacé. + %n fichiers n'ont pas pu être déplacés. + + + + + Moving the files back... + Remise en place des fichiers... + + + + Moving back %1 of %2 +%3 + Remise en place de %1 sur %2 +%3 + + + + Everything was moved back. + Tout a été remis en place. + + + + The undo did not finish: %1 + Le retour en arrière ne s'est pas terminé : %1 + + + + Format help + Aide sur le format + + + + Fields + Champs + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Chaque champ s'écrit entre accolades et est remplacé par les métadonnées de la bande dessinée. Le menu Insérer les liste tous. + + + + {series} gives %1 + {series} donne %1 + + + + Optional parts + Parties facultatives + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Une partie écrite entre les signes < et > disparaît complètement quand tous les champs qu'elle contient sont vides. Utilisez-la pour la ponctuation qui appartient à un champ, comme des parenthèses ou un dièse en tête. Le texte au début ou à la fin d'un nom est rogné sans elle. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) sans année donne %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> sans année donne %1 + + + + Numbers + Numéros + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Écrivez deux-points et quelques zéros pour compléter le numéro. Les numéros restent ainsi dans l'ordre dans un gestionnaire de fichiers. + + + + + Folders + Dossiers + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Un format de nom de fichier ne peut pas contenir de barre oblique. Chaque bande dessinée reste dans son dossier actuel. Utilisez Organiser en dossiers pour déplacer des bandes dessinées. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Chaque partie séparée par une barre oblique devient un dossier. La dernière partie devient le nom du fichier. L'extension d'origine est toujours conservée. diff --git a/YACReaderLibrary/yacreaderlibrary_it.ts b/YACReaderLibrary/yacreaderlibrary_it.ts index 20597c40b..342fa64e4 100644 --- a/YACReaderLibrary/yacreaderlibrary_it.ts +++ b/YACReaderLibrary/yacreaderlibrary_it.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + La voce della cartella non è stata trovata nel database della libreria. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Aggiungi una nuova cartella - + Folder name: Nome della cartella: @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. La cartella selezionata non contiene nessuna Libreria. @@ -1040,17 +1040,17 @@ Questa libreria è stata creata con una versione precedente di YACREaderLibrary. Deve essere aggiornata. Aggiorno ora? - + Folder name: Nome della cartella: - + The selected folder and all its contents will be deleted from your disk. Are you sure? La cartella seleziona e tutto il suo contenuto verranno cancellati dal tuo disco. Sei sicuro? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. C'è stato un problema cancellando le cartelle selezionate. Per favore controlla i tuoi permessi di scrittura e sii sicuro che non ci siano altre applicazioni che usano le stesse cartelle. @@ -1065,7 +1065,7 @@ Vecchia libreria - + There was an error accessing the folder's path C'è stato un errore nell'accesso al percorso della cartella @@ -1095,12 +1095,12 @@ La libreria '%1' non è più disponibile, la vuoi cancellare? - + Do you want remove Vuoi rimuovere - + Error in path Errore nel percorso @@ -1115,7 +1115,7 @@ Salva Copertine - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1128,7 +1128,7 @@ Hai probabilemnte bisogno di una sola Libreria al livello superiore, puoi poi na YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il numero di librerie basso. - + Library info Informazioni sulla biblioteca @@ -1138,9 +1138,9 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Assegna un numero ai fumetti - - - + + + Please, select a folder first Per cortesia prima seleziona una cartella @@ -1155,12 +1155,12 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu C'è un problema nel cancellare i fumetti selezionati. Per favore controlla i tuoi permessi di scrittura sui file o sulla cartella. - + YACReader Library Libreria YACReader - + You are adding too many libraries. Stai aggiungendto troppe librerie. @@ -1170,17 +1170,17 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Devi aggiornarmi - + Library name already exists Esiste già una libreria con lo stesso nome - + There is another library with the name '%1'. Esiste già una libreria con il nome '%1'. - + Delete folder Cancella Cartella @@ -1195,27 +1195,27 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Scarica la nuova versione - + Remove and delete metadata and backups Rimuovi ed elimina metadati e backup - + Invalid image Immagine non valida - + The selected file is not a valid image. Il file selezionato non è un'immagine valida. - + Error saving cover Errore durante il salvataggio della copertina - + There was an error saving the cover image. Si è verificato un errore durante il salvataggio dell'immagine di copertina. @@ -1225,9 +1225,9 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Cancella i fumetti - - - + + + No folder selected Nessuna cartella selezionata @@ -1242,43 +1242,53 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Rimuovi i fumetti - + Library not found Libreria non trovata - + Unable to delete Non posso cancellare - + + Rename or organize files + Rinomina o organizza i file + + + + Set the type of the selected comics + Imposta il tipo dei fumetti selezionati + + + Search filters Filtri di ricerca - + Unread Non letti - + In progress In corso - + Highly rated Con valutazione alta - + Recently added Aggiunti di recente - + Search syntax… Sintassi di ricerca… @@ -1303,14 +1313,14 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Se sei sicuro che non sia in corso nessun'altra riparazione, il blocco può essere rimosso. Rimuovere il blocco e continuare? - + Package operation failed - + Operazione di pacchetto non riuscita - + The covers package operation could not be completed. - + Non è stato possibile completare l'operazione con il pacchetto di copertine. @@ -1318,48 +1328,50 @@ YACReader non ti fermerà dal creare altre librerie ma è meglio se terrai il nu Recupero del ripristino non riuscito - + Rename folder Rinomina cartella - + Invalid folder name - + Nome della cartella non valido - + The folder name is empty or contains characters that are not supported. - + Il nome della cartella è vuoto o contiene caratteri non supportati. - - - + + + Unable to rename folder - + Impossibile rinominare la cartella - + A file or folder named '%1' already exists. - + Esiste già un file o una cartella con il nome '%1'. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + Non è stato possibile rinominare la cartella sul disco. Controlla il nome della cartella e i permessi di scrittura. + +Cartella: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + Non è stato possibile aggiornare il database della libreria. La rinomina della cartella sul disco è stata annullata. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + Non è stato possibile aggiornare il database della libreria né annullare la rinomina della cartella sul disco. Ora la libreria deve essere aggiornata manualmente. @@ -1514,12 +1526,12 @@ You can restore a backup from the Library menu or recreate the library. Puoi ripristinare un backup dal menu Libreria o ricreare la libreria. - + library? Libreria? - + Are you sure? Sei sicuro? @@ -1700,7 +1712,7 @@ File mancanti: %3 - + Set as read Setta come letto @@ -1711,7 +1723,7 @@ File mancanti: %3 - + Set as unread Setta come non letto @@ -1722,7 +1734,7 @@ File mancanti: %3 - + manga Manga @@ -1733,7 +1745,7 @@ File mancanti: %3 - + comic comico @@ -1754,7 +1766,7 @@ File mancanti: %3 - + web comic fumetto web @@ -1765,7 +1777,7 @@ File mancanti: %3 - + yonkoma Yonkoma @@ -1823,7 +1835,7 @@ File mancanti: %3 Rename the current folder on disk and in the library - + Rinomina la cartella corrente sul disco e nella libreria @@ -1873,37 +1885,44 @@ File mancanti: %3 - - Organize files - + + Rename files... + Organize files + Rinomina i file... + + + + + Organize into folders... + Organizza in cartelle... - + Set as uncompleted Segna come non completo - + Set as completed Segna come completo - + Set custom cover Imposta la copertina personalizzata - + Delete custom cover Elimina la copertina personalizzata - + western manga (left to right) manga occidentale (da sinistra a destra) - + Open containing folder... Apri la cartella dei contenuti... @@ -1912,133 +1931,133 @@ File mancanti: %3 Resetta la valutazione dei fumetti - + Select all comics Seleziona tutti i fumetti - + Edit Edita - + Assign current order to comics Assegna l'ordinamento corrente ai fumetti - + Update cover Aggiorna copertina - + Delete selected comics Cancella i fumetti selezionati - + Delete metadata from selected comics Elimina i metadati dai fumetti selezionati - + Download tags from Comic Vine Scarica i Tag da Comic Vine - + Focus search line Mettere a fuoco la linea di ricerca - + Focus comics view Focus sulla visualizzazione dei fumetti - + Edit shortcuts Edita scorciatoie - + &Quit &Esci - + Update folder Aggiorna Cartella - + Update current folder Aggiorna la cartella corrente - + Scan legacy XML metadata Scansione dei metadati XML legacy - + Add new reading list Aggiorna la lista di lettura - + Add a new reading list to the current library Aggiungi una lista di lettura alla libreria corrente - + Remove reading list Rimuovi la lista di lettura - + Remove current reading list from the library Rimuovi la lista di lettura dalla libreria - + Add new label Aggiungi una nuova etichetta - + Add a new label to this library Aggiungi una nuova etichetta a questa libreria - + Rename selected list Rinomina la lista selezionata - + Rename any selected labels or lists Rinomina qualsiasi etichetta o lista selezionata - + Add to... Aggiungi a... - + Favorites Favoriti - + Add selected comics to favorites list Aggiungi i fumetti selezionati alla lista dei favoriti - + Reset rating Reimposta valutazione @@ -2073,8 +2092,8 @@ File mancanti: %3 - - + + Set type Imposta il tipo @@ -2094,53 +2113,53 @@ File mancanti: %3 Fumetto - + Open folder... Apri Cartella... - + Update folder Aggiorna Cartella - + Rename folder Rinomina cartella - + Rescan library for XML info Eseguire nuovamente la scansione della libreria per informazioni XML - + Set as uncompleted Segna come non completo - + Set as completed Segna come completo - + Set as read Setta come letto - - + + Set as unread Setta come non letto - + Set custom cover Imposta la copertina personalizzata - + Delete custom cover Elimina la copertina personalizzata @@ -2476,123 +2495,547 @@ Per interrompere un aggiornamento automatico, tocca l'indicatore di caricam Riavvio Necessario + + OrganizeFiles + + + Renamed, %1 is already in use + Rinominato, %1 è già in uso + + + + Missing metadata: %1 + Metadati mancanti: %1 + + + + %1 could not be created + Non è stato possibile creare %1 + + OrganizeFilesCoordinator - - - + + Organize files - + Organizza i file + + + + This folder does not contain any comics. + Questa cartella non contiene fumetti. - - This folder does not contain any comics to organize. - + + This library is busy: %1 + Questa libreria è occupata: %1 - - All files are already organized according to this format. - + + the library database could not be opened + non è stato possibile aprire il database della libreria - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the library database could not be locked for writing + non è stato possibile bloccare il database della libreria per la scrittura + + + + a folder entry could not be restored + non è stato possibile ripristinare una voce di cartella + + + + a comic entry could not be updated + non è stato possibile aggiornare una voce di fumetto + + + + the library database could not be saved: %1 + non è stato possibile salvare il database della libreria: %1 + + + + the record of the last organize run could not be read + non è stato possibile leggere il registro dell'ultima organizzazione + + + + the folder %1 could not be created + non è stato possibile creare la cartella %1 + + + + %n file(s) could not be moved back + + non è stato possibile riportare indietro %n file + non è stato possibile riportare indietro %n file + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Formato: - - Available tokens: %1 - + + Organize files + Organizza i file - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Rinomina i file - - Place folders relative to the library root - + + Preparing the preview... + Preparazione dell'anteprima... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Formato del nome del file: - - Format: - Formato: + + &Path format: + Formato del &percorso: - - Organize files - + + Filename format + Formato del nome del file - - Example: %1 - + + Path format + Formato del percorso - - Unknown Series - + + Presets + Preimpostazioni - - Unknown Publisher - + + Insert + Inserisci - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Parte opzionale < > + + + + Disappears completely when the fields inside it are empty. + Scompare completamente quando i campi al suo interno sono vuoti. - + + Padded number {number:000} + Numero con zeri iniziali {number:000} + + + + Format help... + Guida al formato... + + + + selected folder + cartella selezionata + + + + library root + radice della libreria + + + + Move into + Sposta in + + + + Reset changes + Reimposta le modifiche + + + + Remove selected + Rimuovi i selezionati + + + + Show unchanged + Mostra quelli invariati + + + + New name + Nuovo nome + + + + Renamed from + Nome precedente + + + New location - + Nuova posizione - - Current location - + + Moved from + Posizione precedente - + Remove from list - + Rimuovi dall'elenco - + Move files - + Sposta i file - - Remove selected - + + Cancel + Annulla - - Organize files - + + Copy the list + Copia l'elenco + + + + Undo + Ripristina + + + + Close + Chiudi + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Un formato del nome del file non può contenere "/". Usa Organizza i file per spostare i fumetti nelle cartelle. + + + + This format cannot be used: %1 + Questo formato non può essere usato: %1 + + + + new folder + cartella nuova + + + + This folder does not exist yet. It will be created. + Questa cartella non esiste ancora. Verrà creata. + + + + file not found + file non trovato + + + + This comic is in the library but not on disk. It is skipped. + Questo fumetto è nella libreria ma non sul disco. Viene saltato. + + + + name in use + nome già in uso + + + + no metadata + senza metadati + + + + already here + già qui + + + + This file is already in the right place. + Questo file è già al posto giusto. + + + + edited + modificato + + + + %n will be renamed + + %n sarà rinominato + %n saranno rinominati + + + + + %n will move + + %n sarà spostato + %n saranno spostati + + + + + %n unchanged + + %n invariato + %n invariati + + + + + %n renamed + + %n rinominato + %n rinominati + + + + + %n removed + + %n rimosso + %n rimossi + + + + + %n missing + + %n mancante + %n mancanti + + + + + %n new folder(s) + + %n cartella nuova + %n cartelle nuove + + + + + %n manual change(s) kept + + %n modifica manuale mantenuta + %n modifiche manuali mantenute + + + + + Nothing would be renamed with this format. + Con questo formato non verrebbe rinominato nulla. + + + + Nothing would move with this format. + Con questo formato non verrebbe spostato nulla. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n file sarà rinominato. Le cartelle non cambiano. Puoi ripristinare in seguito. + %n file saranno rinominati. Le cartelle non cambiano. Puoi ripristinare in seguito. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n file sarà spostato in %1. Questo modifica i tuoi file sul disco. Puoi ripristinare in seguito. + %n file saranno spostati in %1. Questo modifica i tuoi file sul disco. Puoi ripristinare in seguito. + + + + + Moving %1 of %2 +%3 + Spostamento di %1 su %2 +%3 + + + + Updating the library... + Aggiornamento della libreria... + + + + Nothing was moved. + Non è stato spostato nulla. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + Non è stato possibile scrivere il registro con cui annullare questa operazione, quindi l'operazione non è iniziata: %1 + + + + %n file(s) renamed. + + %n file rinominato. + %n file rinominati. + + + + + %n file(s) moved into %1. + + %n file spostato in %1. + %n file spostati in %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + Il registro di questa operazione si è interrotto prima della fine, quindi anche l'operazione si è fermata: %1 + + + + %n file(s) were not moved. + + %n file non è stato spostato. + %n file non sono stati spostati. + + + + + The library database could not be updated: %1 + Non è stato possibile aggiornare il database della libreria: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Usa Ripristina per riportare indietro i file, oppure aggiorna la libreria perché corrisponda ai file. + + + + %n empty folder(s) were removed. + + %n cartella vuota è stata rimossa. + %n cartelle vuote sono state rimosse. + + + + + %n file(s) could not be moved. + + Non è stato possibile spostare %n file. + Non è stato possibile spostare %n file. + + + + + Moving the files back... + Ripristino dei file in corso... + + + + Moving back %1 of %2 +%3 + Ripristino di %1 su %2 +%3 + + + + Everything was moved back. + Tutto è stato riportato indietro. + + + + The undo did not finish: %1 + Il ripristino non è stato completato: %1 + + + + Format help + Guida al formato + + + + Fields + Campi + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Ogni campo si scrive tra parentesi graffe e viene sostituito dai metadati del fumetto. Il menu Inserisci li elenca tutti. + + + + {series} gives %1 + {series} dà %1 + + + + Optional parts + Parti opzionali + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Una parte scritta tra i segni < e > scompare completamente quando tutti i campi al suo interno sono vuoti. Usala per la punteggiatura che appartiene a un campo, come le parentesi o un cancelletto iniziale. Il testo all'inizio o alla fine di un nome viene tagliato anche senza di essa. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) senza anno dà %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> senza anno dà %1 + + + + Numbers + Numeri + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Scrivi due punti e alcuni zeri per riempire il numero dell'albo. Così gli albi restano in ordine in un gestore di file. + + + + + Folders + Cartelle + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Un formato del nome del file non può contenere una barra. Ogni fumetto resta nella cartella attuale. Usa Organizza in cartelle per spostare i fumetti. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Ogni parte separata da una barra diventa una cartella. L'ultima parte diventa il nome del file. L'estensione originale viene sempre mantenuta. diff --git a/YACReaderLibrary/yacreaderlibrary_ko.ts b/YACReaderLibrary/yacreaderlibrary_ko.ts index 02148c271..a677f373c 100644 --- a/YACReaderLibrary/yacreaderlibrary_ko.ts +++ b/YACReaderLibrary/yacreaderlibrary_ko.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + 라이브러리 데이터베이스에서 폴더 항목을 찾을 수 없습니다. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder 새 폴더 추가 - + Folder name: 폴더 이름: @@ -1030,22 +1030,22 @@ LibraryWindow - + Do you want remove 다음을 제거하시겠습니까: - + YACReader Library YACReader Library - + Are you sure? 확실합니까? - + Delete folder 폴더 삭제 @@ -1115,78 +1115,88 @@ 만화 이동 중... - + Folder name: 폴더 이름: - - - + + + No folder selected 선택된 폴더 없음 - - - + + + Please, select a folder first 먼저 폴더를 선택하세요 - + Error in path 경로 오류 - + There was an error accessing the folder's path 폴더 경로에 접근하는 중 오류가 발생했습니다 - + The selected folder and all its contents will be deleted from your disk. Are you sure? 선택한 폴더와 그 안의 모든 내용이 디스크에서 삭제됩니다. 계속하시겠습니까? - + Unable to delete 삭제할 수 없음 - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. 선택한 폴더를 삭제하는 중 문제가 발생했습니다. 쓰기 권한을 확인하고, 다른 응용 프로그램이 이 폴더나 안의 파일을 사용하고 있지 않은지 확인하세요. - + + Rename or organize files + 파일 이름 변경 또는 정리 + + + + Set the type of the selected comics + 선택한 만화의 유형 설정 + + + Search filters 검색 필터 - + Unread 읽지 않음 - + In progress 읽는 중 - + Highly rated 높은 평점 - + Recently added 최근 추가 - + Search syntax… 검색 구문… @@ -1211,58 +1221,60 @@ 다른 복구가 실행 중이 아니라고 확신하면 잠금을 해제할 수 있습니다. 잠금을 해제하고 계속하시겠습니까? - + Package operation failed - + 패키지 작업 실패 - + The covers package operation could not be completed. - + 표지 패키지 작업을 완료할 수 없습니다. - + Rename folder 폴더 이름 바꾸기 - + Invalid folder name - + 잘못된 폴더 이름 - + The folder name is empty or contains characters that are not supported. - + 폴더 이름이 비어 있거나 지원하지 않는 문자가 있습니다. - - - + + + Unable to rename folder - + 폴더 이름을 변경할 수 없음 - + A file or folder named '%1' already exists. - + '%1'(이)라는 파일 또는 폴더가 이미 있습니다. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + 디스크에서 폴더 이름을 변경할 수 없습니다. 폴더 이름과 쓰기 권한을 확인하세요. + +폴더: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + 라이브러리 데이터베이스를 업데이트할 수 없습니다. 디스크의 폴더 이름 변경을 되돌렸습니다. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + 라이브러리 데이터베이스를 업데이트하지 못했고 디스크의 폴더 이름 변경도 되돌리지 못했습니다. 이제 라이브러리를 수동으로 업데이트해야 합니다. @@ -1270,12 +1282,12 @@ Folder: %1 표지 저장 - + You are adding too many libraries. 라이브러리를 너무 많이 추가하고 있습니다. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1288,12 +1300,12 @@ YACReaderLibrary will not stop you from creating more libraries but you should k YACReaderLibrary는 라이브러리를 더 만드는 것을 막지 않지만, 라이브러리 수는 적게 유지하는 것이 좋습니다. - + Library not found 라이브러리를 찾을 수 없음 - + The selected folder doesn't contain any library. 선택한 폴더에 라이브러리가 없습니다. @@ -1450,12 +1462,12 @@ You can restore a backup from the Library menu or recreate the library. 라이브러리 메뉴에서 백업을 복원하거나 라이브러리를 다시 만들 수 있습니다. - + library? 라이브러리? - + Remove and delete metadata and backups 메타데이터 및 백업 제거 후 삭제 @@ -1464,7 +1476,7 @@ You can restore a backup from the Library menu or recreate the library. 제거 및 메타데이터 삭제 - + Library info 라이브러리 정보 @@ -1484,22 +1496,22 @@ You can restore a backup from the Library menu or recreate the library. 다음 번호부터 부여: - + Invalid image 잘못된 이미지 - + The selected file is not a valid image. 선택한 파일이 유효한 이미지가 아닙니다. - + Error saving cover 표지 저장 오류 - + There was an error saving the cover image. 표지 이미지를 저장하는 중 오류가 발생했습니다. @@ -1524,12 +1536,12 @@ You can restore a backup from the Library menu or recreate the library. 만화가 현재 라벨/목록에서만 삭제됩니다. 확실합니까? - + Library name already exists 라이브러리 이름 중복 - + There is another library with the name '%1'. '%1' 이름의 라이브러리가 이미 있습니다. @@ -1700,7 +1712,7 @@ Missing files: %3 - + Set as read 읽음으로 표시 @@ -1711,7 +1723,7 @@ Missing files: %3 - + Set as unread 읽지 않음으로 표시 @@ -1722,7 +1734,7 @@ Missing files: %3 - + manga 망가 @@ -1733,7 +1745,7 @@ Missing files: %3 - + comic 만화 @@ -1754,7 +1766,7 @@ Missing files: %3 - + web comic 웹 만화 @@ -1765,7 +1777,7 @@ Missing files: %3 - + yonkoma 4컷 만화 @@ -1823,7 +1835,7 @@ Missing files: %3 Rename the current folder on disk and in the library - + 디스크와 라이브러리에서 현재 폴더 이름 변경 @@ -1873,37 +1885,44 @@ Missing files: %3 - - Organize files - + + Rename files... + Organize files + 파일 이름 변경... + + + + + Organize into folders... + 폴더로 정리... - + Set as uncompleted 미완료로 표시 - + Set as completed 완료로 표시 - + Set custom cover 사용자 지정 표지 설정 - + Delete custom cover 사용자 지정 표지 삭제 - + western manga (left to right) 서양 만화 (왼쪽 → 오른쪽) - + Open containing folder... 포함된 폴더 열기... @@ -1912,133 +1931,133 @@ Missing files: %3 만화 평점 초기화 - + Select all comics 모든 만화 선택 - + Edit 편집 - + Assign current order to comics 만화에 현재 순서 적용 - + Update cover 표지 업데이트 - + Delete selected comics 선택한 만화 삭제 - + Delete metadata from selected comics 선택한 만화에서 메타데이터 삭제 - + Download tags from Comic Vine Comic Vine에서 태그 내려받기 - + Focus search line 검색창으로 이동 - + Focus comics view 만화 보기로 이동 - + Edit shortcuts 단축키 편집 - + &Quit 끝내기(&Q) - + Update folder 폴더 업데이트 - + Update current folder 현재 폴더 업데이트 - + Scan legacy XML metadata 레거시 XML 메타데이터 스캔 - + Add new reading list 새 읽기 목록 추가 - + Add a new reading list to the current library 현재 라이브러리에 새 읽기 목록 추가 - + Remove reading list 읽기 목록 제거 - + Remove current reading list from the library 라이브러리에서 현재 읽기 목록 제거 - + Add new label 새 라벨 추가 - + Add a new label to this library 이 라이브러리에 새 라벨 추가 - + Rename selected list 선택한 목록 이름 변경 - + Rename any selected labels or lists 선택한 라벨이나 목록 이름 변경 - + Add to... 추가... - + Favorites 즐겨찾기 - + Add selected comics to favorites list 선택한 만화를 즐겨찾기 목록에 추가 - + Reset rating 평점 초기화 @@ -2073,8 +2092,8 @@ Missing files: %3 - - + + Set type 유형 설정 @@ -2094,53 +2113,53 @@ Missing files: %3 만화 - + Open folder... 폴더 열기... - + Update folder 폴더 업데이트 - + Rename folder 폴더 이름 바꾸기 - + Rescan library for XML info XML 정보로 라이브러리 재검색 - + Set as uncompleted 미완료로 표시 - + Set as completed 완료로 표시 - + Set as read 읽음으로 표시 - - + + Set as unread 읽지 않음으로 표시 - + Set custom cover 사용자 지정 표지 설정 - + Delete custom cover 사용자 지정 표지 삭제 @@ -2476,122 +2495,531 @@ To stop an automatic update tap on the loading indicator next to the Libraries t 재시작이 필요합니다 + + OrganizeFiles + + + Renamed, %1 is already in use + 이름 변경됨, %1은(는) 이미 사용 중입니다 + + + + Missing metadata: %1 + 누락된 메타데이터: %1 + + + + %1 could not be created + %1을(를) 만들 수 없습니다 + + OrganizeFilesCoordinator - - - + + Organize files - + 파일 정리 + + + + This folder does not contain any comics. + 이 폴더에는 만화가 없습니다. + + + + This library is busy: %1 + 이 라이브러리는 사용 중입니다: %1 + + + + the library database could not be opened + 라이브러리 데이터베이스를 열 수 없습니다 + + + + the library database could not be locked for writing + 쓰기용으로 라이브러리 데이터베이스를 잠글 수 없습니다 + + + + a folder entry could not be restored + 폴더 항목을 복원할 수 없습니다 + + + + a comic entry could not be updated + 만화 항목을 업데이트할 수 없습니다 - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + 라이브러리 데이터베이스를 저장할 수 없습니다: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + 마지막 정리 작업의 기록을 읽을 수 없습니다 - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + %1 폴더를 만들 수 없습니다 + + + + %n file(s) could not be moved back + + %n개 파일을 되돌리지 못했습니다 + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + 형식: - - Available tokens: %1 - + + Organize files + 파일 정리 - - {title} falls back to the series name when the comic has no title. - + + + Rename files + 파일 이름 변경 - - Place folders relative to the library root - + + Preparing the preview... + 미리 보기를 준비하는 중... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + 파일 이름 형식(&F): - - Format: - 형식: + + &Path format: + 경로 형식(&P): - - Organize files - + + Filename format + 파일 이름 형식 - - Example: %1 - + + Path format + 경로 형식 - - Unknown Series - + + Presets + 사전 설정 - - Unknown Publisher - + + Insert + 삽입 - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - + + + Optional part < > + 선택 부분 < > + + + + Disappears completely when the fields inside it are empty. + 안에 있는 필드가 비어 있으면 완전히 사라집니다. - + + Padded number {number:000} + 0으로 채운 번호 {number:000} + + + + Format help... + 형식 도움말... + + + + selected folder + 선택한 폴더 + + + + library root + 라이브러리 루트 + + + + Move into + 이동 위치 + + + + Reset changes + 변경 사항 초기화 + + + + Remove selected + 선택 항목 제거 + + + + Show unchanged + 변경되지 않은 항목 표시 + + + + New name + 새 이름 + + + + Renamed from + 이전 이름 + + + New location - + 새 위치 - - Current location - + + Moved from + 이전 위치 - + Remove from list - + 목록에서 제거 - + Move files - + 파일 이동 - - Remove selected - + + Cancel + 취소 - - Organize files - + + Copy the list + 목록 복사 + + + + Undo + 실행 취소 + + + + Close + 닫기 + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + 파일 이름 형식에는 "/"를 사용할 수 없습니다. 만화를 폴더로 옮기려면 파일 정리를 사용하세요. + + + + This format cannot be used: %1 + 이 형식은 사용할 수 없습니다: %1 + + + + new folder + 새 폴더 + + + + This folder does not exist yet. It will be created. + 이 폴더는 아직 없습니다. 새로 만듭니다. + + + + file not found + 파일 없음 + + + + This comic is in the library but not on disk. It is skipped. + 이 만화는 라이브러리에 있지만 디스크에 없습니다. 건너뜁니다. + + + + name in use + 이름 사용 중 + + + + no metadata + 메타데이터 없음 + + + + already here + 이미 여기 있음 + + + + This file is already in the right place. + 이 파일은 이미 올바른 위치에 있습니다. + + + + edited + 편집됨 + + + + %n will be renamed + + %n개 이름 변경 예정 + + + + + %n will move + + %n개 이동 예정 + + + + + %n unchanged + + %n개 변경 없음 + + + + + %n renamed + + %n개 이름 변경됨 + + + + + %n removed + + %n개 제거됨 + + + + + %n missing + + %n개 없음 + + + + + %n new folder(s) + + 새 폴더 %n개 + + + + + %n manual change(s) kept + + 수동 변경 %n개 유지됨 + + + + + Nothing would be renamed with this format. + 이 형식으로는 이름이 변경되는 파일이 없습니다. + + + + Nothing would move with this format. + 이 형식으로는 이동하는 파일이 없습니다. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + 파일 %n개의 이름을 변경합니다. 폴더는 바뀌지 않습니다. 나중에 실행 취소할 수 있습니다. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + 파일 %n개를 %1(으)로 이동합니다. 디스크의 파일이 바뀝니다. 나중에 실행 취소할 수 있습니다. + + + + + Moving %1 of %2 +%3 + %2개 중 %1개 이동 중 +%3 + + + + Updating the library... + 라이브러리를 업데이트하는 중... + + + + Nothing was moved. + 이동한 항목이 없습니다. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + 이 작업을 실행 취소할 수 있는 기록을 쓰지 못해 작업을 시작하지 않았습니다: %1 + + + + %n file(s) renamed. + + 파일 %n개의 이름을 변경했습니다. + + + + + %n file(s) moved into %1. + + 파일 %n개를 %1(으)로 이동했습니다. + + + + + The record of this run stopped early, so the run stopped with it: %1 + 이 작업의 기록이 도중에 멈춰서 작업도 함께 멈췄습니다: %1 + + + + %n file(s) were not moved. + + 파일 %n개를 이동하지 않았습니다. + + + + + The library database could not be updated: %1 + 라이브러리 데이터베이스를 업데이트할 수 없습니다: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + 실행 취소를 사용해 파일을 되돌리거나, 라이브러리를 업데이트해 파일과 일치시키세요. + + + + %n empty folder(s) were removed. + + 빈 폴더 %n개를 제거했습니다. + + + + + %n file(s) could not be moved. + + 파일 %n개를 이동하지 못했습니다. + + + + + Moving the files back... + 파일을 되돌리는 중... + + + + Moving back %1 of %2 +%3 + %2개 중 %1개 되돌리는 중 +%3 + + + + Everything was moved back. + 모두 되돌렸습니다. + + + + The undo did not finish: %1 + 실행 취소를 완료하지 못했습니다: %1 + + + + Format help + 형식 도움말 + + + + Fields + 필드 + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + 각 필드는 중괄호 안에 쓰며 만화의 메타데이터로 바뀝니다. 삽입 메뉴에 모든 필드가 있습니다. + + + + {series} gives %1 + {series} → %1 + + + + Optional parts + 선택 부분 + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + < 와 > 사이에 쓴 부분은 그 안의 모든 필드가 비어 있으면 완전히 사라집니다. 괄호나 앞에 붙는 번호 기호처럼 필드에 딸린 문장 부호에 사용하세요. 이름의 처음과 끝에 있는 공백은 이 부분이 없어도 잘립니다. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) 연도가 없으면 %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> 연도가 없으면 %1 + + + + Numbers + 번호 + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + 콜론과 0을 몇 개 써서 호 번호를 채우세요. 그러면 파일 탐색기에서 호가 순서대로 정렬됩니다. + + + + + Folders + 폴더 + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + 파일 이름 형식에는 슬래시를 넣을 수 없습니다. 각 만화는 현재 폴더에 그대로 있습니다. 만화를 옮기려면 폴더로 정리를 사용하세요. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + 슬래시로 나눈 각 부분이 폴더가 됩니다. 마지막 부분이 파일 이름이 됩니다. 원래 확장자는 항상 유지됩니다. diff --git a/YACReaderLibrary/yacreaderlibrary_nl.ts b/YACReaderLibrary/yacreaderlibrary_nl.ts index 0328269fa..7b94a71d5 100644 --- a/YACReaderLibrary/yacreaderlibrary_nl.ts +++ b/YACReaderLibrary/yacreaderlibrary_nl.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + De mapvermelding is niet gevonden in de database van de bibliotheek. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Nieuwe map toevoegen - + Folder name: Mapnaam: @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. De geselecteerde map bevat geen bibliotheek. @@ -1059,7 +1059,7 @@ Bibliotheek ' %1' is niet langer beschikbaar. Wilt u het verwijderen? - + Do you want remove Wilt u verwijderen @@ -1074,7 +1074,7 @@ Bibliotheek niet beschikbaar - + YACReader Library YACReader Bibliotheek @@ -1084,12 +1084,12 @@ Bijwerken is nodig - + Library name already exists Bibliotheek naam bestaat al - + There is another library with the name '%1'. Er is al een bibliotheek met de naam ' %1 '. @@ -1109,22 +1109,22 @@ Alle geselecteerde strips worden verwijderd van uw schijf. Weet u het zeker? - + Library not found Bibliotheek niet gevonden - + library? Bibliotheek? - + Are you sure? Weet u het zeker? - + Delete folder Map verwijderen @@ -1149,78 +1149,88 @@ Strips verplaatsen... - + Folder name: Mapnaam: - - - + + + No folder selected Geen map geselecteerd - - - + + + Please, select a folder first Selecteer eerst een map - + Error in path Fout in pad - + There was an error accessing the folder's path Er is een fout opgetreden bij het verkrijgen van toegang tot het pad van de map - + The selected folder and all its contents will be deleted from your disk. Are you sure? De geselecteerde map en de volledige inhoud ervan worden van uw schijf verwijderd. Weet je het zeker? - + Unable to delete Kan niet verwijderen - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Er is een probleem opgetreden bij het verwijderen van de geselecteerde mappen. Controleer de schrijfrechten en zorg ervoor dat geen toepassingen deze mappen of bestanden daarin gebruiken. - + + Rename or organize files + Bestanden hernoemen of ordenen + + + + Set the type of the selected comics + Het type van de geselecteerde strips instellen + + + Search filters Zoekfilters - + Unread Ongelezen - + In progress Bezig - + Highly rated Hoog gewaardeerd - + Recently added Onlangs toegevoegd - + Search syntax… Zoeksyntaxis… @@ -1245,14 +1255,14 @@ Als u zeker weet dat er geen ander herstel bezig is, kan de vergrendeling worden verwijderd. Vergrendeling verwijderen en doorgaan? - + Package operation failed - + Pakketbewerking mislukt - + The covers package operation could not be completed. - + De bewerking van het omslagpakket kon niet worden voltooid. @@ -1260,48 +1270,50 @@ Herstel na onderbroken terugzetting mislukt - + Rename folder Map hernoemen - + Invalid folder name - + Ongeldige mapnaam - + The folder name is empty or contains characters that are not supported. - + De mapnaam is leeg of bevat tekens die niet worden ondersteund. - - - + + + Unable to rename folder - + Kan de map niet hernoemen - + A file or folder named '%1' already exists. - + Er bestaat al een bestand of map met de naam '%1'. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + De map kon niet op de schijf worden hernoemd. Controleer de mapnaam en de schrijfrechten. + +Map: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + De database van de bibliotheek kon niet worden bijgewerkt. Het hernoemen van de map op de schijf is teruggedraaid. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + De database van de bibliotheek kon niet worden bijgewerkt en het hernoemen van de map op de schijf kon niet worden teruggedraaid. De bibliotheek moet nu handmatig worden bijgewerkt. @@ -1309,12 +1321,12 @@ Folder: %1 Bewaar hoesjes - + You are adding too many libraries. U voegt te veel bibliotheken toe. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1479,12 +1491,12 @@ You can restore a backup from the Library menu or recreate the library. Je kunt een back-up herstellen via het menu Bibliotheek of de bibliotheek opnieuw maken. - + Remove and delete metadata and backups Metagegevens en back-ups verwijderen en wissen - + Library info Bibliotheekinformatie @@ -1504,22 +1516,22 @@ Je kunt een back-up herstellen via het menu Bibliotheek of de bibliotheek opnieu Nummers toewijzen beginnend met: - + Invalid image Ongeldige afbeelding - + The selected file is not a valid image. Het geselecteerde bestand is geen geldige afbeelding. - + Error saving cover Fout bij opslaan van dekking - + There was an error saving the cover image. Er is een fout opgetreden bij het opslaan van de omslagafbeelding. @@ -1700,7 +1712,7 @@ Ontbrekende bestanden: %3 - + Set as read Instellen als gelezen @@ -1711,7 +1723,7 @@ Ontbrekende bestanden: %3 - + Set as unread Instellen als ongelezen @@ -1722,7 +1734,7 @@ Ontbrekende bestanden: %3 - + manga Manga @@ -1733,7 +1745,7 @@ Ontbrekende bestanden: %3 - + comic grappig @@ -1754,7 +1766,7 @@ Ontbrekende bestanden: %3 - + web comic web-strip @@ -1765,7 +1777,7 @@ Ontbrekende bestanden: %3 - + yonkoma yokoma @@ -1823,7 +1835,7 @@ Ontbrekende bestanden: %3 Rename the current folder on disk and in the library - + De huidige map hernoemen op de schijf en in de bibliotheek @@ -1873,37 +1885,44 @@ Ontbrekende bestanden: %3 - - Organize files - + + Rename files... + Organize files + Bestanden hernoemen... + + + + + Organize into folders... + In mappen ordenen... - + Set as uncompleted Ingesteld als onvoltooid - + Set as completed Instellen als voltooid - + Set custom cover Aangepaste omslag instellen - + Delete custom cover Aangepaste omslag verwijderen - + western manga (left to right) westerse manga (van links naar rechts) - + Open containing folder... Open map ... @@ -1912,133 +1931,133 @@ Ontbrekende bestanden: %3 Stripbeoordeling opnieuw instellen - + Select all comics Selecteer alle strips - + Edit Bewerken - + Assign current order to comics Wijs de huidige volgorde toe aan strips - + Update cover Strip omslagen bijwerken - + Delete selected comics Geselecteerde strips verwijderen - + Delete metadata from selected comics Verwijder metadata uit geselecteerde strips - + Download tags from Comic Vine Tags downloaden van Comic Vine - + Focus search line Focus zoeklijn - + Focus comics view Focus stripweergave - + Edit shortcuts Snelkoppelingen bewerken - + &Quit &Afsluiten - + Update folder Map bijwerken - + Update current folder Werk de huidige map bij - + Scan legacy XML metadata Scan oudere XML-metagegevens - + Add new reading list Nieuwe leeslijst toevoegen - + Add a new reading list to the current library Voeg een nieuwe leeslijst toe aan de huidige bibliotheek - + Remove reading list Leeslijst verwijderen - + Remove current reading list from the library Verwijder de huidige leeslijst uit de bibliotheek - + Add new label Nieuw etiket toevoegen - + Add a new label to this library Voeg een nieuw label toe aan deze bibliotheek - + Rename selected list Hernoem de geselecteerde lijst - + Rename any selected labels or lists Hernoem alle geselecteerde labels of lijsten - + Add to... Toevoegen aan... - + Favorites Favorieten - + Add selected comics to favorites list Voeg geselecteerde strips toe aan de favorietenlijst - + Reset rating Beoordeling opnieuw instellen @@ -2073,8 +2092,8 @@ Ontbrekende bestanden: %3 - - + + Set type Soort instellen @@ -2094,53 +2113,53 @@ Ontbrekende bestanden: %3 Grappig - + Open folder... Map openen ... - + Update folder Map bijwerken - + Rename folder Map hernoemen - + Rescan library for XML info Bibliotheek opnieuw scannen op XML-info - + Set as uncompleted Ingesteld als onvoltooid - + Set as completed Instellen als voltooid - + Set as read Instellen als gelezen - - + + Set as unread Instellen als ongelezen - + Set custom cover Aangepaste omslag instellen - + Delete custom cover Aangepaste omslag verwijderen @@ -2476,123 +2495,547 @@ Om een ​​automatische update te stoppen, tikt u op de laadindicator naast de Herstart is nodig + + OrganizeFiles + + + Renamed, %1 is already in use + Hernoemd, %1 is al in gebruik + + + + Missing metadata: %1 + Ontbrekende metagegevens: %1 + + + + %1 could not be created + %1 kon niet worden gemaakt + + OrganizeFilesCoordinator - - - + + Organize files - + Bestanden ordenen + + + + This folder does not contain any comics. + Deze map bevat geen strips. + + + + This library is busy: %1 + Deze bibliotheek is bezig: %1 + + + + the library database could not be opened + de database van de bibliotheek kon niet worden geopend + + + + the library database could not be locked for writing + de database van de bibliotheek kon niet worden vergrendeld om te schrijven + + + + a folder entry could not be restored + een mapvermelding kon niet worden hersteld + + + + a comic entry could not be updated + een stripvermelding kon niet worden bijgewerkt - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + de database van de bibliotheek kon niet worden opgeslagen: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + het verslag van de laatste ordening kon niet worden gelezen - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + de map %1 kon niet worden gemaakt + + + + %n file(s) could not be moved back + + %n bestand kon niet worden teruggezet + %n bestanden konden niet worden teruggezet + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Formaat: - - Available tokens: %1 - + + Organize files + Bestanden ordenen - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Bestanden hernoemen - - Place folders relative to the library root - + + Preparing the preview... + Voorbeeld voorbereiden... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Bestandsnaamopmaak: - - Format: - Formaat: + + &Path format: + &Padopmaak: - - Organize files - + + Filename format + Bestandsnaamopmaak - - Example: %1 - + + Path format + Padopmaak - - Unknown Series - + + Presets + Voorinstellingen - - Unknown Publisher - + + Insert + Invoegen - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Optioneel deel < > + + + + Disappears completely when the fields inside it are empty. + Verdwijnt volledig wanneer de velden erin leeg zijn. - + + Padded number {number:000} + Nummer met voorloopnullen {number:000} + + + + Format help... + Hulp bij de opmaak... + + + + selected folder + geselecteerde map + + + + library root + hoofdmap van de bibliotheek + + + + Move into + Verplaatsen naar + + + + Reset changes + Wijzigingen terugzetten + + + + Remove selected + Selectie verwijderen + + + + Show unchanged + Ongewijzigde tonen + + + + New name + Nieuwe naam + + + + Renamed from + Vorige naam + + + New location - + Nieuwe locatie - - Current location - + + Moved from + Vorige locatie - + Remove from list - + Uit de lijst verwijderen - + Move files - + Bestanden verplaatsen - - Remove selected - + + Cancel + Annuleren - - Organize files - + + Copy the list + De lijst kopiëren + + + + Undo + Ongedaan maken + + + + Close + Sluiten + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Een bestandsnaamopmaak mag geen "/" bevatten. Gebruik Bestanden ordenen om strips naar mappen te verplaatsen. + + + + This format cannot be used: %1 + Deze opmaak kan niet worden gebruikt: %1 + + + + new folder + nieuwe map + + + + This folder does not exist yet. It will be created. + Deze map bestaat nog niet. Ze wordt gemaakt. + + + + file not found + bestand niet gevonden + + + + This comic is in the library but not on disk. It is skipped. + Deze strip staat in de bibliotheek, maar niet op de schijf. Ze wordt overgeslagen. + + + + name in use + naam in gebruik + + + + no metadata + geen metagegevens + + + + already here + al hier + + + + This file is already in the right place. + Dit bestand staat al op de juiste plek. + + + + edited + bewerkt + + + + %n will be renamed + + %n wordt hernoemd + %n worden hernoemd + + + + + %n will move + + %n wordt verplaatst + %n worden verplaatst + + + + + %n unchanged + + %n ongewijzigd + %n ongewijzigd + + + + + %n renamed + + %n hernoemd + %n hernoemd + + + + + %n removed + + %n verwijderd + %n verwijderd + + + + + %n missing + + %n ontbreekt + %n ontbreken + + + + + %n new folder(s) + + %n nieuwe map + %n nieuwe mappen + + + + + %n manual change(s) kept + + %n handmatige wijziging behouden + %n handmatige wijzigingen behouden + + + + + Nothing would be renamed with this format. + Met deze opmaak wordt niets hernoemd. + + + + Nothing would move with this format. + Met deze opmaak wordt niets verplaatst. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n bestand wordt hernoemd. De mappen veranderen niet. U kunt dit daarna ongedaan maken. + %n bestanden worden hernoemd. De mappen veranderen niet. U kunt dit daarna ongedaan maken. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n bestand wordt verplaatst naar %1. Dit wijzigt uw bestanden op de schijf. U kunt dit daarna ongedaan maken. + %n bestanden worden verplaatst naar %1. Dit wijzigt uw bestanden op de schijf. U kunt dit daarna ongedaan maken. + + + + + Moving %1 of %2 +%3 + %1 van %2 wordt verplaatst +%3 + + + + Updating the library... + Bibliotheek bijwerken... + + + + Nothing was moved. + Er is niets verplaatst. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + Het verslag waarmee deze bewerking ongedaan gemaakt kan worden, kon niet worden geschreven. Daarom is de bewerking niet gestart: %1 + + + + %n file(s) renamed. + + %n bestand hernoemd. + %n bestanden hernoemd. + + + + + %n file(s) moved into %1. + + %n bestand verplaatst naar %1. + %n bestanden verplaatst naar %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + Het verslag van deze bewerking is vroegtijdig gestopt, daarom is de bewerking mee gestopt: %1 + + + + %n file(s) were not moved. + + %n bestand is niet verplaatst. + %n bestanden zijn niet verplaatst. + + + + + The library database could not be updated: %1 + De database van de bibliotheek kon niet worden bijgewerkt: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Gebruik Ongedaan maken om de bestanden terug te zetten, of werk de bibliotheek bij zodat ze bij de bestanden past. + + + + %n empty folder(s) were removed. + + %n lege map is verwijderd. + %n lege mappen zijn verwijderd. + + + + + %n file(s) could not be moved. + + %n bestand kon niet worden verplaatst. + %n bestanden konden niet worden verplaatst. + + + + + Moving the files back... + Bestanden worden teruggezet... + + + + Moving back %1 of %2 +%3 + %1 van %2 wordt teruggezet +%3 + + + + Everything was moved back. + Alles is teruggezet. + + + + The undo did not finish: %1 + Het ongedaan maken is niet voltooid: %1 + + + + Format help + Hulp bij de opmaak + + + + Fields + Velden + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Elk veld staat tussen accolades en wordt vervangen door de metagegevens van de strip. Het menu Invoegen toont ze allemaal. + + + + {series} gives %1 + {series} geeft %1 + + + + Optional parts + Optionele delen + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Een deel dat tussen de tekens < en > staat, verdwijnt volledig wanneer alle velden erin leeg zijn. Gebruik het voor leestekens die bij een veld horen, zoals haakjes of een nummerteken ervoor. Tekst aan het begin of het eind van een naam wordt ook zonder dit deel afgekapt. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) zonder jaar geeft %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> zonder jaar geeft %1 + + + + Numbers + Nummers + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Schrijf een dubbele punt en enkele nullen om het nummer aan te vullen. Zo blijven de nummers op volgorde in een bestandsbeheerder. + + + + + Folders + Mappen + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Een bestandsnaamopmaak mag geen schuine streep bevatten. Elke strip blijft in de huidige map. Gebruik In mappen ordenen om strips te verplaatsen. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Elk deel dat door een schuine streep wordt gescheiden, wordt een map. Het laatste deel wordt de bestandsnaam. De oorspronkelijke extensie blijft altijd behouden. diff --git a/YACReaderLibrary/yacreaderlibrary_pt.ts b/YACReaderLibrary/yacreaderlibrary_pt.ts index fbd255e6b..b27d18687 100644 --- a/YACReaderLibrary/yacreaderlibrary_pt.ts +++ b/YACReaderLibrary/yacreaderlibrary_pt.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + A entrada da pasta não foi encontrada no banco de dados da biblioteca. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Adicionar nova pasta - + Folder name: Nome da pasta: @@ -1030,22 +1030,22 @@ LibraryWindow - + Do you want remove Você deseja remover - + YACReader Library Biblioteca YACReader - + Are you sure? Você tem certeza? - + Delete folder Excluir pasta @@ -1115,78 +1115,88 @@ Quadrinhos em movimento... - + Folder name: Nome da pasta: - - - + + + No folder selected Nenhuma pasta selecionada - - - + + + Please, select a folder first Por favor, selecione uma pasta primeiro - + Error in path Erro no caminho - + There was an error accessing the folder's path Ocorreu um erro ao acessar o caminho da pasta - + The selected folder and all its contents will be deleted from your disk. Are you sure? A pasta selecionada e todo o seu conteúdo serão excluídos do disco. Tem certeza? - + Unable to delete Não foi possível excluir - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Ocorreu um problema ao tentar excluir as pastas selecionadas. Por favor, verifique as permissões de gravação e certifique-se de que nenhum aplicativo esteja usando essas pastas ou qualquer um dos arquivos contidos. - + + Rename or organize files + Renomear ou organizar arquivos + + + + Set the type of the selected comics + Definir o tipo dos quadrinhos selecionados + + + Search filters Filtros de pesquisa - + Unread Não lidos - + In progress Em andamento - + Highly rated Bem avaliados - + Recently added Adicionados recentemente - + Search syntax… Sintaxe de pesquisa… @@ -1211,58 +1221,60 @@ Se tem certeza de que nenhuma outra reparação está em execução, o bloqueio pode ser removido. Remover o bloqueio e continuar? - + Package operation failed - + Falha na operação de pacote - + The covers package operation could not be completed. - + Não foi possível concluir a operação com o pacote de capas. - + Rename folder Renomear pasta - + Invalid folder name - + Nome de pasta inválido - + The folder name is empty or contains characters that are not supported. - + O nome da pasta está vazio ou contém caracteres que não são suportados. - - - + + + Unable to rename folder - + Não foi possível renomear a pasta - + A file or folder named '%1' already exists. - + Já existe um arquivo ou pasta com o nome '%1'. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + Não foi possível renomear a pasta no disco. Verifique o nome da pasta e as permissões de gravação. + +Pasta: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + Não foi possível atualizar o banco de dados da biblioteca. A renomeação da pasta no disco foi revertida. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + Não foi possível atualizar o banco de dados da biblioteca nem reverter a renomeação da pasta no disco. Agora a biblioteca precisa ser atualizada manualmente. @@ -1270,12 +1282,12 @@ Folder: %1 Salvar capas - + You are adding too many libraries. Você está adicionando muitas bibliotecas. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1288,12 +1300,12 @@ Você provavelmente só precisa de uma biblioteca em sua pasta de quadrinhos de YACReaderLibrary não impedirá você de criar mais bibliotecas, mas você deve manter o número de bibliotecas baixo. - + Library not found Biblioteca não encontrada - + The selected folder doesn't contain any library. A pasta selecionada não contém nenhuma biblioteca. @@ -1450,12 +1462,12 @@ You can restore a backup from the Library menu or recreate the library. Pode restaurar uma cópia de segurança no menu Biblioteca ou recriar a biblioteca. - + library? biblioteca? - + Remove and delete metadata and backups Remover e eliminar metadados e cópias de segurança @@ -1464,7 +1476,7 @@ Pode restaurar uma cópia de segurança no menu Biblioteca ou recriar a bibliote Remover e excluir metadados - + Library info Informações da biblioteca @@ -1484,22 +1496,22 @@ Pode restaurar uma cópia de segurança no menu Biblioteca ou recriar a bibliote Atribua números começando em: - + Invalid image Imagem inválida - + The selected file is not a valid image. O arquivo selecionado não é uma imagem válida. - + Error saving cover Erro ao salvar a capa - + There was an error saving the cover image. Ocorreu um erro ao salvar a imagem da capa. @@ -1524,12 +1536,12 @@ Pode restaurar uma cópia de segurança no menu Biblioteca ou recriar a bibliote Os quadrinhos serão excluídos apenas do rótulo/lista atual. Tem certeza? - + Library name already exists O nome da biblioteca já existe - + There is another library with the name '%1'. Existe outra biblioteca com o nome '%1'. @@ -1700,7 +1712,7 @@ Arquivos ausentes: %3 - + Set as read Definir como lido @@ -1711,7 +1723,7 @@ Arquivos ausentes: %3 - + Set as unread Definir como não lido @@ -1722,7 +1734,7 @@ Arquivos ausentes: %3 - + manga mangá @@ -1733,7 +1745,7 @@ Arquivos ausentes: %3 - + comic cômico @@ -1754,7 +1766,7 @@ Arquivos ausentes: %3 - + web comic quadrinhos da web @@ -1765,7 +1777,7 @@ Arquivos ausentes: %3 - + yonkoma tira yonkoma @@ -1823,7 +1835,7 @@ Arquivos ausentes: %3 Rename the current folder on disk and in the library - + Renomear a pasta atual no disco e na biblioteca @@ -1873,37 +1885,44 @@ Arquivos ausentes: %3 - - Organize files - + + Rename files... + Organize files + Renomear arquivos... + + + + + Organize into folders... + Organizar em pastas... - + Set as uncompleted Definir como incompleto - + Set as completed Definir como concluído - + Set custom cover Definir capa personalizada - + Delete custom cover Excluir capa personalizada - + western manga (left to right) mangá ocidental (da esquerda para a direita) - + Open containing folder... Abrir a pasta contendo... @@ -1912,133 +1931,133 @@ Arquivos ausentes: %3 Redefinir classificação de quadrinhos - + Select all comics Selecione todos os quadrinhos - + Edit Editar - + Assign current order to comics Atribuir ordem atual aos quadrinhos - + Update cover Atualizar capa - + Delete selected comics Excluir quadrinhos selecionados - + Delete metadata from selected comics Excluir metadados dos quadrinhos selecionados - + Download tags from Comic Vine Baixe tags do Comic Vine - + Focus search line Linha de pesquisa de foco - + Focus comics view Visualização de quadrinhos em foco - + Edit shortcuts Editar atalhos - + &Quit &Qfato - + Update folder Atualizar pasta - + Update current folder Atualizar pasta atual - + Scan legacy XML metadata Digitalize metadados XML legados - + Add new reading list Adicionar nova lista de leitura - + Add a new reading list to the current library Adicione uma nova lista de leitura à biblioteca atual - + Remove reading list Remover lista de leitura - + Remove current reading list from the library Remover lista de leitura atual da biblioteca - + Add new label Adicionar novo rótulo - + Add a new label to this library Adicione um novo rótulo a esta biblioteca - + Rename selected list Renomear lista selecionada - + Rename any selected labels or lists Renomeie quaisquer rótulos ou listas selecionados - + Add to... Adicionar à... - + Favorites Favoritos - + Add selected comics to favorites list Adicione quadrinhos selecionados à lista de favoritos - + Reset rating Redefinir classificação @@ -2073,8 +2092,8 @@ Arquivos ausentes: %3 - - + + Set type Definir tipo @@ -2094,53 +2113,53 @@ Arquivos ausentes: %3 Quadrinhos - + Open folder... Abrir pasta... - + Update folder Atualizar pasta - + Rename folder Renomear pasta - + Rescan library for XML info Reanalisar biblioteca para informa??es XML - + Set as uncompleted Definir como incompleto - + Set as completed Definir como concluído - + Set as read Definir como lido - - + + Set as unread Definir como não lido - + Set custom cover Definir capa personalizada - + Delete custom cover Excluir capa personalizada @@ -2476,123 +2495,547 @@ Para interromper uma atualização automática, toque no indicador de carregamen Reiniciar é necessário + + OrganizeFiles + + + Renamed, %1 is already in use + Renomeado, %1 já está em uso + + + + Missing metadata: %1 + Metadados ausentes: %1 + + + + %1 could not be created + Não foi possível criar %1 + + OrganizeFilesCoordinator - - - + + Organize files - + Organizar arquivos + + + + This folder does not contain any comics. + Esta pasta não contém nenhum quadrinho. + + + + This library is busy: %1 + Esta biblioteca está ocupada: %1 + + + + the library database could not be opened + não foi possível abrir o banco de dados da biblioteca + + + + the library database could not be locked for writing + não foi possível bloquear o banco de dados da biblioteca para gravação + + + + a folder entry could not be restored + não foi possível restaurar uma entrada de pasta + + + + a comic entry could not be updated + não foi possível atualizar uma entrada de quadrinho - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + não foi possível salvar o banco de dados da biblioteca: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + não foi possível ler o registro da última organização - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + não foi possível criar a pasta %1 + + + + %n file(s) could not be moved back + + não foi possível mover %n arquivo de volta + não foi possível mover %n arquivos de volta + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Formatar: - - Available tokens: %1 - + + Organize files + Organizar arquivos - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Renomear arquivos - - Place folders relative to the library root - + + Preparing the preview... + Preparando a pré-visualização... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Formato do nome do arquivo: - - Format: - Formatar: + + &Path format: + Formato do &caminho: - - Organize files - + + Filename format + Formato do nome do arquivo - - Example: %1 - + + Path format + Formato do caminho - - Unknown Series - + + Presets + Predefinições - - Unknown Publisher - + + Insert + Inserir - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - + + + Optional part < > + Parte opcional < > + + + + Disappears completely when the fields inside it are empty. + Desaparece completamente quando os campos dentro dela estão vazios. - + + Padded number {number:000} + Número com zeros {number:000} + + + + Format help... + Ajuda sobre o formato... + + + + selected folder + pasta selecionada + + + + library root + raiz da biblioteca + + + + Move into + Mover para + + + + Reset changes + Descartar as alterações + + + + Remove selected + Remover os selecionados + + + + Show unchanged + Mostrar os que não mudam + + + + New name + Novo nome + + + + Renamed from + Nome anterior + + + New location - + Novo local - - Current location - + + Moved from + Local anterior - + Remove from list - + Remover da lista - + Move files - + Mover os arquivos - - Remove selected - + + Cancel + Cancelar - - Organize files - + + Copy the list + Copiar a lista + + + + Undo + Desfazer + + + + Close + Fechar + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Um formato de nome de arquivo não pode conter "/". Use Organizar arquivos para mover quadrinhos para pastas. + + + + This format cannot be used: %1 + Este formato não pode ser usado: %1 + + + + new folder + pasta nova + + + + This folder does not exist yet. It will be created. + Esta pasta ainda não existe. Ela será criada. + + + + file not found + arquivo não encontrado + + + + This comic is in the library but not on disk. It is skipped. + Este quadrinho está na biblioteca, mas não está no disco. Ele será ignorado. + + + + name in use + nome em uso + + + + no metadata + sem metadados + + + + already here + já está aqui + + + + This file is already in the right place. + Este arquivo já está no lugar certo. + + + + edited + editado + + + + %n will be renamed + + %n será renomeado + %n serão renomeados + + + + + %n will move + + %n será movido + %n serão movidos + + + + + %n unchanged + + %n sem alteração + %n sem alterações + + + + + %n renamed + + %n renomeado + %n renomeados + + + + + %n removed + + %n removido + %n removidos + + + + + %n missing + + %n ausente + %n ausentes + + + + + %n new folder(s) + + %n pasta nova + %n pastas novas + + + + + %n manual change(s) kept + + %n alteração manual mantida + %n alterações manuais mantidas + + + + + Nothing would be renamed with this format. + Com este formato, nada seria renomeado. + + + + Nothing would move with this format. + Com este formato, nada seria movido. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n arquivo será renomeado. As pastas não mudam. Você pode desfazer depois. + %n arquivos serão renomeados. As pastas não mudam. Você pode desfazer depois. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n arquivo será movido para %1. Isso altera seus arquivos no disco. Você pode desfazer depois. + %n arquivos serão movidos para %1. Isso altera seus arquivos no disco. Você pode desfazer depois. + + + + + Moving %1 of %2 +%3 + Movendo %1 de %2 +%3 + + + + Updating the library... + Atualizando a biblioteca... + + + + Nothing was moved. + Nada foi movido. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + Não foi possível gravar o registro que permitiria desfazer esta execução, por isso ela não começou: %1 + + + + %n file(s) renamed. + + %n arquivo renomeado. + %n arquivos renomeados. + + + + + %n file(s) moved into %1. + + %n arquivo movido para %1. + %n arquivos movidos para %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + O registro desta execução parou antes do fim, por isso a execução parou junto: %1 + + + + %n file(s) were not moved. + + %n arquivo não foi movido. + %n arquivos não foram movidos. + + + + + The library database could not be updated: %1 + Não foi possível atualizar o banco de dados da biblioteca: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Use Desfazer para mover os arquivos de volta ou atualize a biblioteca para que ela corresponda aos arquivos. + + + + %n empty folder(s) were removed. + + %n pasta vazia foi removida. + %n pastas vazias foram removidas. + + + + + %n file(s) could not be moved. + + Não foi possível mover %n arquivo. + Não foi possível mover %n arquivos. + + + + + Moving the files back... + Movendo os arquivos de volta... + + + + Moving back %1 of %2 +%3 + Movendo de volta %1 de %2 +%3 + + + + Everything was moved back. + Tudo foi movido de volta. + + + + The undo did not finish: %1 + A ação de desfazer não foi concluída: %1 + + + + Format help + Ajuda sobre o formato + + + + Fields + Campos + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Cada campo é escrito entre chaves e é substituído pelos metadados do quadrinho. O menu Inserir lista todos eles. + + + + {series} gives %1 + {series} resulta em %1 + + + + Optional parts + Partes opcionais + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Uma parte escrita entre os sinais < e > desaparece completamente quando todos os campos dentro dela estão vazios. Use-a para a pontuação que pertence a um campo, como parênteses ou um sinal de número inicial. O texto no início ou no fim de um nome é aparado sem ela. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) sem ano resulta em %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> sem ano resulta em %1 + + + + Numbers + Números + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Escreva dois-pontos e alguns zeros para completar o número da edição. Assim as edições ficam em ordem em um gerenciador de arquivos. + + + + + Folders + Pastas + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Um formato de nome de arquivo não pode conter uma barra. Cada quadrinho fica na pasta atual. Use Organizar em pastas para mover quadrinhos. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Cada parte separada por uma barra vira uma pasta. A última parte vira o nome do arquivo. A extensão original é sempre mantida. diff --git a/YACReaderLibrary/yacreaderlibrary_ru.ts b/YACReaderLibrary/yacreaderlibrary_ru.ts index 99e4d4b7c..e3fd1072b 100644 --- a/YACReaderLibrary/yacreaderlibrary_ru.ts +++ b/YACReaderLibrary/yacreaderlibrary_ru.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + Запись о папке не найдена в базе данных библиотеки. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Добавить новую папку - + Folder name: Имя папки: @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. Выбранная папка не содержит ни одной библиотеки. @@ -1040,17 +1040,17 @@ Эта библиотека была создана с предыдущей версией YACReaderLibrary. Она должна быть обновлена. Обновить сейчас? - + Folder name: Имя папки: - + The selected folder and all its contents will be deleted from your disk. Are you sure? Выбранная папка и все ее содержимое будет удалено с вашего жёсткого диска. Вы уверены? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Возникла проблема при удалении выбранных папок. Пожалуйста, проверьте права на запись и убедитесь что другие приложения не используют эти папки или файлы. @@ -1065,7 +1065,7 @@ Библиотека из старой версии YACreader - + There was an error accessing the folder's path Ошибка доступа к пути папки @@ -1095,12 +1095,12 @@ Библиотека '%1' больше не доступна. Вы хотите удалить ее? - + Do you want remove Вы хотите удалить библиотеку - + Error in path Ошибка в пути @@ -1115,7 +1115,7 @@ Сохранить обложки - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1128,7 +1128,7 @@ YACReaderLibrary will not stop you from creating more libraries but you should k YACReaderLibrary не помешает вам создать больше библиотек, но вы должны иметь не большое количество библиотек. - + Library info Информация о библиотеке @@ -1138,9 +1138,9 @@ YACReaderLibrary не помешает вам создать больше биб Порядковый номер - - - + + + Please, select a folder first Пожалуйста, сначала выберите папку @@ -1155,12 +1155,12 @@ YACReaderLibrary не помешает вам создать больше биб Возникла проблема при удалении выбранных комиксов. Пожалуйста, проверьте права на запись для выбранных файлов или содержащую их папку. - + YACReader Library Библиотека YACReader - + You are adding too many libraries. Вы добавляете слишком много библиотек. @@ -1170,17 +1170,17 @@ YACReaderLibrary не помешает вам создать больше биб Необходимо обновление - + Library name already exists Имя папки уже используется - + There is another library with the name '%1'. Уже существует другая папка с именем '%1'. - + Delete folder Удалить папку @@ -1195,27 +1195,27 @@ YACReaderLibrary не помешает вам создать больше биб Загрузить новую версию - + Remove and delete metadata and backups Удалить библиотеку, метаданные и резервные копии - + Invalid image Неверное изображение - + The selected file is not a valid image. Выбранный файл не является допустимым изображением. - + Error saving cover Не удалось сохранить обложку. - + There was an error saving the cover image. Не удалось сохранить изображение обложки. @@ -1225,9 +1225,9 @@ YACReaderLibrary не помешает вам создать больше биб Удалить комиксы - - - + + + No folder selected Ни одна папка не была выбрана @@ -1242,43 +1242,53 @@ YACReaderLibrary не помешает вам создать больше биб Убрать комиксы - + Library not found Библиотека не найдена - + Unable to delete Не удалось удалить - + + Rename or organize files + Переименовать или упорядочить файлы + + + + Set the type of the selected comics + Задать тип выбранных комиксов + + + Search filters Фильтры поиска - + Unread Непрочитанные - + In progress В процессе - + Highly rated С высокой оценкой - + Recently added Недавно добавленные - + Search syntax… Синтаксис поиска… @@ -1303,14 +1313,14 @@ YACReaderLibrary не помешает вам создать больше биб Если вы уверены, что никакое другое восстановление не выполняется, блокировку можно снять. Снять блокировку и продолжить? - + Package operation failed - + Не удалось выполнить операцию с пакетом - + The covers package operation could not be completed. - + Не удалось завершить операцию с пакетом обложек. @@ -1318,48 +1328,50 @@ YACReaderLibrary не помешает вам создать больше биб Не удалось восстановиться после прерванного восстановления - + Rename folder Переименовать папку - + Invalid folder name - + Недопустимое имя папки - + The folder name is empty or contains characters that are not supported. - + Имя папки пустое или содержит неподдерживаемые символы. - - - + + + Unable to rename folder - + Не удалось переименовать папку - + A file or folder named '%1' already exists. - + Файл или папка с именем «%1» уже существует. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + Не удалось переименовать папку на диске. Проверьте имя папки и права на запись. + +Папка: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + Не удалось обновить базу данных библиотеки. Переименование папки на диске отменено. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + Не удалось обновить базу данных библиотеки, и переименование папки на диске тоже не удалось отменить. Теперь библиотеку нужно обновить вручную. @@ -1514,12 +1526,12 @@ You can restore a backup from the Library menu or recreate the library. Можно восстановить резервную копию из меню «Библиотека» или создать библиотеку заново. - + library? ? - + Are you sure? Вы уверены? @@ -1700,7 +1712,7 @@ Missing files: %3 - + Set as read Отметить как прочитано @@ -1711,7 +1723,7 @@ Missing files: %3 - + Set as unread Отметить как не прочитано @@ -1722,7 +1734,7 @@ Missing files: %3 - + manga манга @@ -1733,7 +1745,7 @@ Missing files: %3 - + comic комикс @@ -1754,7 +1766,7 @@ Missing files: %3 - + web comic веб-комикс @@ -1765,7 +1777,7 @@ Missing files: %3 - + yonkoma йонкома @@ -1823,7 +1835,7 @@ Missing files: %3 Rename the current folder on disk and in the library - + Переименовать текущую папку на диске и в библиотеке @@ -1873,37 +1885,44 @@ Missing files: %3 - - Organize files - + + Rename files... + Organize files + Переименовать файлы... + + + + + Organize into folders... + Разложить по папкам... - + Set as uncompleted Отметить как не завершено - + Set as completed Отметить как завершено - + Set custom cover Установить собственную обложку - + Delete custom cover Удалить пользовательскую обложку - + western manga (left to right) западная манга (слева направо) - + Open containing folder... Открыть выбранную папку... @@ -1912,133 +1931,133 @@ Missing files: %3 Сбросить рейтинг комикса - + Select all comics Выбрать все комиксы - + Edit Редактировать информацию - + Assign current order to comics Назначить порядковый номер - + Update cover Обновить обложки - + Delete selected comics Удалить выбранное - + Delete metadata from selected comics Удалить метаданные из выбранных комиксов - + Download tags from Comic Vine Скачать теги из Comic Vine - + Focus search line Строка поиска фокуса - + Focus comics view Просмотр комиксов в фокусе - + Edit shortcuts Редактировать горячие клавиши - + &Quit &Qкостюм - + Update folder Обновить папку - + Update current folder Обновить выбранную папку - + Scan legacy XML metadata Сканировать устаревшие метаданные XML - + Add new reading list Создать новый список чтения - + Add a new reading list to the current library Создать новый список чтения - + Remove reading list Удалить список чтения - + Remove current reading list from the library Удалить выбранный ярлык/список чтения - + Add new label Создать новый ярлык - + Add a new label to this library Создать новый ярлык - + Rename selected list Переименовать выбранный список - + Rename any selected labels or lists Переименовать выбранный ярлык/список чтения - + Add to... Добавить в... - + Favorites Избранное - + Add selected comics to favorites list Добавить выбранные комиксы в список избранного - + Reset rating Сбросить рейтинг @@ -2073,8 +2092,8 @@ Missing files: %3 - - + + Set type Тип установки @@ -2094,53 +2113,53 @@ Missing files: %3 Комикс - + Open folder... Открыть папку... - + Update folder Обновить папку - + Rename folder Переименовать папку - + Rescan library for XML info Повторное сканирование библиотеки для получения информации XML - + Set as uncompleted Отметить как не завершено - + Set as completed Отметить как завершено - + Set as read Отметить как прочитано - - + + Set as unread Отметить как не прочитано - + Set custom cover Установить собственную обложку - + Delete custom cover Удалить пользовательскую обложку @@ -2476,124 +2495,563 @@ To stop an automatic update tap on the loading indicator next to the Libraries t Требуется перезагрузка + + OrganizeFiles + + + Renamed, %1 is already in use + Переименовано, имя %1 уже занято + + + + Missing metadata: %1 + Отсутствуют метаданные: %1 + + + + %1 could not be created + Не удалось создать %1 + + OrganizeFilesCoordinator - - - + + Organize files - + Упорядочить файлы + + + + This folder does not contain any comics. + В этой папке нет комиксов. + + + + This library is busy: %1 + Эта библиотека занята: %1 + + + + the library database could not be opened + не удалось открыть базу данных библиотеки + + + + the library database could not be locked for writing + не удалось заблокировать базу данных библиотеки для записи + + + + a folder entry could not be restored + не удалось восстановить запись о папке + + + + a comic entry could not be updated + не удалось обновить запись о комиксе - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + не удалось сохранить базу данных библиотеки: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + не удалось прочитать запись о последней операции упорядочивания - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + не удалось создать папку %1 + + + + %n file(s) could not be moved back + + не удалось вернуть на место %n файл + не удалось вернуть на место %n файла + не удалось вернуть на место %n файлов + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Формат: - - Available tokens: %1 - + + Organize files + Упорядочить файлы - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Переименовать файлы - - Place folders relative to the library root - + + Preparing the preview... + Подготовка предварительного просмотра... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Формат имени файла: - - Format: - Формат: + + &Path format: + &Формат пути: - - Organize files - + + Filename format + Формат имени файла - - Example: %1 - + + Path format + Формат пути - - Unknown Series - + + Presets + Шаблоны - - Unknown Publisher - + + Insert + Вставить - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - - - + + + Optional part < > + Необязательная часть < > + + + + Disappears completely when the fields inside it are empty. + Полностью исчезает, если поля внутри пусты. - + + Padded number {number:000} + Номер с нулями {number:000} + + + + Format help... + Справка по формату... + + + + selected folder + выбранная папка + + + + library root + корень библиотеки + + + + Move into + Переместить в + + + + Reset changes + Сбросить изменения + + + + Remove selected + Убрать выбранные + + + + Show unchanged + Показывать без изменений + + + + New name + Новое имя + + + + Renamed from + Прежнее имя + + + New location - + Новое расположение - - Current location - + + Moved from + Прежнее расположение - + Remove from list - + Убрать из списка - + Move files - + Переместить файлы - - Remove selected - + + Cancel + Отмена - - Organize files - + + Copy the list + Скопировать список + + + + Undo + Отменить + + + + Close + Закрыть + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Формат имени файла не может содержать "/". Используйте «Упорядочить файлы», чтобы переместить комиксы в папки. + + + + This format cannot be used: %1 + Этот формат нельзя использовать: %1 + + + + new folder + новая папка + + + + This folder does not exist yet. It will be created. + Этой папки ещё нет. Она будет создана. + + + + file not found + файл не найден + + + + This comic is in the library but not on disk. It is skipped. + Этот комикс есть в библиотеке, но отсутствует на диске. Он пропускается. + + + + name in use + имя занято + + + + no metadata + нет метаданных + + + + already here + уже здесь + + + + This file is already in the right place. + Этот файл уже находится в нужном месте. + + + + edited + изменено + + + + %n will be renamed + + %n будет переименован + %n будут переименованы + %n будут переименованы + + + + + %n will move + + %n будет перемещён + %n будут перемещены + %n будут перемещены + + + + + %n unchanged + + %n без изменений + %n без изменений + %n без изменений + + + + + %n renamed + + %n переименован + %n переименованы + %n переименованы + + + + + %n removed + + %n убран + %n убраны + %n убраны + + + + + %n missing + + %n отсутствует + %n отсутствуют + %n отсутствуют + + + + + %n new folder(s) + + %n новая папка + %n новые папки + %n новых папок + + + + + %n manual change(s) kept + + Сохранено %n ручное изменение + Сохранено %n ручных изменения + Сохранено %n ручных изменений + + + + + Nothing would be renamed with this format. + С этим форматом ничего не будет переименовано. + + + + Nothing would move with this format. + С этим форматом ничего не будет перемещено. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + Будет переименован %n файл. Папки не изменятся. Потом это можно отменить. + Будет переименовано %n файла. Папки не изменятся. Потом это можно отменить. + Будет переименовано %n файлов. Папки не изменятся. Потом это можно отменить. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n файл будет перемещён в %1. Это изменит ваши файлы на диске. Потом это можно отменить. + %n файла будут перемещены в %1. Это изменит ваши файлы на диске. Потом это можно отменить. + %n файлов будут перемещены в %1. Это изменит ваши файлы на диске. Потом это можно отменить. + + + + + Moving %1 of %2 +%3 + Перемещение %1 из %2 +%3 + + + + Updating the library... + Обновление библиотеки... + + + + Nothing was moved. + Ничего не перемещено. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + Не удалось записать данные, по которым эту операцию можно было бы отменить, поэтому она не началась: %1 + + + + %n file(s) renamed. + + Переименован %n файл. + Переименовано %n файла. + Переименовано %n файлов. + + + + + %n file(s) moved into %1. + + %n файл перемещён в %1. + %n файла перемещены в %1. + %n файлов перемещены в %1. + + + + + The record of this run stopped early, so the run stopped with it: %1 + Запись об этой операции прервалась, поэтому операция остановилась вместе с ней: %1 + + + + %n file(s) were not moved. + + %n файл не перемещён. + %n файла не перемещены. + %n файлов не перемещены. + + + + + The library database could not be updated: %1 + Не удалось обновить базу данных библиотеки: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Нажмите «Отменить», чтобы вернуть файлы на место, или обновите библиотеку, чтобы она соответствовала файлам. + + + + %n empty folder(s) were removed. + + Удалена %n пустая папка. + Удалены %n пустые папки. + Удалено %n пустых папок. + + + + + %n file(s) could not be moved. + + Не удалось переместить %n файл. + Не удалось переместить %n файла. + Не удалось переместить %n файлов. + + + + + Moving the files back... + Возврат файлов на место... + + + + Moving back %1 of %2 +%3 + Возврат %1 из %2 +%3 + + + + Everything was moved back. + Все файлы возвращены на место. + + + + The undo did not finish: %1 + Отмена не завершилась: %1 + + + + Format help + Справка по формату + + + + Fields + Поля + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Каждое поле пишется в фигурных скобках и заменяется метаданными комикса. Все поля перечислены в меню «Вставить». + + + + {series} gives %1 + {series} даёт %1 + + + + Optional parts + Необязательные части + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + Часть, записанная между знаками < и >, полностью исчезает, если все поля внутри неё пусты. Используйте её для знаков, которые относятся к полю, например для скобок или знака номера перед ним. Текст в начале и в конце имени обрезается и без неё. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) без года даёт %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> без года даёт %1 + + + + Numbers + Номера + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Поставьте двоеточие и несколько нулей, чтобы дополнить номер выпуска. Тогда выпуски останутся по порядку в файловом менеджере. + + + + + Folders + Папки + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Формат имени файла не может содержать косую черту. Каждый комикс остаётся в своей папке. Чтобы переместить комиксы, используйте «Разложить по папкам». + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Каждая часть, отделённая косой чертой, становится папкой. Последняя часть становится именем файла. Исходное расширение всегда сохраняется. diff --git a/YACReaderLibrary/yacreaderlibrary_source.ts b/YACReaderLibrary/yacreaderlibrary_source.ts index e7c3416fd..f50001c69 100644 --- a/YACReaderLibrary/yacreaderlibrary_source.ts +++ b/YACReaderLibrary/yacreaderlibrary_source.ts @@ -504,7 +504,7 @@ DBHelper - + The folder entry could not be found in the library database. @@ -753,12 +753,12 @@ FolderManagementCoordinator - + Add new folder - + Folder name: @@ -992,22 +992,22 @@ LibraryWindow - + Do you want remove - + YACReader Library - + Are you sure? - + Delete folder @@ -1067,78 +1067,88 @@ - + Folder name: - - - + + + No folder selected - - - + + + Please, select a folder first - + Error in path - + There was an error accessing the folder's path - + The selected folder and all its contents will be deleted from your disk. Are you sure? - + Unable to delete - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. - + + Rename or organize files + + + + + Set the type of the selected comics + + + + Search filters - + Unread - + In progress - + Highly rated - + Recently added - + Search syntax… @@ -1163,56 +1173,56 @@ - + Package operation failed - + The covers package operation could not be completed. - + Rename folder - + Invalid folder name - + The folder name is empty or contains characters that are not supported. - - - + + + Unable to rename folder - + A file or folder named '%1' already exists. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. @@ -1222,12 +1232,12 @@ Folder: %1 - + You are adding too many libraries. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1236,12 +1246,12 @@ YACReaderLibrary will not stop you from creating more libraries but you should k - + Library not found - + The selected folder doesn't contain any library. @@ -1384,17 +1394,17 @@ You can restore a backup from the Library menu or recreate the library. - + library? - + Remove and delete metadata and backups - + Library info @@ -1414,22 +1424,22 @@ You can restore a backup from the Library menu or recreate the library. - + Invalid image - + The selected file is not a valid image. - + Error saving cover - + There was an error saving the cover image. @@ -1454,12 +1464,12 @@ You can restore a backup from the Library menu or recreate the library. - + Library name already exists - + There is another library with the name '%1'. @@ -1638,7 +1648,7 @@ Missing files: %3 - + Set as read @@ -1649,7 +1659,7 @@ Missing files: %3 - + Set as unread @@ -1660,7 +1670,7 @@ Missing files: %3 - + manga @@ -1671,7 +1681,7 @@ Missing files: %3 - + comic @@ -1692,7 +1702,7 @@ Missing files: %3 - + web comic @@ -1703,7 +1713,7 @@ Missing files: %3 - + yonkoma @@ -1811,168 +1821,175 @@ Missing files: %3 - - Organize files + + Rename files... + Organize files - + + + Organize into folders... + + + + Set as uncompleted - + Set as completed - + Set custom cover - + Delete custom cover - + western manga (left to right) - + Open containing folder... Abrir a pasta contendo... - + Select all comics - + Edit - + Assign current order to comics - + Update cover - + Delete selected comics - + Delete metadata from selected comics - + Download tags from Comic Vine - + Focus search line - + Focus comics view - + Edit shortcuts - + &Quit - + Update folder - + Update current folder - + Scan legacy XML metadata - + Add new reading list - + Add a new reading list to the current library - + Remove reading list - + Remove current reading list from the library - + Add new label - + Add a new label to this library - + Rename selected list - + Rename any selected labels or lists - + Add to... - + Favorites - + Add selected comics to favorites list - + Reset rating @@ -2007,8 +2024,8 @@ Missing files: %3 - - + + Set type @@ -2028,53 +2045,53 @@ Missing files: %3 - + Open folder... - + Update folder - + Rename folder - + Rescan library for XML info - + Set as uncompleted - + Set as completed - + Set as read - - + + Set as unread - + Set custom cover - + Delete custom cover @@ -2407,122 +2424,540 @@ To stop an automatic update tap on the loading indicator next to the Libraries t + + OrganizeFiles + + + Renamed, %1 is already in use + + + + + Missing metadata: %1 + + + + + %1 could not be created + + + OrganizeFilesCoordinator - - - + + Organize files - - This folder does not contain any comics to organize. + + This folder does not contain any comics. - - All files are already organized according to this format. + + This library is busy: %1 - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. + + the library database could not be opened + + + the library database could not be locked for writing + + + + + a folder entry could not be restored + + + + + a comic entry could not be updated + + + + + the library database could not be saved: %1 + + + + + the record of the last organize run could not be read + + + + + the folder %1 could not be created + + + + + %n file(s) could not be moved back + + + + + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. + + Organize files - - Available tokens: %1 + + + Rename files - - {title} falls back to the series name when the comic has no title. + + Preparing the preview... - - Place folders relative to the library root + + &Filename format: - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. + + &Path format: - - Format: + + Filename format - - Organize files + + Path format - - Example: %1 + + Presets - - Unknown Series + + Insert - - Unknown Publisher + + Optional part < > - - - OrganizeFilesPreviewDialog + + + Disappears completely when the fields inside it are empty. + + + + + Padded number {number:000} + + + + + Format help... + + + + + selected folder + + + + + library root + + + + + Move into + + + + + Reset changes + + + + + Remove selected + + + + + Show unchanged + + + + + New name + + + + + Renamed from + + + + + New location + + + + + Moved from + + + + + Remove from list + + + + + Move files + + + + + Cancel + + + + + Copy the list + + + + + Undo + + + + + Close + + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + + + + + This format cannot be used: %1 + + + + + new folder + + + + + This folder does not exist yet. It will be created. + + + + + file not found + + + + + This comic is in the library but not on disk. It is skipped. + + + + + name in use + + + + + no metadata + + + + + already here + + + + + This file is already in the right place. + + + + + edited + + + + + %n will be renamed + + + + + + + + %n will move + + + + + + + + %n unchanged + + + + + - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. + + %n renamed + + + + + + + + %n removed + + + + + + + + %n missing + + + + + + + + %n new folder(s) + + + + + + + + %n manual change(s) kept - - New location + + Nothing would be renamed with this format. - - Current location + + Nothing would move with this format. + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + + + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + + + + - - Remove from list + + Moving %1 of %2 +%3 - - Move files + + Updating the library... - - Remove selected + + Nothing was moved. - - Organize files + + The record this run could be undone from could not be written, so the run did not start: %1 + + + + + %n file(s) renamed. + + + + + + + + %n file(s) moved into %1. + + + + + + + + The record of this run stopped early, so the run stopped with it: %1 + + + + + %n file(s) were not moved. + + + + + + + + The library database could not be updated: %1 + + + + + Use Undo to move the files back, or update the library to make it match the files. + + + + + %n empty folder(s) were removed. + + + + + + + + %n file(s) could not be moved. + + + + + + + + Moving the files back... + + + + + Moving back %1 of %2 +%3 + + + + + Everything was moved back. + + + + + The undo did not finish: %1 + + + + + Format help + + + + + Fields + + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + + + + + {series} gives %1 + + + + + Optional parts + + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + + + + + {series} ({year}) with no year gives %1 + + + + + {series}< ({year})> with no year gives %1 + + + + + Numbers + + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + + + + + + Folders + + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. diff --git a/YACReaderLibrary/yacreaderlibrary_tr.ts b/YACReaderLibrary/yacreaderlibrary_tr.ts index f81bb1a58..fef96c9f5 100644 --- a/YACReaderLibrary/yacreaderlibrary_tr.ts +++ b/YACReaderLibrary/yacreaderlibrary_tr.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + Klasör kaydı kütüphane veritabanında bulunamadı. @@ -775,12 +775,12 @@ FolderManagementCoordinator - + Add new folder Yeni klasör ekle - + Folder name: Klasör adı: @@ -1030,7 +1030,7 @@ LibraryWindow - + The selected folder doesn't contain any library. Seçilen dosya kütüphanede yok. @@ -1060,7 +1060,7 @@ Kütüphane '%1'ulaşılabilir değil. Kaldırmak ister misin? - + Do you want remove Kaldırmak ister misin @@ -1075,7 +1075,7 @@ Kütüphane ulaşılabilir değil - + YACReader Library YACReader Kütüphane @@ -1085,12 +1085,12 @@ Güncelleme gerekli - + Library name already exists Kütüphane ismi zaten alınmış - + There is another library with the name '%1'. Bu başka bir kütüphanenin adı '%1'. @@ -1110,22 +1110,22 @@ Seçilen tüm çizgi romanlar diskten silinecek emin misin ? - + Library not found Kütüphane bulunamadı - + library? kütüphane? - + Are you sure? Emin misin? - + Delete folder Klasörü sil @@ -1150,78 +1150,88 @@ Çizgi romanlar taşınıyor... - + Folder name: Klasör adı: - - - + + + No folder selected Hiçbir klasör seçilmedi - - - + + + Please, select a folder first Lütfen, önce bir klasör seçiniz - + Error in path Yolda hata - + There was an error accessing the folder's path Klasörün yoluna erişilirken hata oluştu - + The selected folder and all its contents will be deleted from your disk. Are you sure? Seçilen klasör ve tüm içeriği diskinizden silinecek. Emin misin? - + Unable to delete Silinemedi - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. Seçili klasörleri silmeye çalışırken bir sorun oluştu. Lütfen yazma izinlerini kontrol edin ve hiçbir uygulamanın bu klasörleri veya içerdikleri dosyaları kullanmadığından emin olun. - + + Rename or organize files + Dosyaları yeniden adlandır veya düzenle + + + + Set the type of the selected comics + Seçili çizgi romanların türünü ayarla + + + Search filters Arama filtreleri - + Unread Okunmamış - + In progress Devam eden - + Highly rated Yüksek puanlı - + Recently added Yakın zamanda eklenen - + Search syntax… Arama söz dizimi… @@ -1246,14 +1256,14 @@ Başka bir onarımın çalışmadığından eminseniz kilit kaldırılabilir. Kilit kaldırılıp devam edilsin mi? - + Package operation failed - + Paket işlemi başarısız oldu - + The covers package operation could not be completed. - + Kapak paketi işlemi tamamlanamadı. @@ -1261,48 +1271,50 @@ Geri yükleme kurtarması başarısız oldu - + Rename folder Klasörü yeniden adlandır - + Invalid folder name - + Geçersiz klasör adı - + The folder name is empty or contains characters that are not supported. - + Klasör adı boş veya desteklenmeyen karakterler içeriyor. - - - + + + Unable to rename folder - + Klasör yeniden adlandırılamıyor - + A file or folder named '%1' already exists. - + '%1' adlı bir dosya veya klasör zaten var. - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + Klasör diskte yeniden adlandırılamadı. Lütfen klasör adını ve yazma izinlerini denetleyin. + +Klasör: %1 - + The library database could not be updated. The folder rename on disk was reverted. - + Kütüphane veritabanı güncellenemedi. Klasörün diskteki yeni adı geri alındı. - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + Kütüphane veritabanı güncellenemedi ve klasörün diskteki yeni adı geri alınamadı. Kütüphanenin şimdi elle güncellenmesi gerekiyor. @@ -1310,12 +1322,12 @@ Folder: %1 Kapakları kaydet - + You are adding too many libraries. Çok fazla kütüphane ekliyorsunuz. - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1480,12 +1492,12 @@ You can restore a backup from the Library menu or recreate the library. Kitaplık menüsünden bir yedeği geri yükleyebilir veya kitaplığı yeniden oluşturabilirsiniz. - + Remove and delete metadata and backups Meta verileri ve yedekleri kaldır ve sil - + Library info Kütüphane bilgisi @@ -1505,22 +1517,22 @@ Kitaplık menüsünden bir yedeği geri yükleyebilir veya kitaplığı yeniden Şunlardan başlayarak numaralar ata: - + Invalid image Geçersiz resim - + The selected file is not a valid image. Seçilen dosya geçerli bir resim değil. - + Error saving cover Kapak kaydedilirken hata oluştu - + There was an error saving the cover image. Kapak resmi kaydedilirken bir hata oluştu. @@ -1701,7 +1713,7 @@ Eksik dosyalar: %3 - + Set as read Okundu olarak işaretle @@ -1712,7 +1724,7 @@ Eksik dosyalar: %3 - + Set as unread Hepsini okunmadı işaretle @@ -1723,7 +1735,7 @@ Eksik dosyalar: %3 - + manga manga t?r? @@ -1734,7 +1746,7 @@ Eksik dosyalar: %3 - + comic komik @@ -1755,7 +1767,7 @@ Eksik dosyalar: %3 - + web comic web çizgi romanı @@ -1766,7 +1778,7 @@ Eksik dosyalar: %3 - + yonkoma d?rt panelli @@ -1824,7 +1836,7 @@ Eksik dosyalar: %3 Rename the current folder on disk and in the library - + Geçerli klasörü diskte ve kütüphanede yeniden adlandır @@ -1874,37 +1886,44 @@ Eksik dosyalar: %3 - - Organize files - + + Rename files... + Organize files + Dosyaları yeniden adlandır... + + + + + Organize into folders... + Klasörlere düzenle... - + Set as uncompleted Tamamlanmamış olarak ayarla - + Set as completed Tamamlanmış olarak ayarla - + Set custom cover Özel kapak ayarla - + Delete custom cover Özel kapağı sil - + western manga (left to right) Batı mangası (soldan sağa) - + Open containing folder... Klasör açılıyor... @@ -1913,133 +1932,133 @@ Eksik dosyalar: %3 Çizgi roman reytingini sıfırla - + Select all comics Tüm çizgi romanları seç - + Edit Düzenle - + Assign current order to comics Geçerli sırayı çizgi romanlara ata - + Update cover Kapağı güncelle - + Delete selected comics Seçili çizgi romanları sil - + Delete metadata from selected comics Seçilen çizgi romanlardan meta verileri sil - + Download tags from Comic Vine Etiketleri Comic Vine sitesinden indir - + Focus search line Arama satırına odaklan - + Focus comics view Çizgi roman görünümüne odaklanın - + Edit shortcuts Kısayolları düzenle - + &Quit &Çıkış - + Update folder Klasörü güncelle - + Update current folder Geçerli klasörü güncelle - + Scan legacy XML metadata Eski XML meta verilerini tarayın - + Add new reading list Yeni okuma listesi ekle - + Add a new reading list to the current library Geçerli kitaplığa yeni bir okuma listesi ekle - + Remove reading list Okuma listesini kaldır - + Remove current reading list from the library Geçerli okuma listesini kütüphaneden kaldır - + Add new label Yeni etiket ekle - + Add a new label to this library Bu kitaplığa yeni bir etiket ekle - + Rename selected list Seçilen listeyi yeniden adlandır - + Rename any selected labels or lists Seçilen etiketleri ya da listeleri yeniden adlandır - + Add to... Şuraya ekle... - + Favorites Favoriler - + Add selected comics to favorites list Seçilen çizgi romanları favoriler listesine ekle - + Reset rating Puanı sıfırla @@ -2074,8 +2093,8 @@ Eksik dosyalar: %3 - - + + Set type Türü ayarla @@ -2095,53 +2114,53 @@ Eksik dosyalar: %3 Çizgi roman - + Open folder... Dosyayı aç... - + Update folder Klasörü güncelle - + Rename folder Klasörü yeniden adlandır - + Rescan library for XML info XML bilgisi için kitaplığı yeniden tarayın - + Set as uncompleted Tamamlanmamış olarak ayarla - + Set as completed Tamamlanmış olarak ayarla - + Set as read Okundu olarak işaretle - - + + Set as unread Hepsini okunmadı işaretle - + Set custom cover Özel kapak ayarla - + Delete custom cover Özel kapağı sil @@ -2477,122 +2496,531 @@ Otomatik güncellemeyi durdurmak için Kitaplıklar başlığının yanındaki y Yeniden başlatılmalı + + OrganizeFiles + + + Renamed, %1 is already in use + Yeniden adlandırıldı, %1 zaten kullanımda + + + + Missing metadata: %1 + Eksik üstveri: %1 + + + + %1 could not be created + %1 oluşturulamadı + + OrganizeFilesCoordinator - - - + + Organize files - + Dosyaları düzenle + + + + This folder does not contain any comics. + Bu klasör hiç çizgi roman içermiyor. + + + + This library is busy: %1 + Bu kütüphane meşgul: %1 + + + + the library database could not be opened + kütüphane veritabanı açılamadı + + + + the library database could not be locked for writing + kütüphane veritabanı yazma için kilitlenemedi + + + + a folder entry could not be restored + bir klasör kaydı geri yüklenemedi + + + + a comic entry could not be updated + bir çizgi roman kaydı güncellenemedi - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + kütüphane veritabanı kaydedilemedi: %1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + son düzenleme işleminin kaydı okunamadı - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + %1 klasörü oluşturulamadı + + + + %n file(s) could not be moved back + + %n dosya geri taşınamadı + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + Formato: - - Available tokens: %1 - + + Organize files + Dosyaları düzenle - - {title} falls back to the series name when the comic has no title. - + + + Rename files + Dosyaları yeniden adlandır - - Place folders relative to the library root - + + Preparing the preview... + Önizleme hazırlanıyor... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + &Dosya adı biçimi: - - Format: - Formato: + + &Path format: + &Yol biçimi: - - Organize files - + + Filename format + Dosya adı biçimi - - Example: %1 - + + Path format + Yol biçimi - - Unknown Series - + + Presets + Hazır ayarlar - - Unknown Publisher - + + Insert + Ekle - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - + + + Optional part < > + İsteğe bağlı bölüm < > + + + + Disappears completely when the fields inside it are empty. + İçindeki alanlar boşsa tümüyle kaybolur. - + + Padded number {number:000} + Sıfırla doldurulmuş numara {number:000} + + + + Format help... + Biçim yardımı... + + + + selected folder + seçili klasör + + + + library root + kütüphane kökü + + + + Move into + Şuraya taşı + + + + Reset changes + Değişiklikleri sıfırla + + + + Remove selected + Seçilileri çıkar + + + + Show unchanged + Değişmeyenleri göster + + + + New name + Yeni ad + + + + Renamed from + Önceki ad + + + New location - + Yeni konum - - Current location - + + Moved from + Önceki konum - + Remove from list - + Listeden çıkar - + Move files - + Dosyaları taşı - - Remove selected - + + Cancel + Vazgeç - - Organize files - + + Copy the list + Listeyi kopyala + + + + Undo + Geri al + + + + Close + Kapat + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + Bir dosya adı biçimi "/" içeremez. Çizgi romanları klasörlere taşımak için Dosyaları düzenle komutunu kullanın. + + + + This format cannot be used: %1 + Bu biçim kullanılamaz: %1 + + + + new folder + yeni klasör + + + + This folder does not exist yet. It will be created. + Bu klasör henüz yok. Oluşturulacak. + + + + file not found + dosya bulunamadı + + + + This comic is in the library but not on disk. It is skipped. + Bu çizgi roman kütüphanede var ama diskte yok. Atlanıyor. + + + + name in use + ad kullanımda + + + + no metadata + üstveri yok + + + + already here + zaten burada + + + + This file is already in the right place. + Bu dosya zaten doğru yerde. + + + + edited + düzenlendi + + + + %n will be renamed + + %n yeniden adlandırılacak + + + + + %n will move + + %n taşınacak + + + + + %n unchanged + + %n değişmedi + + + + + %n renamed + + %n yeniden adlandırıldı + + + + + %n removed + + %n çıkarıldı + + + + + %n missing + + %n eksik + + + + + %n new folder(s) + + %n yeni klasör + + + + + %n manual change(s) kept + + Elle yapılan %n değişiklik korundu + + + + + Nothing would be renamed with this format. + Bu biçimle hiçbir şey yeniden adlandırılmaz. + + + + Nothing would move with this format. + Bu biçimle hiçbir şey taşınmaz. + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + %n dosya yeniden adlandırılacak. Klasörler değişmez. Bunu sonradan geri alabilirsiniz. + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + %n dosya %1 konumuna taşınacak. Bu, diskteki dosyalarınızı değiştirir. Bunu sonradan geri alabilirsiniz. + + + + + Moving %1 of %2 +%3 + %2 dosyadan %1 taşınıyor +%3 + + + + Updating the library... + Kütüphane güncelleniyor... + + + + Nothing was moved. + Hiçbir şey taşınmadı. + + + + The record this run could be undone from could not be written, so the run did not start: %1 + Bu işlemin geri alınmasını sağlayacak kayıt yazılamadı, bu yüzden işlem başlamadı: %1 + + + + %n file(s) renamed. + + %n dosya yeniden adlandırıldı. + + + + + %n file(s) moved into %1. + + %n dosya %1 konumuna taşındı. + + + + + The record of this run stopped early, so the run stopped with it: %1 + Bu işlemin kaydı erken durdu, bu yüzden işlem de onunla birlikte durdu: %1 + + + + %n file(s) were not moved. + + %n dosya taşınmadı. + + + + + The library database could not be updated: %1 + Kütüphane veritabanı güncellenemedi: %1 + + + + Use Undo to move the files back, or update the library to make it match the files. + Dosyaları geri taşımak için Geri al'ı kullanın veya kütüphaneyi dosyalarla eşleşecek biçimde güncelleyin. + + + + %n empty folder(s) were removed. + + %n boş klasör kaldırıldı. + + + + + %n file(s) could not be moved. + + %n dosya taşınamadı. + + + + + Moving the files back... + Dosyalar geri taşınıyor... + + + + Moving back %1 of %2 +%3 + %2 dosyadan %1 geri taşınıyor +%3 + + + + Everything was moved back. + Her şey geri taşındı. + + + + The undo did not finish: %1 + Geri alma tamamlanmadı: %1 + + + + Format help + Biçim yardımı + + + + Fields + Alanlar + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + Her alan süslü parantez içinde yazılır ve çizgi romanın üstverisiyle değiştirilir. Ekle menüsü hepsini listeler. + + + + {series} gives %1 + {series} şunu verir: %1 + + + + Optional parts + İsteğe bağlı bölümler + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + < ve > işaretleri arasına yazılan bir bölüm, içindeki bütün alanlar boşsa tümüyle kaybolur. Bunu bir alana ait noktalama için kullanın; örneğin parantezler veya baştaki numara işareti. Bir adın başındaki ve sonundaki boşluklar bu bölüm olmadan da kırpılır. + + + + {series} ({year}) with no year gives %1 + {series} ({year}) yıl yoksa şunu verir: %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> yıl yoksa şunu verir: %1 + + + + Numbers + Numaralar + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + Sayı numarasını doldurmak için iki nokta üst üste ve birkaç sıfır yazın. Böylece sayılar dosya yöneticisinde sırada kalır. + + + + + Folders + Klasörler + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + Bir dosya adı biçimi eğik çizgi içeremez. Her çizgi roman geçerli klasöründe kalır. Çizgi romanları taşımak için Klasörlere düzenle komutunu kullanın. + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + Eğik çizgiyle ayrılan her bölüm bir klasör olur. Son bölüm dosya adı olur. Özgün uzantı her zaman korunur. diff --git a/YACReaderLibrary/yacreaderlibrary_zh_CN.ts b/YACReaderLibrary/yacreaderlibrary_zh_CN.ts index f95d46f33..235b3c209 100644 --- a/YACReaderLibrary/yacreaderlibrary_zh_CN.ts +++ b/YACReaderLibrary/yacreaderlibrary_zh_CN.ts @@ -519,9 +519,9 @@ DBHelper - + The folder entry could not be found in the library database. - + 在库数据库中找不到该文件夹的记录。 @@ -779,12 +779,12 @@ FolderManagementCoordinator - + Add new folder 添加新的文件夹 - + Folder name: 文件夹名称: @@ -1034,7 +1034,7 @@ LibraryWindow - + The selected folder doesn't contain any library. 所选文件夹不包含任何库。 @@ -1049,17 +1049,17 @@ 更新失败 - + Folder name: 文件夹名称: - + The selected folder and all its contents will be deleted from your disk. Are you sure? 所选文件夹及其所有内容将从磁盘中删除。 你确定吗? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. 尝试删除所选文件夹时出现问题。 请检查写入权限,并确保没有其他应用程序在使用这些文件夹或文件。 @@ -1074,7 +1074,7 @@ 旧的库 - + There was an error accessing the folder's path 访问文件夹的路径时出错 @@ -1104,12 +1104,12 @@ 库 '%1' 不再可用。 你想删除它吗? - + Do you want remove 你想要删除 - + Error in path 路径错误 @@ -1124,7 +1124,7 @@ 保存封面 - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1147,9 +1147,9 @@ YACReaderLibrary不会阻止您创建更多的库,但是您应该保持较低 漫画库更新时出现错误: - - - + + + Please, select a folder first 请先选择一个文件夹 @@ -1164,12 +1164,12 @@ YACReaderLibrary不会阻止您创建更多的库,但是您应该保持较低 尝试删除所选漫画时出现问题。 请检查所选文件或包含文件夹中的写入权限。 - + YACReader Library YACReader 库 - + You are adding too many libraries. 您添加的库太多了。 @@ -1179,17 +1179,17 @@ YACReaderLibrary不会阻止您创建更多的库,但是您应该保持较低 需要更新 - + Library name already exists 库名已存在 - + There is another library with the name '%1'. 已存在另一个名为'%1'的库。 - + Delete folder 删除文件夹 @@ -1204,32 +1204,42 @@ YACReaderLibrary不会阻止您创建更多的库,但是您应该保持较低 下载新版本 - + + Rename or organize files + 重命名或整理文件 + + + + Set the type of the selected comics + 设置所选漫画的类型 + + + Search filters 搜索筛选条件 - + Unread 未读 - + In progress 阅读中 - + Highly rated 高评分 - + Recently added 最近添加 - + Search syntax… 搜索语法… @@ -1254,12 +1264,12 @@ YACReaderLibrary不会阻止您创建更多的库,但是您应该保持较低 如果您确定没有其他修复正在运行,可以移除该锁定。移除锁定并继续? - + Package operation failed 打包操作失败 - + The covers package operation could not be completed. 封面包操作无法完成。 @@ -1269,48 +1279,50 @@ YACReaderLibrary不会阻止您创建更多的库,但是您应该保持较低 恢复操作修复失败 - + Rename folder 重命名文件夹 - + Invalid folder name - + 文件夹名称无效 - + The folder name is empty or contains characters that are not supported. - + 文件夹名称为空或包含不支持的字符。 - - - + + + Unable to rename folder - + 无法重命名文件夹 - + A file or folder named '%1' already exists. - + 名为“%1”的文件或文件夹已存在。 - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + 无法在磁盘上重命名该文件夹。请检查文件夹名称和写入权限。 + +文件夹:%1 - + The library database could not be updated. The folder rename on disk was reverted. - + 无法更新库数据库。磁盘上的文件夹重命名已撤销。 - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + 无法更新库数据库,磁盘上的文件夹重命名也无法撤销。现在需要手动更新该库。 @@ -1465,32 +1477,32 @@ You can restore a backup from the Library menu or recreate the library. 您可以从“资料库”菜单恢复备份,或重新创建资料库。 - + Remove and delete metadata and backups 移除并删除元数据和备份 - + Library info 图书馆信息 - + Invalid image 图片无效 - + The selected file is not a valid image. 所选文件不是有效图像。 - + Error saving cover 保存封面时出错 - + There was an error saving the cover image. 保存封面图像时出错。 @@ -1500,9 +1512,9 @@ You can restore a backup from the Library menu or recreate the library. 删除漫画 - - - + + + No folder selected 没有选中的文件夹 @@ -1517,23 +1529,23 @@ You can restore a backup from the Library menu or recreate the library. 移除漫画 - + Library not found 未找到库 - + Unable to delete 无法删除 - + library? 库? - + Are you sure? 你确定吗? @@ -1704,7 +1716,7 @@ Missing files: %3 - + Set as read 设为已读 @@ -1715,7 +1727,7 @@ Missing files: %3 - + Set as unread 设为未读 @@ -1726,7 +1738,7 @@ Missing files: %3 - + manga 日本漫画 @@ -1737,7 +1749,7 @@ Missing files: %3 - + comic 漫画 @@ -1758,7 +1770,7 @@ Missing files: %3 - + web comic 网络漫画 @@ -1769,7 +1781,7 @@ Missing files: %3 - + yonkoma 四格漫画 @@ -1827,7 +1839,7 @@ Missing files: %3 Rename the current folder on disk and in the library - + 在磁盘和库中重命名当前文件夹 @@ -1877,37 +1889,44 @@ Missing files: %3 - - Organize files - + + Rename files... + Organize files + 重命名文件... + + + + + Organize into folders... + 整理到文件夹... - + Set as uncompleted 设为未完成 - + Set as completed 设为已完成 - + Set custom cover 设置自定义封面 - + Delete custom cover 删除自定义封面 - + western manga (left to right) 欧美漫画(从左到右) - + Open containing folder... 打开包含文件夹... @@ -1916,133 +1935,133 @@ Missing files: %3 重置漫画评分 - + Select all comics 全选漫画 - + Edit 编辑 - + Assign current order to comics 将当前序号分配给漫画 - + Update cover 更新封面 - + Delete selected comics 删除所选的漫画 - + Delete metadata from selected comics 从选定的漫画中删除元数据 - + Download tags from Comic Vine 从 Comic Vine 下载标签 - + Focus search line 聚焦于搜索行 - + Focus comics view 聚焦于漫画视图 - + Edit shortcuts 编辑快捷键 - + &Quit 退出(&Q) - + Update folder 更新文件夹 - + Update current folder 更新当前文件夹 - + Scan legacy XML metadata 扫描旧版 XML 元数据 - + Add new reading list 添加新的阅读列表 - + Add a new reading list to the current library 在当前库添加新的阅读列表 - + Remove reading list 移除阅读列表 - + Remove current reading list from the library 从当前库移除阅读列表 - + Add new label 添加新标签 - + Add a new label to this library 在当前库添加标签 - + Rename selected list 重命名列表 - + Rename any selected labels or lists 重命名任何选定的标签或列表 - + Add to... 添加到... - + Favorites 收藏夹 - + Add selected comics to favorites list 将所选漫画添加到收藏夹列表 - + Reset rating 重置评分 @@ -2077,8 +2096,8 @@ Missing files: %3 - - + + Set type 设置类型 @@ -2098,53 +2117,53 @@ Missing files: %3 漫画 - + Open folder... 打开文件夹... - + Update folder 更新文件夹 - + Rename folder 重命名文件夹 - + Rescan library for XML info 重新扫描库的 XML 信息 - + Set as uncompleted 设为未完成 - + Set as completed 设为已完成 - + Set as read 设为已读 - - + + Set as unread 设为未读 - + Set custom cover 设置自定义封面 - + Delete custom cover 删除自定义封面 @@ -2476,122 +2495,531 @@ To stop an automatic update tap on the loading indicator next to the Libraries t 需要重启 + + OrganizeFiles + + + Renamed, %1 is already in use + 已重命名,%1 已被占用 + + + + Missing metadata: %1 + 缺少元数据:%1 + + + + %1 could not be created + 无法创建 %1 + + OrganizeFilesCoordinator - - - + + Organize files - + 整理文件 + + + + This folder does not contain any comics. + 此文件夹不包含任何漫画。 + + + + This library is busy: %1 + 此库正忙:%1 + + + + the library database could not be opened + 无法打开库数据库 + + + + the library database could not be locked for writing + 无法锁定库数据库以进行写入 + + + + a folder entry could not be restored + 无法恢复某个文件夹记录 + + + + a comic entry could not be updated + 无法更新某条漫画记录 - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + 无法保存库数据库:%1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + 无法读取上次整理的记录 - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + 无法创建文件夹 %1 + + + + %n file(s) could not be moved back + + 有 %n 个文件无法移回 + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + 格式: - - Available tokens: %1 - + + Organize files + 整理文件 - - {title} falls back to the series name when the comic has no title. - + + + Rename files + 重命名文件 - - Place folders relative to the library root - + + Preparing the preview... + 正在准备预览... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + 文件名格式(&F): - - Format: - 格式: + + &Path format: + 路径格式(&P): - - Organize files - + + Filename format + 文件名格式 - - Example: %1 - + + Path format + 路径格式 - - Unknown Series - + + Presets + 预设 - - Unknown Publisher - + + Insert + 插入 - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - + + + Optional part < > + 可选部分 < > + + + + Disappears completely when the fields inside it are empty. + 当其中的字段为空时,这一部分会完全消失。 - + + Padded number {number:000} + 补零编号 {number:000} + + + + Format help... + 格式帮助... + + + + selected folder + 所选文件夹 + + + + library root + 库根目录 + + + + Move into + 移动到 + + + + Reset changes + 重置更改 + + + + Remove selected + 移除所选项 + + + + Show unchanged + 显示未更改项 + + + + New name + 新名称 + + + + Renamed from + 原名称 + + + New location - + 新位置 - - Current location - + + Moved from + 原位置 - + Remove from list - + 从列表中移除 - + Move files - + 移动文件 - - Remove selected - + + Cancel + 取消 - - Organize files - + + Copy the list + 复制列表 + + + + Undo + 撤销 + + + + Close + 关闭 + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + 文件名格式不能包含 "/"。请使用“整理文件”把漫画移动到文件夹中。 + + + + This format cannot be used: %1 + 无法使用此格式:%1 + + + + new folder + 新文件夹 + + + + This folder does not exist yet. It will be created. + 此文件夹尚不存在,将会被创建。 + + + + file not found + 找不到文件 + + + + This comic is in the library but not on disk. It is skipped. + 此漫画在库中,但磁盘上没有。将跳过它。 + + + + name in use + 名称已被占用 + + + + no metadata + 无元数据 + + + + already here + 已在此处 + + + + This file is already in the right place. + 此文件已在正确的位置。 + + + + edited + 已编辑 + + + + %n will be renamed + + %n 个将被重命名 + + + + + %n will move + + %n 个将被移动 + + + + + %n unchanged + + %n 个未更改 + + + + + %n renamed + + %n 个已重命名 + + + + + %n removed + + %n 个已移除 + + + + + %n missing + + %n 个缺失 + + + + + %n new folder(s) + + %n 个新文件夹 + + + + + %n manual change(s) kept + + 已保留 %n 处手动修改 + + + + + Nothing would be renamed with this format. + 使用此格式不会重命名任何文件。 + + + + Nothing would move with this format. + 使用此格式不会移动任何文件。 + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + 将重命名 %n 个文件。文件夹不会改变。之后可以撤销。 + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + 将把 %n 个文件移动到 %1。这会改变磁盘上的文件。之后可以撤销。 + + + + + Moving %1 of %2 +%3 + 正在移动第 %1 个,共 %2 个 +%3 + + + + Updating the library... + 正在更新库... + + + + Nothing was moved. + 没有移动任何文件。 + + + + The record this run could be undone from could not be written, so the run did not start: %1 + 无法写入用于撤销本次操作的记录,因此操作没有开始:%1 + + + + %n file(s) renamed. + + 已重命名 %n 个文件。 + + + + + %n file(s) moved into %1. + + 已把 %n 个文件移动到 %1。 + + + + + The record of this run stopped early, so the run stopped with it: %1 + 本次操作的记录提前中断,因此操作也随之停止:%1 + + + + %n file(s) were not moved. + + 有 %n 个文件没有被移动。 + + + + + The library database could not be updated: %1 + 无法更新库数据库:%1 + + + + Use Undo to move the files back, or update the library to make it match the files. + 使用“撤销”把文件移回原处,或更新库使其与文件一致。 + + + + %n empty folder(s) were removed. + + 已移除 %n 个空文件夹。 + + + + + %n file(s) could not be moved. + + 有 %n 个文件无法移动。 + + + + + Moving the files back... + 正在把文件移回原处... + + + + Moving back %1 of %2 +%3 + 正在移回第 %1 个,共 %2 个 +%3 + + + + Everything was moved back. + 所有文件都已移回原处。 + + + + The undo did not finish: %1 + 撤销没有完成:%1 + + + + Format help + 格式帮助 + + + + Fields + 字段 + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + 每个字段都写在花括号中,会被替换为漫画的元数据。“插入”菜单中列出了全部字段。 + + + + {series} gives %1 + {series} 得到 %1 + + + + Optional parts + 可选部分 + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + 写在 < 和 > 之间的部分,在其中所有字段都为空时会完全消失。请把属于某个字段的标点写在里面,例如括号或前置的井号。名称开头和结尾的文字即使不用它也会被修剪。 + + + + {series} ({year}) with no year gives %1 + {series} ({year}) 没有年份时得到 %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> 没有年份时得到 %1 + + + + Numbers + 编号 + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + 写一个冒号和若干个零,即可为期号补零。这样在文件管理器中各期仍按顺序排列。 + + + + + Folders + 文件夹 + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + 文件名格式不能包含斜杠。每本漫画都保留在当前文件夹中。请使用“整理到文件夹”来移动漫画。 + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + 用斜杠分隔的每一部分都会变成一个文件夹。最后一部分是文件名。原有扩展名始终保留。 diff --git a/YACReaderLibrary/yacreaderlibrary_zh_HK.ts b/YACReaderLibrary/yacreaderlibrary_zh_HK.ts index d80ed64a2..d58f221ff 100644 --- a/YACReaderLibrary/yacreaderlibrary_zh_HK.ts +++ b/YACReaderLibrary/yacreaderlibrary_zh_HK.ts @@ -520,9 +520,9 @@ DBHelper - + The folder entry could not be found in the library database. - + 在庫資料庫中找不到該檔夾的記錄。 @@ -777,12 +777,12 @@ FolderManagementCoordinator - + Add new folder 添加新的檔夾 - + Folder name: 檔夾名稱: @@ -1032,7 +1032,7 @@ LibraryWindow - + YACReader Library YACReader 庫 @@ -1043,7 +1043,7 @@ 庫不可用 - + Delete folder 刪除檔夾 @@ -1128,41 +1128,41 @@ 移動漫畫中... - + Folder name: 檔夾名稱: - - - + + + No folder selected 沒有選中的檔夾 - - - + + + Please, select a folder first 請先選擇一個檔夾 - + Error in path 路徑錯誤 - + There was an error accessing the folder's path 訪問檔夾的路徑時出錯 - + The selected folder and all its contents will be deleted from your disk. Are you sure? 所選檔夾及其所有內容將從磁片中刪除。 你確定嗎? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. 嘗試刪除所選檔夾時出現問題。 請檢查寫入許可權,並確保沒有其他應用程式在使用這些檔夾或檔。 @@ -1173,12 +1173,12 @@ 保存封面 - + You are adding too many libraries. 您添加的庫太多了。 - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1191,27 +1191,27 @@ YACReaderLibrary will not stop you from creating more libraries but you should k YACReaderLibrary不會阻止您創建更多的庫,但是您應該保持較低的庫數量來提升性能。 - + Library not found 未找到庫 - + The selected folder doesn't contain any library. 所選檔夾不包含任何庫。 - + Are you sure? 你確定嗎? - + Do you want remove 你想要刪除 - + library? 庫? @@ -1220,7 +1220,7 @@ YACReaderLibrary不會阻止您創建更多的庫,但是您應該保持較低 移除並刪除元數據 - + Library info 圖書館資訊 @@ -1236,93 +1236,105 @@ YACReaderLibrary不會阻止您創建更多的庫,但是您應該保持較低 - + Unable to delete 無法刪除 - + + Rename or organize files + 重新命名或整理檔案 + + + + Set the type of the selected comics + 設定所選漫畫的類型 + + + Search filters 搜尋篩選器 - + Unread 未讀 - + In progress 閱讀中 - + Highly rated 高評分 - + Recently added 最近新增 - + Search syntax… 搜尋語法… - + Package operation failed - + 封裝作業失敗 - + The covers package operation could not be completed. - + 無法完成封面套件作業。 - + Rename folder 重新命名檔夾 - + Invalid folder name - + 檔夾名稱無效 - + The folder name is empty or contains characters that are not supported. - + 檔夾名稱為空或包含不支援的字元。 - - - + + + Unable to rename folder - + 無法重新命名檔夾 - + A file or folder named '%1' already exists. - + 名為「%1」的檔案或檔夾已存在。 - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + 無法在磁碟上重新命名該檔夾。請檢查檔夾名稱與寫入權限。 + +檔夾:%1 - + The library database could not be updated. The folder rename on disk was reverted. - + 無法更新庫資料庫。磁碟上的檔夾重新命名已復原。 - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + 無法更新庫資料庫,磁碟上的檔夾重新命名也無法復原。現在需要手動更新該庫。 @@ -1477,7 +1489,7 @@ You can restore a backup from the Library menu or recreate the library. 您可以從「漫畫庫」選單還原備份,或重新建立漫畫庫。 - + Remove and delete metadata and backups 移除並刪除中繼資料及備份 @@ -1487,22 +1499,22 @@ You can restore a backup from the Library menu or recreate the library. 嘗試刪除所選漫畫時出現問題。 請檢查所選檔或包含檔夾中的寫入許可權。 - + Invalid image 圖片無效 - + The selected file is not a valid image. 所選檔案不是有效影像。 - + Error saving cover 儲存封面時發生錯誤 - + There was an error saving the cover image. 儲存封面圖片時發生錯誤。 @@ -1527,12 +1539,12 @@ You can restore a backup from the Library menu or recreate the library. 漫畫只會從當前標籤/列表中刪除。 你確定嗎? - + Library name already exists 庫名已存在 - + There is another library with the name '%1'. 已存在另一個名為'%1'的庫。 @@ -1703,7 +1715,7 @@ Missing files: %3 - + Set as read 設為已讀 @@ -1714,7 +1726,7 @@ Missing files: %3 - + Set as unread 設為未讀 @@ -1725,7 +1737,7 @@ Missing files: %3 - + manga 漫畫 @@ -1736,7 +1748,7 @@ Missing files: %3 - + comic 漫畫 @@ -1757,7 +1769,7 @@ Missing files: %3 - + web comic 網路漫畫 @@ -1768,7 +1780,7 @@ Missing files: %3 - + yonkoma 四科馬 @@ -1826,7 +1838,7 @@ Missing files: %3 Rename the current folder on disk and in the library - + 在磁碟與庫中重新命名目前檔夾 @@ -1876,37 +1888,44 @@ Missing files: %3 - - Organize files - + + Rename files... + Organize files + 重新命名檔案... + + + + + Organize into folders... + 整理到檔夾... - + Set as uncompleted 設為未完成 - + Set as completed 設為已完成 - + Set custom cover 設定自訂封面 - + Delete custom cover 刪除自訂封面 - + western manga (left to right) 西方漫畫(從左到右) - + Open containing folder... 打開包含檔夾... @@ -1915,133 +1934,133 @@ Missing files: %3 重置漫畫評分 - + Select all comics 全選漫畫 - + Edit 編輯 - + Assign current order to comics 將當前序號分配給漫畫 - + Update cover 更新封面 - + Delete selected comics 刪除所選的漫畫 - + Delete metadata from selected comics 從選定的漫畫中刪除元數據 - + Download tags from Comic Vine 從 Comic Vine 下載標籤 - + Focus search line 聚焦於搜索行 - + Focus comics view 聚焦於漫畫視圖 - + Edit shortcuts 編輯快捷鍵 - + &Quit 退出(&Q) - + Update folder 更新檔夾 - + Update current folder 更新當前檔夾 - + Scan legacy XML metadata 掃描舊版 XML 元數據 - + Add new reading list 添加新的閱讀列表 - + Add a new reading list to the current library 在當前庫添加新的閱讀列表 - + Remove reading list 移除閱讀列表 - + Remove current reading list from the library 從當前庫移除閱讀列表 - + Add new label 添加新標籤 - + Add a new label to this library 在當前庫添加標籤 - + Rename selected list 重命名列表 - + Rename any selected labels or lists 重命名任何選定的標籤或列表 - + Add to... 添加到... - + Favorites 收藏夾 - + Add selected comics to favorites list 將所選漫畫添加到收藏夾列表 - + Reset rating 重置評分 @@ -2076,8 +2095,8 @@ Missing files: %3 - - + + Set type 套裝類型 @@ -2097,53 +2116,53 @@ Missing files: %3 漫畫 - + Open folder... 打開檔夾... - + Update folder 更新檔夾 - + Rename folder 重新命名檔夾 - + Rescan library for XML info 重新掃描庫的 XML 資訊 - + Set as uncompleted 設為未完成 - + Set as completed 設為已完成 - + Set as read 設為已讀 - - + + Set as unread 設為未讀 - + Set custom cover 設定自訂封面 - + Delete custom cover 刪除自訂封面 @@ -2479,122 +2498,531 @@ To stop an automatic update tap on the loading indicator next to the Libraries t 需要重啟 + + OrganizeFiles + + + Renamed, %1 is already in use + 已重新命名,%1 已被使用 + + + + Missing metadata: %1 + 缺少中繼資料:%1 + + + + %1 could not be created + 無法建立 %1 + + OrganizeFilesCoordinator - - - + + Organize files - + 整理檔案 + + + + This folder does not contain any comics. + 此檔夾不包含任何漫畫。 + + + + This library is busy: %1 + 此庫忙碌中:%1 + + + + the library database could not be opened + 無法開啟庫資料庫 + + + + the library database could not be locked for writing + 無法鎖定庫資料庫以進行寫入 + + + + a folder entry could not be restored + 無法還原某個檔夾記錄 + + + + a comic entry could not be updated + 無法更新某筆漫畫記錄 - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + 無法儲存庫資料庫:%1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + 無法讀取上次整理的記錄 - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + 無法建立檔夾 %1 + + + + %n file(s) could not be moved back + + 有 %n 個檔案無法移回 + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + 格式: - - Available tokens: %1 - + + Organize files + 整理檔案 - - {title} falls back to the series name when the comic has no title. - + + + Rename files + 重新命名檔案 - - Place folders relative to the library root - + + Preparing the preview... + 正在準備預覽... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + 檔名格式(&F): - - Format: - 格式: + + &Path format: + 路徑格式(&P): - - Organize files - + + Filename format + 檔名格式 - - Example: %1 - + + Path format + 路徑格式 - - Unknown Series - + + Presets + 預設組合 - - Unknown Publisher - + + Insert + 插入 - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - + + + Optional part < > + 選用部分 < > + + + + Disappears completely when the fields inside it are empty. + 當其中的欄位為空時,這一部分會完全消失。 - + + Padded number {number:000} + 補零編號 {number:000} + + + + Format help... + 格式說明... + + + + selected folder + 所選檔夾 + + + + library root + 庫根目錄 + + + + Move into + 移動到 + + + + Reset changes + 重設變更 + + + + Remove selected + 移除所選項目 + + + + Show unchanged + 顯示未變更項目 + + + + New name + 新名稱 + + + + Renamed from + 原名稱 + + + New location - + 新位置 - - Current location - + + Moved from + 原位置 - + Remove from list - + 從清單中移除 - + Move files - + 移動檔案 - - Remove selected - + + Cancel + 取消 - - Organize files - + + Copy the list + 複製清單 + + + + Undo + 復原 + + + + Close + 關閉 + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + 檔名格式不能包含 "/"。請使用「整理檔案」把漫畫移動到檔夾中。 + + + + This format cannot be used: %1 + 無法使用此格式:%1 + + + + new folder + 新檔夾 + + + + This folder does not exist yet. It will be created. + 此檔夾尚不存在,將會被建立。 + + + + file not found + 找不到檔案 + + + + This comic is in the library but not on disk. It is skipped. + 此漫畫在庫中,但磁碟上沒有。將略過它。 + + + + name in use + 名稱已被使用 + + + + no metadata + 無中繼資料 + + + + already here + 已在此處 + + + + This file is already in the right place. + 此檔案已在正確的位置。 + + + + edited + 已編輯 + + + + %n will be renamed + + %n 個將被重新命名 + + + + + %n will move + + %n 個將被移動 + + + + + %n unchanged + + %n 個未變更 + + + + + %n renamed + + %n 個已重新命名 + + + + + %n removed + + %n 個已移除 + + + + + %n missing + + %n 個遺失 + + + + + %n new folder(s) + + %n 個新檔夾 + + + + + %n manual change(s) kept + + 已保留 %n 處手動修改 + + + + + Nothing would be renamed with this format. + 使用此格式不會重新命名任何檔案。 + + + + Nothing would move with this format. + 使用此格式不會移動任何檔案。 + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + 將重新命名 %n 個檔案。檔夾不會改變。之後可以復原。 + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + 將把 %n 個檔案移動到 %1。這會改變磁碟上的檔案。之後可以復原。 + + + + + Moving %1 of %2 +%3 + 正在移動第 %1 個,共 %2 個 +%3 + + + + Updating the library... + 正在更新庫... + + + + Nothing was moved. + 沒有移動任何檔案。 + + + + The record this run could be undone from could not be written, so the run did not start: %1 + 無法寫入用於復原本次作業的記錄,因此作業沒有開始:%1 + + + + %n file(s) renamed. + + 已重新命名 %n 個檔案。 + + + + + %n file(s) moved into %1. + + 已把 %n 個檔案移動到 %1。 + + + + + The record of this run stopped early, so the run stopped with it: %1 + 本次作業的記錄提前中斷,因此作業也隨之停止:%1 + + + + %n file(s) were not moved. + + 有 %n 個檔案沒有被移動。 + + + + + The library database could not be updated: %1 + 無法更新庫資料庫:%1 + + + + Use Undo to move the files back, or update the library to make it match the files. + 使用「復原」把檔案移回原處,或更新庫使其與檔案一致。 + + + + %n empty folder(s) were removed. + + 已移除 %n 個空檔夾。 + + + + + %n file(s) could not be moved. + + 有 %n 個檔案無法移動。 + + + + + Moving the files back... + 正在把檔案移回原處... + + + + Moving back %1 of %2 +%3 + 正在移回第 %1 個,共 %2 個 +%3 + + + + Everything was moved back. + 所有檔案都已移回原處。 + + + + The undo did not finish: %1 + 復原沒有完成:%1 + + + + Format help + 格式說明 + + + + Fields + 欄位 + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + 每個欄位都寫在大括號中,會被取代為漫畫的中繼資料。「插入」選單中列出了全部欄位。 + + + + {series} gives %1 + {series} 得到 %1 + + + + Optional parts + 選用部分 + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + 寫在 < 和 > 之間的部分,在其中所有欄位都為空時會完全消失。請把屬於某個欄位的標點寫在裡面,例如括號或前置的井號。名稱開頭和結尾的文字即使不用它也會被修剪。 + + + + {series} ({year}) with no year gives %1 + {series} ({year}) 沒有年份時得到 %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> 沒有年份時得到 %1 + + + + Numbers + 編號 + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + 寫一個冒號和數個零,即可為期號補零。這樣在檔案管理員中各期仍按順序排列。 + + + + + Folders + 檔夾 + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + 檔名格式不能包含斜線。每本漫畫都保留在目前檔夾中。請使用「整理到檔夾」來移動漫畫。 + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + 用斜線分隔的每一部分都會變成一個檔夾。最後一部分是檔名。原有副檔名一律保留。 diff --git a/YACReaderLibrary/yacreaderlibrary_zh_TW.ts b/YACReaderLibrary/yacreaderlibrary_zh_TW.ts index b9900de6a..2cc9be910 100644 --- a/YACReaderLibrary/yacreaderlibrary_zh_TW.ts +++ b/YACReaderLibrary/yacreaderlibrary_zh_TW.ts @@ -520,9 +520,9 @@ DBHelper - + The folder entry could not be found in the library database. - + 在庫資料庫中找不到該檔夾的記錄。 @@ -777,12 +777,12 @@ FolderManagementCoordinator - + Add new folder 添加新的檔夾 - + Folder name: 檔夾名稱: @@ -1032,7 +1032,7 @@ LibraryWindow - + YACReader Library YACReader 庫 @@ -1043,7 +1043,7 @@ 庫不可用 - + Delete folder 刪除檔夾 @@ -1128,41 +1128,41 @@ 移動漫畫中... - + Folder name: 檔夾名稱: - - - + + + No folder selected 沒有選中的檔夾 - - - + + + Please, select a folder first 請先選擇一個檔夾 - + Error in path 路徑錯誤 - + There was an error accessing the folder's path 訪問檔夾的路徑時出錯 - + The selected folder and all its contents will be deleted from your disk. Are you sure? 所選檔夾及其所有內容將從磁片中刪除。 你確定嗎? - + There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that no applications are using these folders or any of the contained files. There was an issue trying to delete the selected folders. Please, check for write permissions and be sure that any applications are using these folders or any of the contained files. 嘗試刪除所選檔夾時出現問題。 請檢查寫入許可權,並確保沒有其他應用程式在使用這些檔夾或檔。 @@ -1173,12 +1173,12 @@ 保存封面 - + You are adding too many libraries. 您添加的庫太多了。 - + You are adding too many libraries. You probably only need one library in your top level comics folder, you can browse any subfolders using the folders section in the left sidebar. @@ -1191,27 +1191,27 @@ YACReaderLibrary will not stop you from creating more libraries but you should k YACReaderLibrary不會阻止您創建更多的庫,但是您應該保持較低的庫數量來提升性能。 - + Library not found 未找到庫 - + The selected folder doesn't contain any library. 所選檔夾不包含任何庫。 - + Are you sure? 你確定嗎? - + Do you want remove 你想要刪除 - + library? 庫? @@ -1220,7 +1220,7 @@ YACReaderLibrary不會阻止您創建更多的庫,但是您應該保持較低 移除並刪除元數據 - + Library info 圖書館資訊 @@ -1236,93 +1236,105 @@ YACReaderLibrary不會阻止您創建更多的庫,但是您應該保持較低 - + Unable to delete 無法刪除 - + + Rename or organize files + 重新命名或整理檔案 + + + + Set the type of the selected comics + 設定所選漫畫的類型 + + + Search filters 搜尋篩選條件 - + Unread 未讀 - + In progress 閱讀中 - + Highly rated 高評分 - + Recently added 最近加入 - + Search syntax… 搜尋語法… - + Package operation failed - + 封裝作業失敗 - + The covers package operation could not be completed. - + 無法完成封面套件作業。 - + Rename folder 重新命名檔夾 - + Invalid folder name - + 檔夾名稱無效 - + The folder name is empty or contains characters that are not supported. - + 檔夾名稱為空或包含不支援的字元。 - - - + + + Unable to rename folder - + 無法重新命名檔夾 - + A file or folder named '%1' already exists. - + 名為「%1」的檔案或檔夾已存在。 - + The folder could not be renamed on disk. Please check the folder name and write permissions. Folder: %1 - + 無法在磁碟上重新命名該檔夾。請檢查檔夾名稱與寫入權限。 + +檔夾:%1 - + The library database could not be updated. The folder rename on disk was reverted. - + 無法更新庫資料庫。磁碟上的檔夾重新命名已復原。 - + The library database could not be updated, and the folder rename on disk could not be reverted. The library now needs to be updated manually. - + 無法更新庫資料庫,磁碟上的檔夾重新命名也無法復原。現在需要手動更新該庫。 @@ -1477,7 +1489,7 @@ You can restore a backup from the Library menu or recreate the library. 您可以從「漫畫庫」選單還原備份,或重新建立漫畫庫。 - + Remove and delete metadata and backups 移除並刪除中繼資料與備份 @@ -1487,22 +1499,22 @@ You can restore a backup from the Library menu or recreate the library. 嘗試刪除所選漫畫時出現問題。 請檢查所選檔或包含檔夾中的寫入許可權。 - + Invalid image 圖片無效 - + The selected file is not a valid image. 所選檔案不是有效影像。 - + Error saving cover 儲存封面時發生錯誤 - + There was an error saving the cover image. 儲存封面圖片時發生錯誤。 @@ -1527,12 +1539,12 @@ You can restore a backup from the Library menu or recreate the library. 漫畫只會從當前標籤/列表中刪除。 你確定嗎? - + Library name already exists 庫名已存在 - + There is another library with the name '%1'. 已存在另一個名為'%1'的庫。 @@ -1703,7 +1715,7 @@ Missing files: %3 - + Set as read 設為已讀 @@ -1714,7 +1726,7 @@ Missing files: %3 - + Set as unread 設為未讀 @@ -1725,7 +1737,7 @@ Missing files: %3 - + manga 漫畫 @@ -1736,7 +1748,7 @@ Missing files: %3 - + comic 漫畫 @@ -1757,7 +1769,7 @@ Missing files: %3 - + web comic 網路漫畫 @@ -1768,7 +1780,7 @@ Missing files: %3 - + yonkoma 四科馬 @@ -1826,7 +1838,7 @@ Missing files: %3 Rename the current folder on disk and in the library - + 在磁碟與庫中重新命名目前檔夾 @@ -1876,37 +1888,44 @@ Missing files: %3 - - Organize files - + + Rename files... + Organize files + 重新命名檔案... + + + + + Organize into folders... + 整理到檔夾... - + Set as uncompleted 設為未完成 - + Set as completed 設為已完成 - + Set custom cover 設定自訂封面 - + Delete custom cover 刪除自訂封面 - + western manga (left to right) 西方漫畫(從左到右) - + Open containing folder... 打開包含檔夾... @@ -1915,133 +1934,133 @@ Missing files: %3 重置漫畫評分 - + Select all comics 全選漫畫 - + Edit 編輯 - + Assign current order to comics 將當前序號分配給漫畫 - + Update cover 更新封面 - + Delete selected comics 刪除所選的漫畫 - + Delete metadata from selected comics 從選定的漫畫中刪除元數據 - + Download tags from Comic Vine 從 Comic Vine 下載標籤 - + Focus search line 聚焦於搜索行 - + Focus comics view 聚焦於漫畫視圖 - + Edit shortcuts 編輯快捷鍵 - + &Quit 退出(&Q) - + Update folder 更新檔夾 - + Update current folder 更新當前檔夾 - + Scan legacy XML metadata 掃描舊版 XML 元數據 - + Add new reading list 添加新的閱讀列表 - + Add a new reading list to the current library 在當前庫添加新的閱讀列表 - + Remove reading list 移除閱讀列表 - + Remove current reading list from the library 從當前庫移除閱讀列表 - + Add new label 添加新標籤 - + Add a new label to this library 在當前庫添加標籤 - + Rename selected list 重命名列表 - + Rename any selected labels or lists 重命名任何選定的標籤或列表 - + Add to... 添加到... - + Favorites 收藏夾 - + Add selected comics to favorites list 將所選漫畫添加到收藏夾列表 - + Reset rating 重置評分 @@ -2076,8 +2095,8 @@ Missing files: %3 - - + + Set type 套裝類型 @@ -2097,53 +2116,53 @@ Missing files: %3 漫畫 - + Open folder... 打開檔夾... - + Update folder 更新檔夾 - + Rename folder 重新命名檔夾 - + Rescan library for XML info 重新掃描庫的 XML 資訊 - + Set as uncompleted 設為未完成 - + Set as completed 設為已完成 - + Set as read 設為已讀 - - + + Set as unread 設為未讀 - + Set custom cover 設定自訂封面 - + Delete custom cover 刪除自訂封面 @@ -2479,122 +2498,531 @@ To stop an automatic update tap on the loading indicator next to the Libraries t 需要重啟 + + OrganizeFiles + + + Renamed, %1 is already in use + 已重新命名,%1 已被使用 + + + + Missing metadata: %1 + 缺少中繼資料:%1 + + + + %1 could not be created + 無法建立 %1 + + OrganizeFilesCoordinator - - - + + Organize files - + 整理檔案 + + + + This folder does not contain any comics. + 此檔夾不包含任何漫畫。 + + + + This library is busy: %1 + 此庫忙碌中:%1 + + + + the library database could not be opened + 無法開啟庫資料庫 + + + + the library database could not be locked for writing + 無法鎖定庫資料庫以進行寫入 + + + + a folder entry could not be restored + 無法還原某個檔夾記錄 + + + + a comic entry could not be updated + 無法更新某筆漫畫記錄 - - This folder does not contain any comics to organize. - + + the library database could not be saved: %1 + 無法儲存庫資料庫:%1 - - All files are already organized according to this format. - + + the record of the last organize run could not be read + 無法讀取上次整理的記錄 - - %1 of %2 file(s) were moved. %3 file(s) could not be moved. - + + the folder %1 could not be created + 無法建立檔夾 %1 + + + + %n file(s) could not be moved back + + 有 %n 個檔案無法移回 + OrganizeFilesDialog - - Files will be moved into subfolders following the format below. Each part separated by "/" becomes a folder, except the last one which becomes the file name. - + Format: + 格式: - - Available tokens: %1 - + + Organize files + 整理檔案 - - {title} falls back to the series name when the comic has no title. - + + + Rename files + 重新命名檔案 - - Place folders relative to the library root - + + Preparing the preview... + 正在準備預覽... - - When enabled, the format is applied from the library root instead of the selected folder, so it is not nested inside the folder being organized. - + + &Filename format: + 檔名格式(&F): - - Format: - 格式: + + &Path format: + 路徑格式(&P): - - Organize files - + + Filename format + 檔名格式 - - Example: %1 - + + Path format + 路徑格式 - - Unknown Series - + + Presets + 預設組合 - - Unknown Publisher - + + Insert + 插入 - - - OrganizeFilesPreviewDialog - - - %n file(s) will be moved as shown below. Double-click an item in the "New location" column to rename a folder or file, or remove items to leave them where they are, before applying the changes. - - - + + + Optional part < > + 選用部分 < > + + + + Disappears completely when the fields inside it are empty. + 當其中的欄位為空時,這一部分會完全消失。 - + + Padded number {number:000} + 補零編號 {number:000} + + + + Format help... + 格式說明... + + + + selected folder + 所選檔夾 + + + + library root + 庫根目錄 + + + + Move into + 移動到 + + + + Reset changes + 重設變更 + + + + Remove selected + 移除所選項目 + + + + Show unchanged + 顯示未變更項目 + + + + New name + 新名稱 + + + + Renamed from + 原名稱 + + + New location - + 新位置 - - Current location - + + Moved from + 原位置 - + Remove from list - + 從清單中移除 - + Move files - + 移動檔案 - - Remove selected - + + Cancel + 取消 - - Organize files - + + Copy the list + 複製清單 + + + + Undo + 復原 + + + + Close + 關閉 + + + + A filename format cannot contain "/". Use Organize files to move comics into folders. + 檔名格式不能包含 "/"。請使用「整理檔案」把漫畫移動到檔夾中。 + + + + This format cannot be used: %1 + 無法使用此格式:%1 + + + + new folder + 新檔夾 + + + + This folder does not exist yet. It will be created. + 此檔夾尚不存在,將會被建立。 + + + + file not found + 找不到檔案 + + + + This comic is in the library but not on disk. It is skipped. + 此漫畫在庫中,但磁碟上沒有。將略過它。 + + + + name in use + 名稱已被使用 + + + + no metadata + 無中繼資料 + + + + already here + 已在此處 + + + + This file is already in the right place. + 此檔案已在正確的位置。 + + + + edited + 已編輯 + + + + %n will be renamed + + %n 個將被重新命名 + + + + + %n will move + + %n 個將被移動 + + + + + %n unchanged + + %n 個未變更 + + + + + %n renamed + + %n 個已重新命名 + + + + + %n removed + + %n 個已移除 + + + + + %n missing + + %n 個遺失 + + + + + %n new folder(s) + + %n 個新檔夾 + + + + + %n manual change(s) kept + + 已保留 %n 處手動修改 + + + + + Nothing would be renamed with this format. + 使用此格式不會重新命名任何檔案。 + + + + Nothing would move with this format. + 使用此格式不會移動任何檔案。 + + + + %n file(s) will be renamed. The folders do not change. You can undo it afterwards. + + 將重新命名 %n 個檔案。檔夾不會改變。之後可以復原。 + + + + + %n file(s) will move into %1. This changes your files on disk. You can undo it afterwards. + + 將把 %n 個檔案移動到 %1。這會改變磁碟上的檔案。之後可以復原。 + + + + + Moving %1 of %2 +%3 + 正在移動第 %1 個,共 %2 個 +%3 + + + + Updating the library... + 正在更新庫... + + + + Nothing was moved. + 沒有移動任何檔案。 + + + + The record this run could be undone from could not be written, so the run did not start: %1 + 無法寫入用於復原本次作業的記錄,因此作業沒有開始:%1 + + + + %n file(s) renamed. + + 已重新命名 %n 個檔案。 + + + + + %n file(s) moved into %1. + + 已把 %n 個檔案移動到 %1。 + + + + + The record of this run stopped early, so the run stopped with it: %1 + 本次作業的記錄提前中斷,因此作業也隨之停止:%1 + + + + %n file(s) were not moved. + + 有 %n 個檔案沒有被移動。 + + + + + The library database could not be updated: %1 + 無法更新庫資料庫:%1 + + + + Use Undo to move the files back, or update the library to make it match the files. + 使用「復原」把檔案移回原處,或更新庫使其與檔案一致。 + + + + %n empty folder(s) were removed. + + 已移除 %n 個空檔夾。 + + + + + %n file(s) could not be moved. + + 有 %n 個檔案無法移動。 + + + + + Moving the files back... + 正在把檔案移回原處... + + + + Moving back %1 of %2 +%3 + 正在移回第 %1 個,共 %2 個 +%3 + + + + Everything was moved back. + 所有檔案都已移回原處。 + + + + The undo did not finish: %1 + 復原沒有完成:%1 + + + + Format help + 格式說明 + + + + Fields + 欄位 + + + + Every field is written between braces and is replaced by the metadata of the comic. The Insert menu lists all of them. + 每個欄位都寫在大括號中,會被取代為漫畫的中繼資料。「插入」選單中列出了全部欄位。 + + + + {series} gives %1 + {series} 得到 %1 + + + + Optional parts + 選用部分 + + + + A part written between the signs < and > disappears completely when every field inside it is empty. Use it for punctuation that belongs to a field, such as brackets or a leading number sign. Text at the start or the end of a name is trimmed without it. + 寫在 < 和 > 之間的部分,在其中所有欄位都為空時會完全消失。請把屬於某個欄位的標點寫在裡面,例如括號或前置的井號。名稱開頭和結尾的文字即使不用它也會被修剪。 + + + + {series} ({year}) with no year gives %1 + {series} ({year}) 沒有年份時得到 %1 + + + + {series}< ({year})> with no year gives %1 + {series}< ({year})> 沒有年份時得到 %1 + + + + Numbers + 編號 + + + + Write a colon and some zeros to pad the issue number. This keeps the issues in order in a file browser. + 寫一個冒號和數個零,即可為期號補零。這樣在檔案管理員中各期仍按順序排列。 + + + + + Folders + 檔夾 + + + + A filename format cannot contain a slash. Every comic keeps its current folder. Use Organize into folders to move comics. + 檔名格式不能包含斜線。每本漫畫都保留在目前檔夾中。請使用「整理到檔夾」來移動漫畫。 + + + + Each part separated by a slash becomes a folder. The last part becomes the file name. The original extension is always kept. + 用斜線分隔的每一部分都會變成一個檔夾。最後一部分是檔名。原有副檔名一律保留。 diff --git a/common/yacreader_global.h b/common/yacreader_global.h index 184ce59f1..18415ede7 100644 --- a/common/yacreader_global.h +++ b/common/yacreader_global.h @@ -20,6 +20,9 @@ class QLibrary; #define IMPORT_COMIC_INFO_XML_METADATA "IMPORT_COMIC_INFO_XML_METADATA" #define ORGANIZE_FILES_RELATIVE_TO_ROOT "ORGANIZE_FILES_RELATIVE_TO_ROOT" +#define ORGANIZE_FILES_FILENAME_PATTERN "ORGANIZE_FILES_FILENAME_PATTERN" +#define ORGANIZE_FILES_PATH_PATTERN "ORGANIZE_FILES_PATH_PATTERN" +#define ORGANIZE_FILES_SHOW_UNCHANGED "ORGANIZE_FILES_SHOW_UNCHANGED" #define COMPARE_MODIFIED_DATE_ON_LIBRARY_UPDATES "COMPARE_MODIFIED_DATE_ON_LIBRARY_UPDATES" #define UPDATE_LIBRARIES_AT_STARTUP "UPDATE_LIBRARIES_AT_STARTUP" #define DETECT_CHANGES_IN_LIBRARIES_AUTOMATICALLY "DETECT_CHANGES_IN_LIBRARIES_AUTOMATICALLY" diff --git a/images/comics_view_toolbar/organize.svg b/images/comics_view_toolbar/organize.svg new file mode 100644 index 000000000..23d541e4c --- /dev/null +++ b/images/comics_view_toolbar/organize.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/shortcuts_management/shortcuts_manager.h b/shortcuts_management/shortcuts_manager.h index 865f2a08d..03ec8f163 100644 --- a/shortcuts_management/shortcuts_manager.h +++ b/shortcuts_management/shortcuts_manager.h @@ -55,7 +55,7 @@ class ShortcutsManager #define SET_AS_READ_ACTION_YL "SET_AS_READ_ACTION_YL" #define SET_AS_NON_READ_ACTION_YL "SET_AS_NON_READ_ACTION_YL" #define SET_AS_MANGA_ACTION_YL "SET_AS_MANGA_ACTION_YL" -#define SET_AS_NORMAL_ACTION_YL "SET_AS_MANGA_ACTION_YL" +#define SET_AS_NORMAL_ACTION_YL "SET_AS_NORMAL_ACTION_YL" #define SET_AS_WESTERN_MANGA_ACTION_YL "SET_AS_WESTERN_MANGA_ACTION_YL" #define SET_AS_WEB_COMIC_ACTION_YL "SET_AS_WEB_COMIC_ACTION_YL" #define SET_AS_YONKOMA_ACTION_YL "SET_AS_YONKOMA_ACTION_YL" @@ -69,6 +69,8 @@ class ShortcutsManager #define SERVER_CONFIG_ACTION_YL "SERVER_CONFIG_ACTION_YL" #define TOGGLE_COMICS_VIEW_ACTION_YL "TOGGLE_COMICS_VIEW_ACTION_YL" #define OPEN_CONTAINING_FOLDER_ACTION_YL "OPEN_CONTAINING_FOLDER_ACTION_YL" +#define RENAME_FILES_ACTION_YL "RENAME_FILES_ACTION_YL" +#define ORGANIZE_FILES_ACTION_YL "ORGANIZE_FILES_ACTION_YL" #define SET_FOLDER_AS_NOT_COMPLETED_ACTION_YL "SET_FOLDER_AS_NOT_COMPLETED_ACTION_YL" #define SET_FOLDER_AS_COMPLETED_ACTION_YL "SET_FOLDER_AS_COMPLETED_ACTION_YL" #define SET_FOLDER_AS_READ_ACTION_YL "SET_FOLDER_AS_READ_ACTION_YL" @@ -81,6 +83,8 @@ class ShortcutsManager #define SET_FOLDER_COVER_ACTION_YL "SET_FOLDER_COVER_ACTION_YL" #define DELETE_CUSTOM_FOLDER_COVER_ACTION_YL "DELETE_CUSTOM_FOLDER_COVER_ACTION_YL" #define OPEN_CONTAINING_FOLDER_COMIC_ACTION_YL "OPEN_CONTAINING_FOLDER_COMIC_ACTION_YL" +#define RENAME_COMICS_FILES_ACTION_YL "RENAME_COMICS_FILES_ACTION_YL" +#define ORGANIZE_COMICS_FILES_ACTION_YL "ORGANIZE_COMICS_FILES_ACTION_YL" #define RESET_COMIC_RATING_ACTION_YL "RESET_COMIC_RATING_ACTION_YL" #define SELECT_ALL_COMICS_ACTION_YL "SELECT_ALL_COMICS_ACTION_YL" #define EDIT_SELECTED_COMICS_ACTION_YL "EDIT_SELECTED_COMICS_ACTION_YL" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 857c79c9b..39da29cb2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,4 +7,5 @@ add_subdirectory(pdf_render_size_test) add_subdirectory(folder_rename_test) add_subdirectory(epub_page_index_test) add_subdirectory(comic_files_manager_test) +add_subdirectory(organize_files_test) add_subdirectory(yacreader_libraries_test) diff --git a/tests/organize_files_test/CMakeLists.txt b/tests/organize_files_test/CMakeLists.txt new file mode 100644 index 000000000..e0b4f55b1 --- /dev/null +++ b/tests/organize_files_test/CMakeLists.txt @@ -0,0 +1,20 @@ +qt_add_executable(organize_files_test + main.cpp + ${CMAKE_SOURCE_DIR}/YACReaderLibrary/organize_files/organize_files_plan.cpp + ${CMAKE_SOURCE_DIR}/YACReaderLibrary/organize_files/organize_files_journal.cpp + ${CMAKE_SOURCE_DIR}/YACReaderLibrary/organize_files/organize_files_worker.cpp +) +yacreader_apply_build_options(organize_files_test) +target_include_directories(organize_files_test PRIVATE + ${CMAKE_SOURCE_DIR}/YACReaderLibrary + ${CMAKE_SOURCE_DIR}/YACReaderLibrary/organize_files +) +target_link_libraries(organize_files_test PRIVATE + Qt6::Core + Qt6::Sql + Qt6::Test + db_helper + library_common +) + +add_test(NAME organize_files_test COMMAND organize_files_test) diff --git a/tests/organize_files_test/main.cpp b/tests/organize_files_test/main.cpp new file mode 100644 index 000000000..5f1b72083 --- /dev/null +++ b/tests/organize_files_test/main.cpp @@ -0,0 +1,923 @@ +#include "db_helper.h" +#include "organize_files_journal.h" +#include "organize_files_plan.h" +#include "organize_files_worker.h" + +#include +#include +#include +#include +#include + +#include + +using namespace OrganizeFiles; + +class OrganizeFilesTest : public QObject +{ + Q_OBJECT + +private slots: + void substitutesEveryToken(); + void keepsPunctuationOutOfEmptyOptionalGroups(); + void padsOnlyTheLeadingDigits(); + void reportsInvalidTokens(); + void sanitizesSegments(); + void resolvesCollisionsAgainstDiskAndPlan(); + void freesTheNameOfAFileThatMovesAway(); + void keepsOverridesAndExclusions(); + void renameModeKeepsEveryFileInItsFolder(); + void rejectsSeparatorsInAFilenamePattern(); + void claimsThePathOfAnExcludedComic(); + void renamesWhenOnlyTheCaseChanges(); + void adoptsTheOnDiskCasingOfExistingFolders(); + void mergesPlannedFolderCasingsIntoOne(); + void ordersMovesSoNothingIsOverwritten(); + void removesOnlyEmptyDirectoriesInsideTheBase(); + void sweepsCreatedDirectoriesWhenAMoveFails(); + void undoRemovesOnlyTheDirectoriesTheRunCreated(); + void movesComicRowWithoutLosingCuration(); + void keepsAFolderThatStillHoldsAComic(); + void createsFolderRowsInheritingTheType(); + void removesOnlyEmptyCreatedFolderRows(); + void journalRoundTrip(); + void journalCarriesTheFolderRowsItRemoved(); + void undoPutsTheDatabaseBackExactlyAsItWas(); +}; + +namespace { + +ComicEntry spiderMan() +{ + ComicEntry entry; + entry.comicId = 1; + entry.sourceAbsolute = QStringLiteral("/library/Unsorted/asm42.cbz"); + entry.baseName = QStringLiteral("asm42"); + entry.extension = QStringLiteral(".cbz"); + entry.publisher = QStringLiteral("Marvel"); + entry.imprint = QStringLiteral("Epic"); + entry.series = QStringLiteral("The Amazing Spider-Man"); + entry.volume = QStringLiteral("1"); + entry.number = QStringLiteral("42"); + entry.count = QStringLiteral("100"); + entry.title = QStringLiteral("The Sinister Six"); + entry.year = QStringLiteral("2018"); + entry.month = QStringLiteral("7"); + entry.storyArc = QStringLiteral("Sinister War"); + entry.arcNumber = QStringLiteral("2"); + entry.writer = QStringLiteral("Dan Slott"); + return entry; +} + +ComicEntry bareScan() +{ + ComicEntry entry; + entry.comicId = 2; + entry.sourceAbsolute = QStringLiteral("/library/Unsorted/scan001.cbz"); + entry.baseName = QStringLiteral("scan001"); + entry.extension = QStringLiteral(".cbz"); + return entry; +} + +QSqlDatabase createDatabase(const QString &connectionName) +{ + auto db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); + db.setDatabaseName(QStringLiteral(":memory:")); + db.open(); + + QSqlQuery query(db); + query.exec("PRAGMA foreign_keys = ON"); + query.exec("CREATE TABLE folder (id INTEGER PRIMARY KEY, parentId INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, " + "finished BOOLEAN DEFAULT 0, completed BOOLEAN DEFAULT 1, numChildren INTEGER, firstChildHash TEXT, customImage TEXT, " + "manga BOOLEAN DEFAULT 0, type INTEGER DEFAULT 0, added INTEGER, updated INTEGER, " + "FOREIGN KEY(parentId) REFERENCES folder(id) ON DELETE CASCADE)"); + query.exec("CREATE TABLE comic_info (id INTEGER PRIMARY KEY, hash TEXT, added INTEGER)"); + query.exec("CREATE TABLE comic (id INTEGER PRIMARY KEY, parentId INTEGER NOT NULL, comicInfoId INTEGER NOT NULL, fileName TEXT NOT NULL, path TEXT, " + "FOREIGN KEY(parentId) REFERENCES folder(id) ON DELETE CASCADE, FOREIGN KEY(comicInfoId) REFERENCES comic_info(id))"); + query.exec("CREATE TABLE label (id INTEGER PRIMARY KEY, name TEXT, ordering INTEGER, color INTEGER)"); + query.exec("CREATE TABLE comic_label (id INTEGER PRIMARY KEY, label_id INTEGER, comic_id INTEGER, ordering INTEGER, " + "FOREIGN KEY(comic_id) REFERENCES comic(id) ON DELETE CASCADE)"); + query.exec("CREATE TABLE comic_reading_list (id INTEGER PRIMARY KEY, reading_list_id INTEGER, comic_id INTEGER, ordering INTEGER, " + "FOREIGN KEY(comic_id) REFERENCES comic(id) ON DELETE CASCADE)"); + + query.exec("INSERT INTO folder VALUES (1, 1, 'root', '/', 0, 1, NULL, NULL, NULL, 0, 1, 100, 100)"); + // customImage and type are the folder state that an undo has to bring back. They + // only survive if the row comes back with the same id. + query.exec("INSERT INTO folder VALUES (2, 1, 'Unsorted', '/Unsorted', 0, 1, NULL, NULL, 'cover.jpg', 0, 3, 200, 200)"); + query.exec("INSERT INTO comic_info VALUES (1, 'hash1', 500)"); + query.exec("INSERT INTO comic VALUES (10, 2, 1, 'asm42.cbz', '/Unsorted/asm42.cbz')"); + query.exec("INSERT INTO label VALUES (1, 'To read', 0, 0)"); + query.exec("INSERT INTO comic_label VALUES (1, 1, 10, 0)"); + query.exec("INSERT INTO comic_reading_list VALUES (1, 1, 10, 0)"); + + return db; +} + +void writeFile(const QString &path) +{ + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + file.open(QIODevice::WriteOnly); + file.write("x"); + file.close(); +} + +} + +void OrganizeFilesTest::substitutesEveryToken() +{ + const auto entry = spiderMan(); + + QCOMPARE(buildRelativePath(QStringLiteral("{publisher}/{series}/{number} {title}"), entry), + QStringLiteral("Marvel/The Amazing Spider-Man/42 The Sinister Six.cbz")); + QCOMPARE(buildRelativePath(QStringLiteral("{imprint}/{volume}/{count}/{year}/{month}"), entry), + QStringLiteral("Epic/1/100/2018/7.cbz")); + QCOMPARE(buildRelativePath(QStringLiteral("{storyArc} {arcNumber}/{writer}/{filename}"), entry), + QStringLiteral("Sinister War 2/Dan Slott/asm42.cbz")); +} + +void OrganizeFilesTest::keepsPunctuationOutOfEmptyOptionalGroups() +{ + const auto entry = bareScan(); + + QStringList fallbacks; + QCOMPARE(buildRelativePath(QStringLiteral("{series}< ({year})>/<#{number}>< - {title}>"), entry, &fallbacks), + QStringLiteral("Unknown Series/scan001.cbz")); + + QCOMPARE(buildRelativePath(QStringLiteral("{publisher}/{series}"), entry), + QStringLiteral("Unknown Publisher/Unknown Series.cbz")); + + QVERIFY(!fallbacks.isEmpty()); + + const auto complete = spiderMan(); + QCOMPARE(buildRelativePath(QStringLiteral("{series}< ({year})>/<#{number}>< - {title}>"), complete), + QStringLiteral("The Amazing Spider-Man (2018)/#42 - The Sinister Six.cbz")); +} + +void OrganizeFilesTest::padsOnlyTheLeadingDigits() +{ + QCOMPARE(padNumber(QStringLiteral("42"), 3), QStringLiteral("042")); + QCOMPARE(padNumber(QStringLiteral("42AU"), 4), QStringLiteral("0042AU")); + QCOMPARE(padNumber(QStringLiteral("AU42"), 4), QStringLiteral("AU42")); + QCOMPARE(padNumber(QStringLiteral("1234"), 3), QStringLiteral("1234")); + + QCOMPARE(buildRelativePath(QStringLiteral("{number:000}"), spiderMan()), QStringLiteral("042.cbz")); +} + +void OrganizeFilesTest::reportsInvalidTokens() +{ + QVERIFY(invalidTokens(QStringLiteral("{series}/{number:000}")).isEmpty()); + QCOMPARE(invalidTokens(QStringLiteral("{series}/{penciller}")), QStringList { QStringLiteral("{penciller}") }); + QCOMPARE(invalidTokens(QStringLiteral("{title:000}")), QStringList { QStringLiteral("{title:000}") }); + QVERIFY(!invalidTokens(QStringLiteral("{series")).isEmpty()); + QVERIFY(!invalidTokens(QStringLiteral("{series}<({year})")).isEmpty()); +} + +void OrganizeFilesTest::sanitizesSegments() +{ + QCOMPARE(sanitizeSegment(QStringLiteral("a/b:c*d")), QStringLiteral("a_b_c_d")); + QCOMPARE(sanitizeSegment(QStringLiteral("trailing dots...")), QStringLiteral("trailing dots")); + QCOMPARE(sanitizeSegment(QStringLiteral(" spaced out ")), QStringLiteral("spaced out")); + QCOMPARE(sanitizeSegment(QStringLiteral("- leading dash")), QStringLiteral("leading dash")); + QCOMPARE(sanitizeSegment(QStringLiteral("NUL")), QStringLiteral("NUL_")); + QCOMPARE(sanitizeSegment(QStringLiteral("com1.cbz")), QStringLiteral("com1.cbz_")); + QCOMPARE(sanitizeSegment(QStringLiteral("Console")), QStringLiteral("Console")); +} + +void OrganizeFilesTest::resolvesCollisionsAgainstDiskAndPlan() +{ + QTemporaryDir temporary; + const QString base = temporary.path(); + + writeFile(base + QStringLiteral("/Marvel/Series/001.cbz")); + + ComicEntry first; + first.comicId = 1; + first.sourceAbsolute = base + QStringLiteral("/in/a.cbz"); + first.extension = QStringLiteral(".cbz"); + first.baseName = QStringLiteral("a"); + first.publisher = QStringLiteral("Marvel"); + first.series = QStringLiteral("Series"); + first.number = QStringLiteral("1"); + + ComicEntry second = first; + second.comicId = 2; + second.sourceAbsolute = base + QStringLiteral("/in/b.cbz"); + second.baseName = QStringLiteral("b"); + + writeFile(first.sourceAbsolute); + writeFile(second.sourceAbsolute); + + PlanBuilder builder({ first, second }, base, Mode::Organize); + const auto moves = builder.build(QStringLiteral("{publisher}/{series}/{number:000}"), { }); + + QCOMPARE(moves.size(), 2); + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Marvel/Series/001 (1).cbz")); + QCOMPARE(moves.at(0).status, PlannedMove::Status::Renamed); + QCOMPARE(moves.at(1).destinationRelative, QStringLiteral("Marvel/Series/001 (2).cbz")); + QCOMPARE(moves.at(1).status, PlannedMove::Status::Renamed); +} + +void OrganizeFilesTest::freesTheNameOfAFileThatMovesAway() +{ + QTemporaryDir temporary; + const QString base = temporary.path(); + + ComicEntry mover; + mover.comicId = 1; + mover.sourceAbsolute = base + QStringLiteral("/Series/001.cbz"); + mover.extension = QStringLiteral(".cbz"); + mover.baseName = QStringLiteral("001"); + mover.series = QStringLiteral("Series"); + mover.number = QStringLiteral("1"); + writeFile(mover.sourceAbsolute); + + PlanBuilder builder({ mover }, base, Mode::Organize); + const auto moves = builder.build(QStringLiteral("{series}/{number:000}"), { }); + + QCOMPARE(moves.size(), 1); + QCOMPARE(moves.at(0).status, PlannedMove::Status::Unchanged); + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Series/001.cbz")); +} + +void OrganizeFilesTest::keepsOverridesAndExclusions() +{ + QTemporaryDir temporary; + const QString base = temporary.path(); + + auto entry = spiderMan(); + entry.sourceAbsolute = base + QStringLiteral("/in/asm42.cbz"); + writeFile(entry.sourceAbsolute); + + Overrides overrides; + overrides[entry.sourceAbsolute].destinationRelative = QStringLiteral("Renamed/By hand.cbz"); + + PlanBuilder builder({ entry }, base, Mode::Organize); + + auto moves = builder.build(QStringLiteral("{publisher}/{series}/{number:000}"), overrides); + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Renamed/By hand.cbz")); + + moves = builder.build(QStringLiteral("{series}/{filename}"), overrides); + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Renamed/By hand.cbz")); + + overrides[entry.sourceAbsolute].excluded = true; + moves = builder.build(QStringLiteral("{series}/{filename}"), overrides); + QCOMPARE(moves.at(0).status, PlannedMove::Status::Excluded); +} + +void OrganizeFilesTest::renameModeKeepsEveryFileInItsFolder() +{ + QTemporaryDir temporary; + const QString base = temporary.path(); + + ComicEntry deep; + deep.comicId = 1; + deep.sourceAbsolute = base + QStringLiteral("/3x3 ojos/scans/raw01.cbz"); + deep.baseName = QStringLiteral("raw01"); + deep.extension = QStringLiteral(".cbz"); + deep.series = QStringLiteral("3x3 Eyes"); + deep.number = QStringLiteral("1"); + writeFile(deep.sourceAbsolute); + + ComicEntry atBase; + atBase.comicId = 2; + atBase.sourceAbsolute = base + QStringLiteral("/loose.cbz"); + atBase.baseName = QStringLiteral("loose"); + atBase.extension = QStringLiteral(".cbz"); + atBase.series = QStringLiteral("Loose"); + atBase.number = QStringLiteral("7"); + writeFile(atBase.sourceAbsolute); + + PlanBuilder builder({ deep, atBase }, base, Mode::Rename); + const auto moves = builder.build(QStringLiteral("{series}< #{number:000}>"), { }); + + QCOMPARE(moves.size(), 2); + + // The folder structure is untouched; only the file name changes. + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("3x3 ojos/scans/3x3 Eyes #001.cbz")); + + // A comic already sitting in the base gets no empty leading segment. + QCOMPARE(moves.at(1).destinationRelative, QStringLiteral("Loose #007.cbz")); + + for (const auto &move : moves) + QCOMPARE(QFileInfo(move.destinationRelative).path(), QFileInfo(QDir(base).relativeFilePath(move.sourceAbsolute)).path()); +} + +void OrganizeFilesTest::rejectsSeparatorsInAFilenamePattern() +{ + QVERIFY(patternCreatesFolders(QStringLiteral("{series}/{number:000}"))); + QVERIFY(!patternCreatesFolders(QStringLiteral("{series} #{number:000}"))); + + QVERIFY(!patternCreatesFolders(defaultPattern(Mode::Rename))); + QVERIFY(patternCreatesFolders(defaultPattern(Mode::Organize))); + + for (const auto &preset : presets(Mode::Rename)) + QVERIFY2(!patternCreatesFolders(preset.second), qPrintable(preset.second)); + + QVERIFY(!knownTokens().contains(QStringLiteral("folder"))); + QVERIFY(invalidTokens(defaultPattern(Mode::Rename)).isEmpty()); +} + +void OrganizeFilesTest::claimsThePathOfAnExcludedComic() +{ + QTemporaryDir temporary; + const QString base = temporary.path(); + + // The excluded comic already sits on the path the pattern gives the other one. + // It never moves, so the other one cannot have that path. + ComicEntry staying; + staying.comicId = 1; + staying.sourceAbsolute = QDir::cleanPath(base + QStringLiteral("/Series/001.cbz")); + staying.baseName = QStringLiteral("001"); + staying.extension = QStringLiteral(".cbz"); + staying.series = QStringLiteral("Series"); + staying.number = QStringLiteral("1"); + + ComicEntry moving; + moving.comicId = 2; + moving.sourceAbsolute = QDir::cleanPath(base + QStringLiteral("/Unsorted/loose.cbz")); + moving.baseName = QStringLiteral("loose"); + moving.extension = QStringLiteral(".cbz"); + moving.series = QStringLiteral("Series"); + moving.number = QStringLiteral("1"); + + writeFile(staying.sourceAbsolute); + writeFile(moving.sourceAbsolute); + + Overrides overrides; + overrides[staying.sourceAbsolute].excluded = true; + + PlanBuilder builder({ staying, moving }, base, Mode::Organize); + const auto moves = builder.build(QStringLiteral("{series}/{number:000}"), overrides); + + QCOMPARE(moves.size(), 2); + QCOMPARE(moves.at(0).status, PlannedMove::Status::Excluded); + QCOMPARE(moves.at(1).status, PlannedMove::Status::Renamed); + QCOMPARE(moves.at(1).destinationRelative, QStringLiteral("Series/001 (1).cbz")); + + // The same has to hold when the entries arrive the other way round. The old + // single pass made the answer depend on the order of the list. + PlanBuilder reversed({ moving, staying }, base, Mode::Organize); + const auto reversedMoves = reversed.build(QStringLiteral("{series}/{number:000}"), overrides); + + QCOMPARE(reversedMoves.size(), 2); + QCOMPARE(reversedMoves.at(0).status, PlannedMove::Status::Renamed); + QCOMPARE(reversedMoves.at(0).destinationRelative, QStringLiteral("Series/001 (1).cbz")); + QCOMPARE(reversedMoves.at(1).status, PlannedMove::Status::Excluded); +} + +void OrganizeFilesTest::renamesWhenOnlyTheCaseChanges() +{ + QTemporaryDir temporary; + const QString base = temporary.path(); + + ComicEntry entry; + entry.comicId = 1; + entry.sourceAbsolute = QDir::cleanPath(base + QStringLiteral("/Series/spider-man 001.cbz")); + entry.baseName = QStringLiteral("spider-man 001"); + entry.extension = QStringLiteral(".cbz"); + entry.series = QStringLiteral("Spider-Man"); + entry.number = QStringLiteral("001"); + + writeFile(entry.sourceAbsolute); + + PlanBuilder builder({ entry }, base, Mode::Rename); + const auto moves = builder.build(QStringLiteral("{series} {number}"), Overrides()); + + QCOMPARE(moves.size(), 1); + // Folded paths would call this unchanged on Windows and macOS, and fixing + // capitalisation is a normal reason to rename. + QCOMPARE(moves.at(0).status, PlannedMove::Status::Move); + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Series/Spider-Man 001.cbz")); + + // And the move itself has to go through, which a plain rename cannot do on a + // file system that ignores case. + QString reason; + const QString destination = QDir::cleanPath(base + QStringLiteral("/Series/Spider-Man 001.cbz")); + QVERIFY2(OrganizeFiles::moveFile(entry.sourceAbsolute, destination, &reason), qPrintable(reason)); + QCOMPARE(QDir(base + QStringLiteral("/Series")).entryList(QDir::Files), QStringList { QStringLiteral("Spider-Man 001.cbz") }); +} + +void OrganizeFilesTest::adoptsTheOnDiskCasingOfExistingFolders() +{ + QTemporaryDir temporary; + const QString base = QDir::cleanPath(temporary.path()); + + // The destination folder already exists on disk with a different casing. + // mkpath() cannot re-case it, so the files will land in "marvel" whatever the + // pattern says, and the plan and the database have to say the same. + writeFile(base + QStringLiteral("/marvel/existing.cbz")); + + ComicEntry entry; + entry.comicId = 1; + entry.sourceAbsolute = QDir::cleanPath(base + QStringLiteral("/Unsorted/a.cbz")); + entry.baseName = QStringLiteral("a"); + entry.extension = QStringLiteral(".cbz"); + entry.publisher = QStringLiteral("Marvel"); + entry.number = QStringLiteral("1"); + writeFile(entry.sourceAbsolute); + + PlanBuilder builder({ entry }, base, Mode::Organize); + const auto moves = builder.build(QStringLiteral("{publisher}/{number:000}"), { }); + + QCOMPARE(moves.size(), 1); +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("marvel/001.cbz")); +#else + // On a case-sensitive file system "Marvel" really is a different directory. + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Marvel/001.cbz")); +#endif +} + +void OrganizeFilesTest::mergesPlannedFolderCasingsIntoOne() +{ + QTemporaryDir temporary; + const QString base = QDir::cleanPath(temporary.path()); + + // Two casings of one new folder. On disk the second mkpath() is a no-op, so + // only one directory appears, with the casing of whichever move ran first. The + // plan has to agree with itself, or the database gets two folder rows for one + // directory and the next update deletes one of them, comics included. + ComicEntry first; + first.comicId = 1; + first.sourceAbsolute = QDir::cleanPath(base + QStringLiteral("/in/a.cbz")); + first.baseName = QStringLiteral("a"); + first.extension = QStringLiteral(".cbz"); + first.publisher = QStringLiteral("Marvel"); + first.number = QStringLiteral("1"); + + ComicEntry second = first; + second.comicId = 2; + second.sourceAbsolute = QDir::cleanPath(base + QStringLiteral("/in/b.cbz")); + second.baseName = QStringLiteral("b"); + second.publisher = QStringLiteral("MARVEL"); + second.number = QStringLiteral("2"); + + writeFile(first.sourceAbsolute); + writeFile(second.sourceAbsolute); + + PlanBuilder builder({ first, second }, base, Mode::Organize); + const auto moves = builder.build(QStringLiteral("{publisher}/{number:000}"), { }); + + QCOMPARE(moves.size(), 2); + QCOMPARE(moves.at(0).destinationRelative, QStringLiteral("Marvel/001.cbz")); +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + // The first appearance in the plan decides the casing. + QCOMPARE(moves.at(1).destinationRelative, QStringLiteral("Marvel/002.cbz")); +#else + QCOMPARE(moves.at(1).destinationRelative, QStringLiteral("MARVEL/002.cbz")); +#endif +} + +void OrganizeFilesTest::ordersMovesSoNothingIsOverwritten() +{ + using OrganizeFiles::FileMove; + + // A chain: the second move has to free /b before the first can take it. + const QList chain { + { 1, QStringLiteral("/a.cbz"), QStringLiteral("/b.cbz") }, + { 2, QStringLiteral("/b.cbz"), QStringLiteral("/c.cbz") } + }; + + const auto orderedChain = OrganizeFiles::orderMoves(chain); + QCOMPARE(orderedChain.size(), 2); + QCOMPARE(orderedChain.at(0).move.comicId, 2ull); + QCOMPARE(orderedChain.at(1).move.comicId, 1ull); + QVERIFY(!orderedChain.at(0).viaTemporary); + QVERIFY(!orderedChain.at(1).viaTemporary); + + // A swap cannot be ordered at all, so one file is parked under a temporary name + // first. The parking move has to come first, or nothing else can proceed. + const QList swap { + { 1, QStringLiteral("/a.cbz"), QStringLiteral("/b.cbz") }, + { 2, QStringLiteral("/b.cbz"), QStringLiteral("/a.cbz") } + }; + + const auto orderedSwap = OrganizeFiles::orderMoves(swap); + QCOMPARE(orderedSwap.size(), 2); + QVERIFY(orderedSwap.at(0).viaTemporary); + QVERIFY(!orderedSwap.at(1).viaTemporary); + QCOMPARE(orderedSwap.at(1).move.comicId, 2ull); + + // Moves that have nothing to do with each other keep their order. + const QList independent { + { 1, QStringLiteral("/a.cbz"), QStringLiteral("/x.cbz") }, + { 2, QStringLiteral("/b.cbz"), QStringLiteral("/y.cbz") } + }; + + const auto orderedIndependent = OrganizeFiles::orderMoves(independent); + QCOMPARE(orderedIndependent.size(), 2); + QCOMPARE(orderedIndependent.at(0).move.comicId, 1ull); + QCOMPARE(orderedIndependent.at(1).move.comicId, 2ull); +} + +void OrganizeFilesTest::removesOnlyEmptyDirectoriesInsideTheBase() +{ + QTemporaryDir temporary; + const QString root = QDir::cleanPath(temporary.path()); + const QString base = root + QStringLiteral("/base"); + + QDir().mkpath(base + QStringLiteral("/empty/deeper")); + QDir().mkpath(base + QStringLiteral("/kept")); + QDir().mkpath(root + QStringLiteral("/outside")); + writeFile(base + QStringLiteral("/kept/still here.cbz")); + + const auto removed = OrganizeFiles::removeEmptyDirectories( + { base + QStringLiteral("/empty/deeper"), base + QStringLiteral("/kept"), root + QStringLiteral("/outside") }, + base); + + // The empty branch goes, and the walk up stops at the base. + QVERIFY(!QDir(base + QStringLiteral("/empty/deeper")).exists()); + QVERIFY(!QDir(base + QStringLiteral("/empty")).exists()); + QCOMPARE(removed.size(), 2); + + QVERIFY(QDir(base).exists()); + QVERIFY(QDir(base + QStringLiteral("/kept")).exists()); + // Outside the base is none of this operation's business, even when it is empty. + QVERIFY(QDir(root + QStringLiteral("/outside")).exists()); +} + +void OrganizeFilesTest::sweepsCreatedDirectoriesWhenAMoveFails() +{ + QTemporaryDir temporary; + const QString library = QDir::cleanPath(temporary.path()); + + // The first move fails: its source does not exist. The directory created for + // it must not survive the run, or a library update right after would find a + // folder the database knows nothing about. + OrganizeFiles::FileMove failing; + failing.comicId = 1; + failing.source = library + QStringLiteral("/Unsorted/missing.cbz"); + failing.destination = library + QStringLiteral("/Marvel/Series/001.cbz"); + + OrganizeFiles::FileMove moving; + moving.comicId = 2; + moving.source = library + QStringLiteral("/Unsorted/real.cbz"); + moving.destination = library + QStringLiteral("/DC/002.cbz"); + writeFile(moving.source); + + MoveWorker worker(library, library, { failing, moving }, true); + worker.process(); + + QCOMPARE(worker.completedMoves().size(), 1); + QCOMPARE(worker.failures().size(), 1); + + // The created-and-unused branch is gone, the used one holds the file. + QVERIFY(!QDir(library + QStringLiteral("/Marvel")).exists()); + QVERIFY(QFileInfo::exists(library + QStringLiteral("/DC/002.cbz"))); + + // The emptied source directory was cleaned up as usual. + QVERIFY(!QDir(library + QStringLiteral("/Unsorted")).exists()); +} + +void OrganizeFilesTest::undoRemovesOnlyTheDirectoriesTheRunCreated() +{ + QTemporaryDir temporary; + const QString base = QDir::cleanPath(temporary.path()); + + // The run created Marvel and Marvel/Spider-Man. It did not create Existing: that + // folder was already there, empty, and the run only put a file in it. + QDir().mkpath(base + QStringLiteral("/Marvel/Spider-Man")); + QDir().mkpath(base + QStringLiteral("/Existing")); + QDir().mkpath(base + QStringLiteral("/Marvel/Kept")); + writeFile(base + QStringLiteral("/Marvel/Kept/other.cbz")); + + const auto removed = OrganizeFiles::removeCreatedDirectories( + { base + QStringLiteral("/Marvel"), base + QStringLiteral("/Marvel/Spider-Man") }); + + QVERIFY(!QDir(base + QStringLiteral("/Marvel/Spider-Man")).exists()); + QCOMPARE(removed.size(), 1); + + // Marvel still holds Kept, so it stays, and so does everything under it. + QVERIFY(QDir(base + QStringLiteral("/Marvel")).exists()); + QVERIFY(QDir(base + QStringLiteral("/Marvel/Kept")).exists()); + + // The folder that was there before the run is empty again, and it is still not + // this operation's to delete. + QVERIFY(QDir(base + QStringLiteral("/Existing")).exists()); +} + +void OrganizeFilesTest::movesComicRowWithoutLosingCuration() +{ + const QString connectionName = QStringLiteral("organize_move"); + { + auto db = createDatabase(connectionName); + + const auto parentId = DBHelper::ensureFolderPath(QStringLiteral("/Marvel/The Amazing Spider-Man"), db); + QVERIFY(parentId > 1); + QVERIFY(DBHelper::moveComic(10, parentId, QStringLiteral("042.cbz"), + QStringLiteral("/Marvel/The Amazing Spider-Man/042.cbz"), db)); + + QSqlQuery query(db); + query.exec("SELECT parentId, fileName, path FROM comic WHERE id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toULongLong(), parentId); + QCOMPARE(query.value(1).toString(), QStringLiteral("042.cbz")); + QCOMPARE(query.value(2).toString(), QStringLiteral("/Marvel/The Amazing Spider-Man/042.cbz")); + + query.exec("SELECT COUNT(*) FROM comic_label WHERE comic_id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + query.exec("SELECT COUNT(*) FROM comic_reading_list WHERE comic_id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + // /Unsorted is empty now that the comic moved out, so the row goes. + QList removed; + DBHelper::removeEmptyFolderPaths({ QStringLiteral("/Unsorted") }, db, &removed); + QCOMPARE(removed.size(), 1); + + query.exec("SELECT COUNT(*) FROM folder WHERE path = '/Unsorted'"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 0); + + // Checked again after the folder row is gone. The cascade runs from folder + // to comic and from comic to the curation tables, so this is the assertion + // that says the cleanup did not reach the comic. + query.exec("SELECT COUNT(*) FROM comic WHERE id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + query.exec("SELECT COUNT(*) FROM comic_label WHERE comic_id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + query.exec("SELECT COUNT(*) FROM comic_reading_list WHERE comic_id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + } + QSqlDatabase::removeDatabase(connectionName); +} + +void OrganizeFilesTest::keepsAFolderThatStillHoldsAComic() +{ + const QString connectionName = QStringLiteral("organize_keep_folder"); + { + auto db = createDatabase(connectionName); + + // Comic 11 is in the library but not on disk, so the plan skips it. Its row + // still lives in /Unsorted. When comic 10 moves out, the directory on disk + // is empty, but the folder row is not. + QSqlQuery seed(db); + seed.exec("INSERT INTO comic_info VALUES (2, 'hash2', 600)"); + seed.exec("INSERT INTO comic VALUES (11, 2, 2, 'gone.cbz', '/Unsorted/gone.cbz')"); + seed.exec("INSERT INTO comic_label VALUES (2, 1, 11, 0)"); + + const auto parentId = DBHelper::ensureFolderPath(QStringLiteral("/Marvel"), db); + QVERIFY(DBHelper::moveComic(10, parentId, QStringLiteral("042.cbz"), QStringLiteral("/Marvel/042.cbz"), db)); + + QList removed; + DBHelper::removeEmptyFolderPaths({ QStringLiteral("/Unsorted") }, db, &removed); + + QVERIFY(removed.isEmpty()); + + QSqlQuery query(db); + query.exec("SELECT COUNT(*) FROM folder WHERE path = '/Unsorted'"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + // The whole point: the skipped comic and its label are still there. + query.exec("SELECT COUNT(*) FROM comic WHERE id = 11"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + query.exec("SELECT COUNT(*) FROM comic_label WHERE comic_id = 11"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + } + QSqlDatabase::removeDatabase(connectionName); +} + +void OrganizeFilesTest::createsFolderRowsInheritingTheType() +{ + const QString connectionName = QStringLiteral("organize_folders"); + { + auto db = createDatabase(connectionName); + + QList created; + const auto parentId = DBHelper::ensureFolderPath(QStringLiteral("/Manga/Series"), db, &created); + QCOMPARE(created.size(), 2); + + QSqlQuery query(db); + query.exec("SELECT type, path FROM folder WHERE id = " + QString::number(parentId)); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + QCOMPARE(query.value(1).toString(), QStringLiteral("/Manga/Series")); + + QList createdAgain; + QCOMPARE(DBHelper::ensureFolderPath(QStringLiteral("/Manga/Series"), db, &createdAgain), parentId); + QVERIFY(createdAgain.isEmpty()); + + DBHelper::moveComic(10, parentId, QStringLiteral("a.cbz"), QStringLiteral("/Manga/Series/a.cbz"), db); + DBHelper::syncFolderAddedFromContents({ parentId }, db); + + query.exec("SELECT added FROM folder WHERE id = " + QString::number(parentId)); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toLongLong(), 500); + } + QSqlDatabase::removeDatabase(connectionName); +} + +void OrganizeFilesTest::removesOnlyEmptyCreatedFolderRows() +{ + const QString connectionName = QStringLiteral("organize_created_rows"); + { + auto db = createDatabase(connectionName); + + // The run created /Marvel and /Marvel/Series and moved the comic in. + QList created; + const auto parentId = DBHelper::ensureFolderPath(QStringLiteral("/Marvel/Series"), db, &created); + QCOMPARE(created.size(), 2); + QVERIFY(DBHelper::moveComic(10, parentId, QStringLiteral("042.cbz"), QStringLiteral("/Marvel/Series/042.cbz"), db)); + + // While the comic is inside, the rows are not empty and must stay, even + // when they are asked for by id. + QList reversed = created; + std::reverse(reversed.begin(), reversed.end()); + DBHelper::removeEmptyFolderRows(reversed, db); + + QSqlQuery query(db); + query.exec("SELECT COUNT(*) FROM folder WHERE path LIKE '/Marvel%'"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 2); + + // The undo moves the comic back. Now the created rows are empty, and the + // undo deletes exactly them, children first. + QVERIFY(DBHelper::moveComic(10, 2, QStringLiteral("asm42.cbz"), QStringLiteral("/Unsorted/asm42.cbz"), db)); + DBHelper::removeEmptyFolderRows(reversed, db); + + query.exec("SELECT COUNT(*) FROM folder WHERE path LIKE '/Marvel%'"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 0); + + // The folders that were there before the run are untouched. + query.exec("SELECT COUNT(*) FROM folder"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 2); + } + QSqlDatabase::removeDatabase(connectionName); +} + +void OrganizeFilesTest::journalRoundTrip() +{ + QTemporaryDir temporary; + const QString library = temporary.path(); + + Journal journal(library); + QVERIFY(journal.begin(library)); + journal.appendMove(10, library + QStringLiteral("/Unsorted/asm42.cbz"), library + QStringLiteral("/Marvel/042.cbz")); + journal.appendRemovedDirectory(library + QStringLiteral("/Unsorted")); + journal.finish(); + + JournalData data; + QVERIFY(Journal::read(library, journal.filePath(), &data)); + QVERIFY(data.complete); + QCOMPARE(data.moves.size(), 1); + QCOMPARE(data.moves.at(0).comicId, 10ull); + QCOMPARE(data.moves.at(0).from, QStringLiteral("/Unsorted/asm42.cbz")); + QCOMPARE(data.moves.at(0).to, QStringLiteral("/Marvel/042.cbz")); + QCOMPARE(data.removedDirectories, QStringList { QStringLiteral("/Unsorted") }); + QCOMPARE(absoluteFromRelative(library, data.moves.at(0).to), QDir::cleanPath(library + QStringLiteral("/Marvel/042.cbz"))); + + QCOMPARE(Journal::latestPath(library), journal.filePath()); +} + +void OrganizeFilesTest::journalCarriesTheFolderRowsItRemoved() +{ + QTemporaryDir temporary; + const QString library = temporary.path(); + + QString journalPath; + + { + Journal journal(library); + QVERIFY(journal.begin(library)); + journal.appendMove(10, library + QStringLiteral("/Unsorted/asm42.cbz"), library + QStringLiteral("/Marvel/042.cbz")); + journal.finish(); + journalPath = journal.filePath(); + } + + // The database work runs after the files have moved and the journal is closed, + // so it has to be able to add to the same record. + { + Journal journal(library); + QVERIFY(journal.reopen(journalPath)); + + QVariantMap row; + row.insert(QStringLiteral("id"), 2); + row.insert(QStringLiteral("parentId"), 1); + row.insert(QStringLiteral("name"), QStringLiteral("Unsorted")); + row.insert(QStringLiteral("path"), QStringLiteral("/Unsorted")); + row.insert(QStringLiteral("type"), 3); + row.insert(QStringLiteral("customImage"), QStringLiteral("cover.jpg")); + + journal.appendRemovedFolder(row); + journal.appendCreatedFolder(7); + journal.appendCreatedFolder(8); + journal.finish(); + } + + JournalData data; + QVERIFY(Journal::read(library, journalPath, &data)); + + QCOMPARE(data.moves.size(), 1); + QCOMPARE(data.removedFolders.size(), 1); + + // The created rows come back in creation order, so an undo can reverse the + // list and delete children before parents. + QCOMPARE(data.createdFolders, (QList { 7, 8 })); + + const auto row = data.removedFolders.at(0); + // The id has to survive the round trip through JSON as an integer, because it + // goes straight back into an INTEGER PRIMARY KEY. + QCOMPARE(row.value(QStringLiteral("id")).toULongLong(), 2ull); + QCOMPARE(row.value(QStringLiteral("path")).toString(), QStringLiteral("/Unsorted")); + QCOMPARE(row.value(QStringLiteral("type")).toInt(), 3); + QCOMPARE(row.value(QStringLiteral("customImage")).toString(), QStringLiteral("cover.jpg")); +} + +void OrganizeFilesTest::undoPutsTheDatabaseBackExactlyAsItWas() +{ + QTemporaryDir temporary; + const QString library = temporary.path(); + + const QString connectionName = QStringLiteral("organize_undo"); + { + auto db = createDatabase(connectionName); + + const auto readFolder = [&db](const QString &path) { + QSqlQuery query(db); + query.prepare("SELECT id, parentId, name, path, type, customImage, added FROM folder WHERE path = :path"); + query.bindValue(":path", path); + query.exec(); + QVariantList values; + if (query.next()) { + for (int i = 0; i < 7; ++i) + values << query.value(i); + } + return values; + }; + + const auto before = readFolder(QStringLiteral("/Unsorted")); + QCOMPARE(before.size(), 7); + + // The run: the comic moves out and the folder it left is deleted. + const auto destinationId = DBHelper::ensureFolderPath(QStringLiteral("/Marvel/The Amazing Spider-Man"), db); + QVERIFY(DBHelper::moveComic(10, destinationId, QStringLiteral("042.cbz"), + QStringLiteral("/Marvel/The Amazing Spider-Man/042.cbz"), db)); + + QList removedFolders; + DBHelper::removeEmptyFolderPaths({ QStringLiteral("/Unsorted") }, db, &removedFolders); + QCOMPARE(removedFolders.size(), 1); + QVERIFY(readFolder(QStringLiteral("/Unsorted")).isEmpty()); + + // The record goes through the journal, exactly as it does in a real run, so + // the JSON round trip is part of what is being tested here. + Journal journal(library); + QVERIFY(journal.begin(library)); + for (const auto &row : removedFolders) + journal.appendRemovedFolder(row); + journal.finish(); + + JournalData data; + QVERIFY(Journal::read(library, journal.filePath(), &data)); + QCOMPARE(data.removedFolders.size(), 1); + + // The undo. + QVERIFY(DBHelper::restoreFolderRows(data.removedFolders, db)); + + QList created; + const auto restoredId = DBHelper::ensureFolderPath(QStringLiteral("/Unsorted"), db, &created); + // Nothing new was created: the original row is back, so the path resolves to + // it. A new row would mean a new id, and the custom cover for this folder is + // stored under the old one. + QVERIFY(created.isEmpty()); + QCOMPARE(restoredId, before.at(0).toULongLong()); + + QVERIFY(DBHelper::moveComic(10, restoredId, QStringLiteral("asm42.cbz"), QStringLiteral("/Unsorted/asm42.cbz"), db)); + + const auto after = readFolder(QStringLiteral("/Unsorted")); + QCOMPARE(after, before); + + QSqlQuery query(db); + query.exec("SELECT parentId, fileName, path FROM comic WHERE id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toULongLong(), before.at(0).toULongLong()); + QCOMPARE(query.value(1).toString(), QStringLiteral("asm42.cbz")); + QCOMPARE(query.value(2).toString(), QStringLiteral("/Unsorted/asm42.cbz")); + + query.exec("SELECT COUNT(*) FROM comic_label WHERE comic_id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + + query.exec("SELECT COUNT(*) FROM comic_reading_list WHERE comic_id = 10"); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toInt(), 1); + } + QSqlDatabase::removeDatabase(connectionName); +} + +QTEST_GUILESS_MAIN(OrganizeFilesTest) + +#include "main.moc"