Merge branch 'main' into keyboard_nav_improvements

This commit is contained in:
Jann Stute
2024-12-21 15:16:35 +01:00
183 changed files with 10775 additions and 1605 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"home.base_title": "TagStudio Alfa",
"home.include_all_tags": "Og (inkluderer alle tags)",
"home.include_any_tag": "Og (inkluderer envher tag)",
"home.main_window": "Hovedvindue"
}
+125
View File
@@ -0,0 +1,125 @@
{
"add_field.add": "Feld hinzufügen",
"build_tags.add_parent_tags": "Übergeordnete Tags hinzufügen",
"build_tags.parent_tags": "Übergeordnete Tags",
"delete_unlinked.confirm": "Sind Sie sicher, dass Sie die folgenden %{len(self.lib.missing_files)}-Einträge löschen wollen?",
"delete_unlinked.delete_entries": "Einträge am Löschen",
"delete_unlinked.delete_unlinked": "Unverknüpfte Einträge löschen",
"delete_unlinked.deleting_number_entries": "Löschen von %{x[0]+1}/{len(self.lib.missing_files)} Unverknüpften Einträgen",
"dialog.open_create_library": "Bibliothek öffnen/erstellen",
"dialog.refresh_directories": "Verzeichnisse werden aktualisiert",
"dialog.save_library": "Bibliothek speichern",
"dialog.scan_directories": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...",
"dialog.scan_directories.new_files": "Verzeichnisse nach neuen Dateien durchsuchen...\n%{x + 1} Datei%{„s“ if x + 1 != 1 else „“} Gesucht, %{len(self.lib.files_not_in_library)} Neue Dateien gefunden",
"file_extension.add_extension": "Erweiterung hinzufügen",
"file_opener.command_not_found": "Konnte %{command_name} nicht im System PATH finden",
"file_opener.not_found": "Datei nicht gefunden:",
"file_opener.open_file": "Datei öffnen:}",
"fix_dupes.advice_label": "Nach der Spiegelung können Sie DupeGuru verwenden, um die unerwünschten Dateien zu löschen. Verwenden Sie anschliessend die Funktion „Unverknüpfte Einträge reparieren“ im Menü „Extras“ von TagStudio, um die unverknüpften Einträge zu löschen.",
"fix_dupes.fix_dupes": "Doppelte Dateien korrigieren",
"fix_dupes.load_file": "DupeGuru-Datei laden",
"fix_dupes.mirror_description": "Spiegeln die Eingabedaten für jeden doppelten Abgleichsatz, indem Sie alle Daten kombinieren, ohne Felder zu entfernen oder zu duplizieren. Bei diesem Vorgang werden keine Dateien oder Daten gelöscht.",
"fix_dupes.mirror_entries": "Spiegelnde Einträge",
"fix_dupes.name_filter": "DupeGuru-Dateien (*.dupeguru)",
"fix_dupes.no_file_match": "Übereinstimmungen mit doppelten Dateien: N/A",
"fix_dupes.no_file_selected": "Keine DupeGuru-Datei ausgewählt",
"fix_dupes.number_file_match": "Übereinstimmungen mit doppelten Dateien: %{count}",
"fix_dupes.open_result_files": "DupeGuru Ergebnisdatei öffnen",
"fix_unlinked.delete_unlinked": "Nicht verknüpfte Einträge löschen",
"fix_unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, ausserhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft. Nicht verknüpfte Einträge können durch Durchsuchen Ihrer Verzeichnisse automatisch neu verknüpft, vom Benutzer manuell neu verknüpft oder auf Wunsch gelöscht werden.",
"fix_unlinked.duplicate_description": "Doppelte Einträge sind definiert als mehrere Einträge, die auf dieselbe Datei auf der Festplatte verweisen. Durch das Zusammenführen dieser Einträge werden die Tags und Metadaten aller Duplikate zu einem einzigen konsolidierten Eintrag zusammengefasst. Diese sind nicht zu verwechseln mit „doppelten Dateien“, die Duplikate Ihrer Dateien selbst außerhalb von TagStudio sind.",
"fix_unlinked.fix_unlinked": "Unverknüpfte Einträge reparieren",
"fix_unlinked.manual_relink": "Manuelle Neuverknüpfung",
"fix_unlinked.merge_dupes": "Doppelte Einträge zusammenführen",
"fix_unlinked.refresh_dupes": "Doppelte Einträge aktualisieren",
"fix_unlinked.scan_library.label": "Bibliothek nach nicht verknüpften Einträgen durchsuchen...",
"fix_unlinked.scan_library.title": "Bibliothek wird scannen",
"fix_unlinked.search_and_relink": "Suche && Neuverbindung",
"generic.add": "hinzufügen",
"generic.aliases": "Aliase",
"generic.apply": "anwenden",
"generic.cancel": "Abbrechen",
"generic.close_all": "Alle schliessen",
"generic.color": "Farbe",
"generic.delete": "Löschen",
"generic.done": "Fertig",
"generic.exclude": "ausschliessen",
"generic.file_extension": "Dateierweiterungen",
"generic.include": "einschliessen",
"generic.mirror": "Spiegel",
"generic.name": "Name",
"generic.open_all": "Alle öffnen",
"generic.open_file": "Datei öffnen",
"generic.open_file_explorer": "Datei im Explorer öffnen",
"generic.refresh_all": "Alle aktualisieren",
"generic.remove_field": "Feld entfernen",
"generic.search_tags": "Tags suchen",
"generic.shorthand": "Kürzel",
"home.base_title": "TagStudio Alpha",
"home.include_all_tags": "Und (enthält alle Tags)",
"home.include_any_tag": "Oder (enthält alle Tags)",
"home.main_window": "Hauptfenster",
"home.search": "Suchen",
"home.search_entries": "Nach Einträgen suchen",
"home.thumbnail_size": "Grösse des Vorschaubildes",
"library.Artist": "Künstler",
"library.anthology": "Anthologie",
"library.archived": "Archivierungsdatum",
"library.author": "Autor",
"library.book": "Buch",
"library.collation": "Zusammenstellung",
"library.comic": "Comicheft",
"library.comments": "Kommentare",
"library.composer": "Komponist",
"library.content_tags": "Inhalt Tags",
"library.date": "Datum",
"library.date_created": "Erstellungsdatum",
"library.date_modified": "Datum geändert",
"library.date_published": "Datum des Publizierten",
"library.date_released": "Veröffentlichungsdatum",
"library.date_taken": "Aufnahmedatum",
"library.date_uploaded": "Hochladedatum",
"library.description": "Beschreibung",
"library.favorite": "Favoriten",
"library.guest_artist": "Gastkünstler",
"library.magazine": "Zeitschrift",
"library.manga": "Manga",
"library.meta_tags": "Meta Tags",
"library.notes": "Notizen",
"library.publisher": "Herausgeber",
"library.series": "Serie",
"library.source": "Quelle",
"library.tags": "Tags",
"library.title": "Titel",
"library.url": "URL",
"library.volume": "Band",
"menu.edit": "Bearbeiten",
"menu.file": "Datei",
"menu.help": "Hilfe",
"menu.macros": "Makros",
"menu.tools": "Werkzeuge",
"menu.window": "Fenster",
"merge.merge_dupe_entries": "Zusammenführen von doppelten Einträgen",
"merge.window_title": "Zusammenführen von doppelten Einträgen",
"open_library.library_creation_return_code": "Bibliothekserstellung Rückgabecode:",
"open_library.no_tagstudio_library_found": "Keine vorhandene TagStudio-Bibliothek unter '%{Pfad}' gefunden. Eine wird erstellt.",
"open_library.title": "Bibliothek",
"preview.dimensions": "Abmessungen",
"preview.recent": "Aktuelle Bibliotheken",
"progression.running_macros.new_entries": "Ausführen von Makros bei neuen Einträgen",
"progression.running_macros.one_new_entry": "Ausführen konfigurierter Makros für 1/%{len(new_ids)} neue Einträge",
"progression.running_macros.several_new_entry": "Ausführen konfigurierter Makros für %{x + 1}/%{len(new_ids)} neue Einträge",
"relink_unlinked.title": "Einträge werden neuverknüpft",
"splash.open_library": "Die Bibliothek wird geöffnet",
"status.backup_success": "Bibliotheks-Backup gespeichert unter:",
"status.enumerate_query": "Abfrage:%{Abfrage}, Rahmen: %{i}, Länge: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Ergebnisse gefunden für „%{query}“ (%{format_timespan(end_time - start_time)})",
"status.results_found": "Ergebnisse",
"status.save_success": "Bibliothek gespeichert und geschlossen!",
"status.search_library_query": "Suche in der Bibliothek nach",
"tag.add": "Hinzufüge Tag",
"tag.library": "Bibliothek Tags",
"tag.new": "Neuer Tag",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"home.base_title": "TagStudio Alpha",
"home.main_window": "Main Window",
"home.include_all_tags": "And (Includes All Tags)",
"home.include_any_tag": "Or (Includes Any Tag)",
"home.thumbnail_size": "Thumbnail Size",
"home.search_entries": "Search Entries",
"home.search": "Search",
"menu.file": "File",
"menu.edit": "Edit",
"menu.tools": "Tools",
"menu.macros": "Macros",
"menu.window": "Window",
"menu.help": "Help",
"tag.new": "New Tag",
"tag.add": "Add Tag",
"tag.library": "Library Tags",
"merge.window_title": "Merging Duplicate Entries",
"merge.merge_dupe_entries": "Merging Duplicate Entries",
"preview.dimensions": "Dimensions",
"preview.recent": "Recent Libraries",
"library.title": "Title",
"library.author": "Author",
"library.Artist": "Artist",
"library.url": "URL",
"library.description": "Description",
"library.notes": "Notes",
"library.tags": "Tags",
"library.content_tags": "Content Tags",
"library.meta_tags": "Meta Tags",
"library.collation": "Collation",
"library.date": "Date",
"library.date_created": "Date Created",
"library.date_modified": "Date Modified",
"library.date_taken": "Date Taken",
"library.date_published": "Date Published",
"library.archived": "Date Archived",
"library.favorite": "Favorite",
"library.book": "Book",
"library.comic": "Comic",
"library.series": "Series",
"library.manga": "Manga",
"library.source": "Source",
"library.date_uploaded": "Date Uploaded",
"library.date_released": "Date Released",
"library.volume": "Volume",
"library.anthology": "Anthology",
"library.magazine": "Magazine",
"library.publisher": "Publisher",
"library.guest_artist": "Guest Artist",
"library.composer": "Composer",
"library.comments": "Comments",
"open_library.no_tagstudio_library_found": "No existing TagStudio library found at '%{path}'. Creating one.",
"open_library.library_creation_return_code": "Library Creation Return Code:",
"open_library.title": "Library",
"dialog.open_create_library": "Open/Create Library",
"splash.open_library": "Opening Library",
"status.save_success": "Library Saved and Closed!",
"status.backup_success": "Library Backup Saved at:",
"status.search_library_query": "Searching Library for",
"status.enumerate_query": "Query:%{query}, Frame: %{i}, Length: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Results Found for \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "Results",
"dialog.save_library": "Save Library",
"dialog.refresh_directories": "Refreshing Directories",
"dialog.scan_directories": "Scanning Directories for New Files...\nPreparing...",
"dialog.scan_directories.new_files": "Scanning Directories for New Files...\n%{x + 1} File%{\"s\" if x + 1 != 1 else \"\"} Searched, %{len(self.lib.files_not_in_library)} New Files Found",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S",
"progression.running_macros.new_entries": "Running Macros on New Entries",
"progression.running_macros.one_new_entry": "Running Configured Macros on 1/%{len(new_ids)} New Entries",
"progression.running_macros.several_new_entry": "Running Configured Macros on %{x + 1}/%{len(new_ids)} New Entries",
"file_opener.open_file": "Opening file:}",
"file_opener.not_found": "File not found:",
"file_opener.command_not_found": "Could not find %{command_name} on system PATH",
"add_field.add": "Add Field",
"generic.remove_field": "Remove Field",
"generic.file_extension": "File Extensions",
"generic.open_file": "Open file",
"generic.open_file_explorer": "Open file in explorer",
"generic.cancel": "Cancel",
"generic.add": "Add",
"generic.name": "Name",
"generic.shorthand": "Shorthand",
"generic.aliases": "Aliases",
"generic.color": "Color",
"generic.delete": "Delete",
"generic.exclude": "Exclude",
"generic.include": "Include",
"generic.done": "Done",
"generic.open_all": "Open All",
"generic.close_all": "Close All",
"generic.refresh_all": "Refresh All",
"generic.apply": "Apply",
"generic.mirror": "Mirror",
"generic.search_tags": "Search Tags",
"build_tags.parent_tags": "Parent Tags",
"build_tags.add_parent_tags": "Add Parent Tags",
"delete_unlinked.delete_unlinked": "Delete Unlinked Entries",
"delete_unlinked.confirm": "Are you sure you want to delete the following %{len(self.lib.missing_files)} entries?",
"delete_unlinked.delete_entries": "Deleting Entries",
"delete_unlinked.deleting_number_entries": "Deleting %{x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries",
"file_extension.add_extension": "Add Extension",
"file_extension.list_mode": "List Mode:",
"fix_dupes.fix_dupes": "Fix Duplicate Files",
"fix_dupes.no_file_selected": "No DupeGuru File Selected",
"fix_dupes.load_file": "Load DupeGuru File",
"fix_dupes.mirror_entries": "Mirror Entries",
"fix_dupes.mirror_description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.",
"fix_dupes.advice_label": "After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's \"Fix Unlinked Entries\" feature in the Tools menu in order to delete the unlinked Entries.",
"fix_dupes.open_result_files": "Open DupeGuru Results File",
"fix_dupes.name_filter": "DupeGuru Files (*.dupeguru)",
"fix_dupes.no_file_match": "Duplicate File Matches: N/A",
"fix_dupes.number_file_match": "Duplicate File Matches: %{count}",
"fix_unlinked.fix_unlinked": "Fix Unlinked Entries",
"fix_unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.",
"fix_unlinked.duplicate_description": "Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with \"duplicate files\", which are duplicates of your files themselves outside of TagStudio.",
"fix_unlinked.search_and_relink": "Search && Relink",
"fix_unlinked.refresh_dupes": "Refresh Duplicate Entries",
"fix_unlinked.merge_dupes": "Merge Duplicate Entries",
"fix_unlinked.manual_relink": "Manual Relink",
"fix_unlinked.delete_unlinked": "Delete Unlinked Entries",
"fix_unlinked.scan_library.title": "Scanning Library",
"fix_unlinked.scan_library.label": "Scanning Library for Unlinked Entries...",
"folders_to_tags.folders_to_tags": "Converting folders to Tags",
"folders_to_tags.title": "Create Tags From Folders",
"folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.",
"mirror_entities.are_you_sure": "Are you sure you want to mirror the following %{len(self.lib.dupe_files)} Entries?",
"mirror_entities.title": "Mirroring Entries",
"mirror_entities.label": "Mirroring 1/%{count} Entries...",
"relink_unlinked.title": "Relinking Entries",
"relink_unlinked.attempt_relink": "Attempting to Relink %{x[0]+1}/%{len(self.lib.missing_files)} Entries, %{self.fixed} Successfully Relinked",
"landing.open_button": "Open/Create Library %{open_shortcut_text}",
"preview_panel.missing_location": "Location is missing",
"preview_panel.update_widgets": "[ENTRY PANEL] UPDATE WIDGETS (%{self.driver.selected})",
"preview_panel.no_items_selected": "No Items Selected",
"preview_panel.confirm_remove": "Are you sure you want to remove this \"%{self.lib.get_field_attr(field, \"name\")}\" field?",
"preview_panel.mixed_data": "Mixed Data",
"preview_panel.edit_name": "Edit",
"preview_panel.unknown_field_type": "Unknown Field Type",
"tag.search_for_tag": "Search for Tag",
"tag.add_search": "Add to Search",
"text_line_edit.unknown_event_type": "unknown event type: %{event}"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "Añadir campo",
"build_tags.add_parent_tags": "Añadir etiquetas principales",
"build_tags.parent_tags": "Etiquetas principales",
"delete_unlinked.confirm": "¿Está seguro de que desea eliminar las siguientes %{len(self.lib.missing_files)} entradas?",
"delete_unlinked.delete_entries": "Eliminando entradas",
"delete_unlinked.delete_unlinked": "Eliminar entradas no vinculadas",
"delete_unlinked.deleting_number_entries": "Eliminando %{x[0]+1}/{len(self.lib.missing_files)} entradas no vinculadas",
"dialog.open_create_library": "Abrir/Crear biblioteca",
"dialog.refresh_directories": "Refrescar directorios",
"dialog.save_library": "Guardar la biblioteca",
"dialog.scan_directories": "Buscar archivos nuevos en los directorios...\nPreparando...",
"dialog.scan_directories.new_files": "Buscando archivos nuevos en los directorios...\n%{x + 1} Archivo%{\"s\" if x + 1 != 1 else \"\"} Buscado, %{len(self.lib.files_not_in_library)} Archivos nuevos encontrados",
"file_extension.add_extension": "Añadir extensión",
"file_extension.list_mode": "Modo de lista:",
"file_opener.command_not_found": "No se pudo encontrar %{command_name} en el PATH del sistema",
"file_opener.not_found": "Archivo no encontrado:",
"file_opener.open_file": "Abriendo archivo :}",
"fix_dupes.advice_label": "Después de la duplicación, puede utilizar DupeGuru para eliminar los archivos no deseados. Luego, utilice la función \"Reparar entradas no vinculadas\" de TagStudio en el menú Herramientas para eliminar las entradas no vinculadas.",
"fix_dupes.fix_dupes": "Reparar archivos duplicados",
"fix_dupes.load_file": "Cargar archivo DupeGuru",
"fix_dupes.mirror_description": "Reflejar los datos de entrada en cada conjunto de coincidencias duplicadas, combinando todos los datos sin eliminar ni duplicar campos. Esta operación no eliminará ningún archivos ni dato.",
"fix_dupes.mirror_entries": "Reflejar entradas",
"fix_dupes.name_filter": "Archivos DupeGuru (*.dupeguru)",
"fix_dupes.no_file_match": "Coincidencias de archivos duplicados: N/D",
"fix_dupes.no_file_selected": "No se ha seleccionado ningún archivo DupeGuru",
"fix_dupes.number_file_match": "Coincidencias de archivos duplicados: %{count}",
"fix_dupes.open_result_files": "Abrir el archivo de resultados de DupeGuru",
"fix_unlinked.delete_unlinked": "Borrar entradas no vinculadas",
"fix_unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de sus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado. Las entradas no vinculadas se pueden volver a vincular automáticamente mediante una búsqueda en sus directorios, el usuario puede volver a vincularlas manualmente o eliminarlas si así lo desea.",
"fix_unlinked.duplicate_description": "Las entradas duplicadas se definen como múltiples entradas que apuntan al mismo archivo en el disco. Al fusionarlas, se combinarán las etiquetas y los metadatos de todos los duplicados en una única entrada consolidada. No deben confundirse con los \"archivos duplicados\", que son duplicados de sus archivos fuera de TagStudio.",
"fix_unlinked.fix_unlinked": "Corregir entradas no vinculadas",
"fix_unlinked.manual_relink": "Reenlace manual",
"fix_unlinked.merge_dupes": "Fusionar entradas duplicadas",
"fix_unlinked.refresh_dupes": "Recargar entradas duplicadas",
"fix_unlinked.scan_library.label": "Buscando entradas no enlazadas en la biblioteca...",
"fix_unlinked.scan_library.title": "Escaneando la biblioteca",
"fix_unlinked.search_and_relink": "Buscar y volver a vincular",
"folders_to_tags.description": "Crea etiquetas basadas en su estructura de carpetas y las aplica a sus entradas.\n La siguiente estructura muestra todas las etiquetas que se crearán y a qué entradas se aplicarán.",
"folders_to_tags.folders_to_tags": "Convertir carpetas en etiquetas",
"folders_to_tags.title": "Crear etiquetas a partir de carpetas",
"generic.add": "Añadir",
"generic.aliases": "Alias",
"generic.apply": "Aplicar",
"generic.cancel": "Cancelar",
"generic.close_all": "Cerrar todo",
"generic.color": "Color",
"generic.delete": "Borrar",
"generic.done": "Donar",
"generic.exclude": "Excluir",
"generic.file_extension": "Extensiones del archivo",
"generic.include": "Incluir",
"generic.mirror": "Espejo",
"generic.name": "Nombre",
"generic.open_all": "Abrir todo",
"generic.open_file": "Abrir archivo",
"generic.open_file_explorer": "Abrir archivo en el explorador",
"generic.refresh_all": "Recargar todo",
"generic.remove_field": "Eliminar campo",
"generic.search_tags": "Buscar etiquetas",
"generic.shorthand": "Taquigrafía",
"home.base_title": "TagStudio Alfa",
"home.include_all_tags": "Y (Incluye todas las etiquetas)",
"home.include_any_tag": "O (Incluye cualquier etiqueta)",
"home.main_window": "Ventana principal",
"home.search": "Buscar",
"home.search_entries": "Buscar entradas",
"home.thumbnail_size": "Tamaño de la miniatura",
"landing.open_button": "Abrir/Crear biblioteca %{open_shortcut_text}",
"library.Artist": "Artista",
"library.anthology": "Antología",
"library.archived": "Fecha de archivo",
"library.author": "Autor",
"library.book": "Libro",
"library.collation": "Recopilación",
"library.comic": "Tebeo",
"library.comments": "Comentarios",
"library.composer": "Compositor",
"library.content_tags": "Etiquetas del contenido",
"library.date": "Fecha",
"library.date_created": "Fecha de creación",
"library.date_modified": "Fecha de modificación",
"library.date_published": "Fecha de publicación",
"library.date_released": "Fecha de lanzamiento",
"library.date_taken": "Fecha de realización",
"library.date_uploaded": "Fecha de carga",
"library.description": "Descripción",
"library.favorite": "Favorito",
"library.guest_artist": "Artista invitado",
"library.magazine": "Revista",
"library.manga": "Manga",
"library.meta_tags": "Etiquetas de metadatos",
"library.notes": "Notas",
"library.publisher": "Editor",
"library.series": "Series",
"library.source": "Fuente",
"library.tags": "Etiquetas",
"library.title": "Título",
"library.url": "URL",
"library.volume": "Volumen",
"menu.edit": "Editar",
"menu.file": "Archivo",
"menu.help": "Ayuda",
"menu.macros": "Macro",
"menu.tools": "Herramientas",
"menu.window": "Ventana",
"merge.merge_dupe_entries": "Fusionando entradas duplicadas",
"merge.window_title": "Fusionando entradas duplicadas",
"mirror_entities.are_you_sure": "¿Estás seguro de que quieres reflejar las siguientes %{len(self.lib.dupe_files)} entradas?",
"mirror_entities.label": "Reflejando 1/%{count} Entradas...",
"mirror_entities.title": "Entradas reflejadas",
"open_library.library_creation_return_code": "Código de retorno de creación de biblioteca:",
"open_library.no_tagstudio_library_found": "No se encontró ninguna biblioteca TagStudio existente en '%{path}'. Se está creando una.",
"open_library.title": "Biblioteca",
"preview.dimensions": "Dimensiones",
"preview.recent": "Bibliotecas recientes",
"preview_panel.confirm_remove": "¿Está seguro de que desea eliminar este campo \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"preview_panel.edit_name": "Editar",
"preview_panel.missing_location": "Falta la ubicación",
"preview_panel.mixed_data": "Datos variados",
"preview_panel.no_items_selected": "No hay elementos seleccionados",
"preview_panel.unknown_field_type": "Tipo de campo desconocido",
"preview_panel.update_widgets": "[PANEL DE ENTRADA] ACTUALIZAR WIDGETS (%{self.driver.selected})",
"progression.running_macros.new_entries": "Ejecución de macros en entradas nuevas",
"progression.running_macros.one_new_entry": "Ejecución de macros configurados en 1/%{len(new_ids)} entradas nuevas",
"progression.running_macros.several_new_entry": "Ejecución de macros configurados en %{x + 1}/%{len(new_ids)} entradas nuevas",
"relink_unlinked.attempt_relink": "Intentando volver a vincular %{x[0]+1}/%{len(self.lib.missing_files)} Entradas, %{self.fixed} Reenlazado correctamente",
"relink_unlinked.title": "Volver a vincular las entradas",
"splash.open_library": "Abriendo la biblioteca",
"status.backup_success": "Copia de seguridad de la biblioteca guardada en:",
"status.enumerate_query": "Consulta:%{query}, Cuadro: %{i}, Longitud: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Resultados encontrados para \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "Resultados",
"status.save_success": "¡Biblioteca guardada y cerrada!",
"status.search_library_query": "Buscando en la biblioteca",
"tag.add": "Añadir etiqueta",
"tag.add_search": "Añadir a la búsqueda",
"tag.library": "Etiquetas de la biblioteca",
"tag.new": "Nueva etiqueta",
"tag.search_for_tag": "Buscar por etiqueta",
"text_line_edit.unknown_event_type": "tipo de suceso desconocido: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+110
View File
@@ -0,0 +1,110 @@
{
"add_field.add": "Magdagdag ng Field",
"build_tags.add_parent_tags": "Magdagdag ng Mga Parent Tag",
"build_tags.parent_tags": "Mga Parent Tag",
"delete_unlinked.confirm": "Sigurado ka ba gusto mong burahin ang (mga) sumusunod na %{len(self.lib.missing_files)} entry?",
"delete_unlinked.delete_entries": "Binubura ang Mga Entry",
"delete_unlinked.delete_unlinked": "Burahin ang Mga Hindi Naka-link na Entry",
"delete_unlinked.deleting_number_entries": "Binubura ang %{x[0]+1}/{len(self.lib.missing_files)} (mga) Naka-unlink na Entry",
"dialog.open_create_library": "Magbukas/Gumawa ng Library",
"dialog.refresh_directories": "Nire-refresh ang Mga Direktoryo",
"dialog.save_library": "I-save ang Library",
"dialog.scan_directories": "Sina-scan ang Mga Direktoryo para sa Mga Bagong File...\nNaghahanda...",
"dialog.scan_directories.new_files": "Sina-scan ang Mga Direktoryo para sa Mga Bagong File...\n%{x + 1} %{\"mga\" if x + 1 != 1 else \"\"} File na nahanap, %{len(self.lib.files_not_in_library)} Mga Bagong File na Nahanap",
"file_extension.add_extension": "Magdagdag ng Extension",
"file_extension.list_mode": "Mode ng Pag-lista:",
"file_opener.command_not_found": "Hindi mahanap ang %{command_name} sa system PATH",
"file_opener.not_found": "Hindi nahanap ang file:",
"file_opener.open_file": "Binubuksan ang file:}",
"fix_dupes.fix_dupes": "Ayusin ang Mga Duplicate na File",
"fix_dupes.load_file": "Mag-load ng DupeGuru File",
"fix_dupes.mirror_description": "Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.",
"fix_dupes.mirror_entries": "Mga Entry ng Salamin",
"fix_dupes.no_file_selected": "Walang DupeGuru na File na Napili",
"generic.add": "Magdagdag",
"generic.aliases": "Mga Alyas",
"generic.apply": "I-apply",
"generic.cancel": "Kanselahin",
"generic.close_all": "Isara Lahat",
"generic.color": "Kulay",
"generic.delete": "Burahin",
"generic.done": "Tapos na",
"generic.exclude": "Huwag isama",
"generic.file_extension": "Mga File Extension",
"generic.include": "Isama",
"generic.mirror": "Salamin",
"generic.name": "Pangalan",
"generic.open_all": "Buksan Lahat",
"generic.open_file": "Buksan ang file",
"generic.open_file_explorer": "Buksan ang file sa explorer",
"generic.refresh_all": "I-refresh Lahat",
"generic.remove_field": "Tanggalin ang Field",
"generic.search_tags": "Maghanap ng Mga Tag",
"generic.shorthand": "Shorthand",
"home.base_title": "TagStudio Alpha",
"home.include_all_tags": "And (sinasama ang Lahat ng Mga Tag)",
"home.include_any_tag": "Or (Sinasama ang Anumang Tag)",
"home.main_window": "Main na Window",
"home.search": "Maghanap",
"home.search_entries": "Mga Entry sa Paghahanap",
"home.thumbnail_size": "Laki ng Thumbnail",
"library.Artist": "Artista",
"library.anthology": "Antolohiya",
"library.archived": "Petsa na Na-archve",
"library.author": "Awtor",
"library.book": "Aklat",
"library.collation": "Collation",
"library.comic": "Komik",
"library.comments": "Mga Komento",
"library.composer": "Kompositor",
"library.content_tags": "Mga Tag ng Content",
"library.date": "Petsa",
"library.date_created": "Petsa na Ginawa",
"library.date_modified": "Binago Noong",
"library.date_published": "Petsa na Inilabas",
"library.date_released": "Petsa na na-release",
"library.date_taken": "Kinuha Noong",
"library.date_uploaded": "Petsa na Na-upload",
"library.description": "Paglalarawan",
"library.favorite": "Paborito",
"library.guest_artist": "Bisitang Artista",
"library.magazine": "Magasin",
"library.manga": "Manga",
"library.meta_tags": "Mga Meta Tag",
"library.notes": "Mga tala",
"library.publisher": "Tagapaglathala",
"library.series": "Serye",
"library.source": "Pinagmulan",
"library.tags": "Mga Tag",
"library.title": "Paksa",
"library.url": "URL",
"library.volume": "Volume",
"menu.edit": "I-edit",
"menu.file": "File",
"menu.help": "Tulong",
"menu.macros": "Mga macro",
"menu.tools": "Mga tool",
"menu.window": "Window",
"merge.merge_dupe_entries": "Sinasama ang mga Duplicate na Entry",
"merge.window_title": "Sinasama ang mga Duplicate na Entry",
"open_library.library_creation_return_code": "Return Code ng Paggawa ng Library:",
"open_library.no_tagstudio_library_found": "Walang umiiral na TagStudio library na nahanap sa '%{path}'. Gumagawa ng isa.",
"open_library.title": "Library",
"preview.dimensions": "Laki",
"preview.recent": "Mga Kamakailang Library",
"progression.running_macros.new_entries": "Tumatakbo ng Mga Macro sa Mga Bagong Entry",
"progression.running_macros.one_new_entry": "Tinatakbo ang Mga Naka-configure na Macro sa 1/%{len(new_ids)} Mga Bagong Entry",
"progression.running_macros.several_new_entry": "Tinatakbo ang Mga Naka-configure na Macro sa %{x + 1}/%{len(new_ids)} Mga Bagong Entry",
"splash.open_library": "Binubuksan ang Library",
"status.backup_success": "Na-save ang Library Backup sa:",
"status.enumerate_query": "Query:%{query}, Frame: %{i}, Haba: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Nakahanap ng Mga Resulta para sa \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "Mga Resulta",
"status.save_success": "Sinave at Sinara ang Library!",
"status.search_library_query": "Hinahanap ang library para sa",
"tag.add": "Magdagdag ng Tag",
"tag.library": "Mga Tag ng Library",
"tag.new": "Bagong Tag",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "Ajouter un Champ",
"build_tags.add_parent_tags": "Ajouter des Labels Parents",
"build_tags.parent_tags": "Labels Parent",
"delete_unlinked.confirm": "Êtes-vous sûr de vouloir supprimer les %{len(self.lib.missing_files)} entrées suivantes ?",
"delete_unlinked.delete_entries": "Suppression des Entrées",
"delete_unlinked.delete_unlinked": "Supprimer les Entrées non Liées",
"delete_unlinked.deleting_number_entries": "Suppression des Entrées non Liées %{x[0]+1}/{len(self.lib.missing_files)}",
"dialog.open_create_library": "Ouvrir/Créer une Bibliothèque",
"dialog.refresh_directories": "Rafraîchissement des Dossiers",
"dialog.save_library": "Sauvegarder la Bibliothèque",
"dialog.scan_directories": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...",
"dialog.scan_directories.new_files": "Recherche de Nouveaux Fichiers dans les Dossiers...\n%{x + 1} Fichiers%{\"s\" if x + 1!= 1 else \"\"} Recherchés, %{len(self.lib.files_not_in_library)} Nouveaux Fichiers Trouvés",
"file_extension.add_extension": "Ajouter une Extension",
"file_extension.list_mode": "Mode Liste :",
"file_opener.command_not_found": "Impossible de trouver %{command_name} dans le CHEMIN système",
"file_opener.not_found": "Fichier non trouvé:",
"file_opener.open_file": "Ouverture du Fichier:}",
"fix_dupes.advice_label": "Après réplication, vous êtes libre d'utiliser DupeGuru pour supprimer des fichiers non désirés. Ensuite, utilisez la fonctionnalité \"Réparation des Entrées non Liées\" de TagStudio dans le menu Outils pour supprimer les Entrées non liées.",
"fix_dupes.fix_dupes": "Réparer les Fichiers en Double",
"fix_dupes.load_file": "Charger un Fichier DupeGuru",
"fix_dupes.mirror_description": "Repliquer les données d'entrée dans chaque jeu de correspondances en double, en combinant toutes les données sans supprimer ni dupliquer de champs. Cette opération ne supprime aucun fichier ni aucune donnée.",
"fix_dupes.mirror_entries": "Répliquer les Entrées",
"fix_dupes.name_filter": "Fichiers DupeGuru (*.dupeguru)",
"fix_dupes.no_file_match": "Dupliquer les Correspondances de Fichier : N/A",
"fix_dupes.no_file_selected": "Aucun Fichier DupeGuru Sélectionné",
"fix_dupes.number_file_match": "Dupliquer les Correspondances de Fichier : %{count}",
"fix_dupes.open_result_files": "Ouvrire les Fichiers de Résultats de DupeGuru",
"fix_unlinked.delete_unlinked": "Supprimer les Entrées non Liées",
"fix_unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.",
"fix_unlinked.duplicate_description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les labels et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
"fix_unlinked.fix_unlinked": "Réparation des Entrées non Liées",
"fix_unlinked.manual_relink": "Reliage Manuel",
"fix_unlinked.merge_dupes": "Fusionner les Entrées en Doublon",
"fix_unlinked.refresh_dupes": "Rafraichir les Entrées en Doublon",
"fix_unlinked.scan_library.label": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",
"fix_unlinked.scan_library.title": "Balayage de la Bibliothèque",
"fix_unlinked.search_and_relink": "Rechercher && Relier",
"folders_to_tags.description": "Créé des labels basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.",
"folders_to_tags.folders_to_tags": "Conversion des dossiers en Labels",
"folders_to_tags.title": "Créer un Label à partir d'un Dossier",
"generic.add": "Ajouter",
"generic.aliases": "Alias",
"generic.apply": "Appliquer",
"generic.cancel": "Annuler",
"generic.close_all": "Tout Fermer",
"generic.color": "Couleur",
"generic.delete": "Supprimer",
"generic.done": "Terminé",
"generic.exclude": "Exclure",
"generic.file_extension": "Extensions de Fichiers",
"generic.include": "Inclure",
"generic.mirror": "Refléter",
"generic.name": "Nom",
"generic.open_all": "Tout Ouvrir",
"generic.open_file": "Ouvrir un Fichier",
"generic.open_file_explorer": "Ouvrir un Fichier dans l'Explorateur",
"generic.refresh_all": "Tout Rafraîchir",
"generic.remove_field": "Supprimer un Champ",
"generic.search_tags": "Recherche de Labels",
"generic.shorthand": "Abrégé",
"home.base_title": "TagStudio Alpha",
"home.include_all_tags": "Et (Inclus tous les Labels)",
"home.include_any_tag": "Ou (Inclus N'importe quel Label)",
"home.main_window": "Fenêtre Principale",
"home.search": "Rechercher",
"home.search_entries": "Recherche",
"home.thumbnail_size": "Taille de la miniature",
"landing.open_button": "Ouvrir/Créer une Bibliothèque %{open_shortcut_text}",
"library.Artist": "Artiste",
"library.anthology": "Anthologie",
"library.archived": "Date d'Archivage",
"library.author": "Auteur",
"library.book": "Livre",
"library.collation": "Collage",
"library.comic": "Bande Dessinée",
"library.comments": "Commentaires",
"library.composer": "Compositeur",
"library.content_tags": "Labels de Contenu",
"library.date": "Date",
"library.date_created": "Date de Création",
"library.date_modified": "Date de Modification",
"library.date_published": "Date de Publication",
"library.date_released": "Date de Sortie",
"library.date_taken": "Date de Capture",
"library.date_uploaded": "Date de Mise en Ligne",
"library.description": "Description",
"library.favorite": "Favori",
"library.guest_artist": "Artiste Invité",
"library.magazine": "Magazine",
"library.manga": "Manga",
"library.meta_tags": "Label Meta",
"library.notes": "Notes",
"library.publisher": "Éditeur",
"library.series": "Séries",
"library.source": "Source",
"library.tags": "Labels",
"library.title": "Titre",
"library.url": "URL",
"library.volume": "Volume",
"menu.edit": "Édition",
"menu.file": "Fichier",
"menu.help": "Aide",
"menu.macros": "Macros",
"menu.tools": "Outils",
"menu.window": "Fenêtre",
"merge.merge_dupe_entries": "Fusionner les duplicatas",
"merge.window_title": "Fusionner les duplicatas",
"mirror_entities.are_you_sure": "Êtes-vous sûr de vouloir répliquer les %{len(self.lib.dupe_files)} Entrées suivantes?",
"mirror_entities.label": "Réplication de 1/%{count} Entrées...",
"mirror_entities.title": "Réplication des Entrées",
"open_library.library_creation_return_code": "Code de Retour de la Création de bibliothèque:",
"open_library.no_tagstudio_library_found": "Aucune bibliothèque TagStudio trouvé au chemin '%{path}'. Création d'une bibliothèque.",
"open_library.title": "Bibliothèque",
"preview.dimensions": "Dimensions",
"preview.recent": "Bibliothèques Récentes",
"preview_panel.confirm_remove": "Êtes-vous sûr de vouloir supprimer le champ \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"preview_panel.edit_name": "Éditer",
"preview_panel.missing_location": "Emplacement Manquant",
"preview_panel.mixed_data": "Données Mélangées",
"preview_panel.no_items_selected": "Pas d'Objet Selectionné",
"preview_panel.unknown_field_type": "Type de Champ Inconnu",
"preview_panel.update_widgets": "[PANEL D'ENTRÉE] MISE À JOUR DES WIDGETS (%{self.driver.selected})",
"progression.running_macros.new_entries": "Exécution des Macros sur les Nouvelles Entrées",
"progression.running_macros.one_new_entry": "Éxecution des Macros Configurées sur 1/%{len(new_ids)} Nouvelles Entrées",
"progression.running_macros.several_new_entry": "Éxectution des Macros Configurées sur %{x + 1}/%{len(new_ids)} Nouvelles Entrées",
"relink_unlinked.attempt_relink": "Tentative de Reliage de %{x[0]+1}/%{len(self.lib.missing_files)} Entrées, %{self.fixed} ont été Reliées avec Succès",
"relink_unlinked.title": "Reliage des Entrées",
"splash.open_library": "Ouverture de la Bilbliothèque",
"status.backup_success": "Bibliothèque sauvegardée au chemin  :",
"status.enumerate_query": "Requête:%{query}, Image: %{i}, Longueur: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Résultats Trouvés pour \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "Résultats",
"status.save_success": "Bibliothèque Sauvegardée et Fermée!",
"status.search_library_query": "Recherche dans la Bibliothèque pour",
"tag.add": "Ajouter un Label",
"tag.add_search": "Ajouter à la Recherche",
"tag.library": "Labels de la Bibliothèque",
"tag.new": "Nouveau Label",
"tag.search_for_tag": "Recherche de Label",
"text_line_edit.unknown_event_type": "Type d'évenement inconnu : %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "Új mező",
"build_tags.add_parent_tags": "Új szülőcímke",
"build_tags.parent_tags": "Szülőcímkék",
"delete_unlinked.confirm": "Biztosan törölni akarja az alábbi %{len(self.lib.missing_files)} elemet?",
"delete_unlinked.delete_entries": "Elemek törlése",
"delete_unlinked.delete_unlinked": "Kapcsolat nélküli elemek törlése",
"delete_unlinked.deleting_number_entries": "{len(self.lib.missing_files)}/%{x[0]+1}. kapcsolat nélküli elem törlése",
"dialog.open_create_library": "Könyvtár megnyitása/létrehozása",
"dialog.refresh_directories": "Mappák frissítése",
"dialog.save_library": "Könyvtár mentése",
"dialog.scan_directories": "Új fájlok keresése a mappákban…\nElőkészítés…",
"dialog.scan_directories.new_files": "Új fájlok keresése a mappákban…\n%{x + 1} fájl megvizsgálva; ebből %{len(self.lib.files_not_in_library)} új",
"file_extension.add_extension": "Kiterjesztés hozzáadása",
"file_extension.list_mode": "Listázott elemek módja:",
"file_opener.command_not_found": "A(z) „%{command_name}”-parancs nem szerepel a rendszer PATH-változójában.",
"file_opener.not_found": "Az alábbi fájl nem található:",
"file_opener.open_file": "Fájl megnyitása:",
"fix_dupes.advice_label": "A tükrözés befejezése után a DupeGuruval kitörölheti a nem kívánt fájlokat. Ezt követően, a TagStudio „Kapcsolat nélküli elemek javítása” funkciójával eltávolíthatja az árván maradt elemeket.",
"fix_dupes.fix_dupes": "Egyező fájlok egyesítése",
"fix_dupes.load_file": "DupeGuru fájl betöltése",
"fix_dupes.mirror_description": "Az összes adat átmásolása minden összetartozó fájl között, ezzel kiegészítve a hiányzó címkéket eltávolítás és duplikálás nélkül. Ez a folyamat nem fog adatokat vagy fájlokat törölni.",
"fix_dupes.mirror_entries": "Elemek tükrözése",
"fix_dupes.name_filter": "DupeGuru-fájlok (*.dupeguru)",
"fix_dupes.no_file_match": "Nincsenek egyező fájlok",
"fix_dupes.no_file_selected": "Nincs kiválasztott DupeGuru-fájl.",
"fix_dupes.number_file_match": "%{count} egyező fájl",
"fix_dupes.open_result_files": "DupeGuru-fájl megnyitása",
"fix_unlinked.delete_unlinked": "Kapcsolat nélküli elemek törlése",
"fix_unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül kerül áthelyezésre vagy törésre, akkor ez a kapcsolat megszakad. Ezeket a kapcsolat nélküli elemeket a program megpróbálhatja automatikusan megkeresni, de Ön is kézileg újra összekapcsolhatja vagy törölheti őket.",
"fix_unlinked.duplicate_description": "Ha több elem ugyanazzal a fájllal van összekapcsolva, akkor egyezőnek számítanak. Ha egyesíti őket, akkor egy olyan elem lesz létrehozva, ami az eredeti elemek összes adatát tartalmazza. Ezeket nem szabad összetéveszteni az „egyező fájlokkal”, amelyek a TagStudión kívüli azonos tartalmú fájlok.",
"fix_unlinked.fix_unlinked": "Kapcsolat nélküli elemek javítása",
"fix_unlinked.manual_relink": "Újra összekapcsolás kézileg",
"fix_unlinked.merge_dupes": "Egyező elemek egyesítése",
"fix_unlinked.refresh_dupes": "Egyező elemek frissítése",
"fix_unlinked.scan_library.label": "Kapcsolat nélküli elemek keresése a könyvtárban…",
"fix_unlinked.scan_library.title": "Könyvtár vizsgálata",
"fix_unlinked.search_and_relink": "Keresés és újra összekapcsolás",
"folders_to_tags.description": "Címkék automatikus létrehozása a létező mappastruktúra alapján.\nAz alábbi mappafán megtekintheti a létrehozandó címkéket, és hogy mely elemekre lesznek alkalmazva.",
"folders_to_tags.folders_to_tags": "Mappák címkékké alakítása",
"folders_to_tags.title": "Címkék létrehozása mappák alapján",
"generic.add": "Hozzáadás",
"generic.aliases": "Áljelek",
"generic.apply": "Alkalmaz",
"generic.cancel": "Mégse",
"generic.close_all": "Összes bezárása",
"generic.color": "Szín",
"generic.delete": "Törlés",
"generic.done": "Kész",
"generic.exclude": "Elrejtés",
"generic.file_extension": "Kiterjesztések",
"generic.include": "Mutatás",
"generic.mirror": "Tükrözés",
"generic.name": "Név",
"generic.open_all": "Összes megnyitása",
"generic.open_file": "Fájl megnyitása",
"generic.open_file_explorer": "Fájl megnyitása Intézőben",
"generic.refresh_all": "Összes frissítése",
"generic.remove_field": "Mező eltávolítása",
"generic.search_tags": "Címkék keresése",
"generic.shorthand": "Rövidítés",
"home.base_title": "TagStudio Alfa",
"home.include_all_tags": "És (az összes keresett címkét tartalmazza)",
"home.include_any_tag": "Vagy (valamelyik keresett címkét tartalmazza)",
"home.main_window": "Fő ablak",
"home.search": "Keresés",
"home.search_entries": "Tételek keresése",
"home.thumbnail_size": "Indexkép mérete",
"landing.open_button": "Könyvtár megnyitása/létrehozása %{open_shortcut_text}",
"library.Artist": "Előadó",
"library.anthology": "Gyűjtemény",
"library.archived": "Archiválás dátuma",
"library.author": "Szerző",
"library.book": "Könyv",
"library.collation": "Rendezés",
"library.comic": "Képregény",
"library.comments": "Megjegyzés",
"library.composer": "Zeneszerző",
"library.content_tags": "Tartalomcímkék",
"library.date": "Dátum",
"library.date_created": "Létrehozás dátuma",
"library.date_modified": "Módosítás dátuma",
"library.date_published": "Közzététel dátuma",
"library.date_released": "Kiadás dátuma",
"library.date_taken": "Készítés dátuma",
"library.date_uploaded": "Feltöltés dátuma",
"library.description": "Leírás",
"library.favorite": "Kedvenc",
"library.guest_artist": "Társelőadó",
"library.magazine": "Magazin",
"library.manga": "Manga",
"library.meta_tags": "Metacímkék",
"library.notes": "Jegyzetek",
"library.publisher": "Kiadó",
"library.series": "Sorozat",
"library.source": "Forrás",
"library.tags": "Címkék",
"library.title": "Cím",
"library.url": "Hivatkozás",
"library.volume": "Kötet",
"menu.edit": "Szerkesztés",
"menu.file": "Fájl",
"menu.help": "Súgó",
"menu.macros": "Makrók",
"menu.tools": "Eszközök",
"menu.window": "Ablak",
"merge.merge_dupe_entries": "Egyező elemek egyesítése",
"merge.window_title": "Egyező elemek egyesítése",
"mirror_entities.are_you_sure": "Biztosan tükrözni akarja az alábbi adatokat %{len(self.lib.dupe_files)} különböző elemre?",
"mirror_entities.label": "%{count}/1 elem tükrözése folyamatban…",
"mirror_entities.title": "Elemek tükrözése",
"open_library.library_creation_return_code": "A könyvtárlétrehozási folyamat visszatérési kódja:",
"open_library.no_tagstudio_library_found": "A program nem talált létező TagStudio-könyvtárat az alábbi elérési úton: „%{path}”. Ezen a helyen egy új könyvtár lesz létrehozva.",
"open_library.title": "Könyvtár",
"preview.dimensions": "Méret",
"preview.recent": "Legutóbbi könytárak",
"preview_panel.confirm_remove": "Biztosan el akarja távolítani a(z) „%{self.lib.get_field_attr(field, \"name\")}”-mezőt?",
"preview_panel.edit_name": "Szerkesztés",
"preview_panel.missing_location": "Hiányzó hely",
"preview_panel.mixed_data": "Kevert adatok",
"preview_panel.no_items_selected": "Nincs kijelölt elem",
"preview_panel.unknown_field_type": "Ismeretlen mezőtípus",
"preview_panel.update_widgets": "[ENTRY PANEL] UPDATE WIDGETS (%{self.driver.selected})",
"progression.running_macros.new_entries": "Makrók futtatása az új elemeken",
"progression.running_macros.one_new_entry": "Korábban beállított makrók futtatása %{len(new_ids)}/1 új elemen",
"progression.running_macros.several_new_entry": "Korábban beállított makrók futtatása %{len(new_ids)}/%{x + 1} új elemen",
"relink_unlinked.attempt_relink": "%{len(self.lib.missing_files)}/%{x[0]+1} elem újra összekapcsolásának megkísérlése; %{self.fixed} elem sikeresen újra összekapcsolva",
"relink_unlinked.title": "Elemek újra összekapcsolása",
"splash.open_library": "Könyvtár megnyitása",
"status.backup_success": "A biztonsági mentés létrehozása megtörtént az alábbi elérési úton:",
"status.enumerate_query": "Keresési kifejezés: %{query}; %{i}. képokcka; Hossz: %{len(f)}",
"status.number_results_found": "%{len(all_items)} találat az alábbi kifejezésre: „%{query}” (%{format_timespan(end_time - start_time)})",
"status.results_found": "találat",
"status.save_success": "A könyvtár mentése és bezárása sikeresen megtörtént.",
"status.search_library_query": "Az alábbi kifejezés keresése a könyvtárban:",
"tag.add": "Címke hozzáadása",
"tag.add_search": "Keresési kifejezés kiegészítése",
"tag.library": "Könyvtárcímkék",
"tag.new": "Új címke",
"tag.search_for_tag": "Címke keresése",
"text_line_edit.unknown_event_type": "Ismeretlen eseménytípus: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+19
View File
@@ -0,0 +1,19 @@
{
"generic.add": "Aggiungi",
"generic.cancel": "Annulla",
"generic.color": "Colore",
"generic.delete": "Elimina",
"generic.name": "Nome",
"home.base_title": "TagStudio Alfa",
"home.main_window": "Finestra principale",
"home.search": "Cerca",
"library.Artist": "Artista",
"library.book": "Libro",
"library.description": "Descrizione",
"library.title": "Titolo",
"menu.file": "File",
"menu.window": "Finestra",
"preview.recent": "Librerias Recenti",
"tag.add": "Aggiungi Tag",
"tag.new": "Nuovo Tag"
}
+102
View File
@@ -0,0 +1,102 @@
{
"add_field.add": "Legg til felt",
"delete_unlinked.confirm": "Slett følgende %{len(self.lib.missing_files)} oppføringer?",
"delete_unlinked.delete_entries": "Sletting av oppføringer",
"delete_unlinked.deleting_number_entries": "Sletter %{x[0]+1}/{len(self.lib.missing_files)} ulenkede oppføringer",
"dialog.open_create_library": "Åpne/opprett bibliotek",
"dialog.save_library": "Lagre bibliotek",
"file_extension.add_extension": "Legg til utvidelse",
"file_extension.list_mode": "Listemodus:",
"file_opener.not_found": "Fant ikke filen:",
"file_opener.open_file": "Åpner fil :}",
"fix_dupes.fix_dupes": "Fiks duplikatfiler",
"fix_dupes.load_file": "Last inn DupeGuru-fil",
"fix_dupes.mirror_entries": "Speil oppføringer",
"fix_dupes.name_filter": "DupeGuru-filer (*.dupeguru)",
"fix_dupes.no_file_selected": "Ingen DupeGuru-fil valgt",
"fix_dupes.open_result_files": "Åpne DupeGuru-resultatfil",
"fix_unlinked.fix_unlinked": "Fiks ulenkede oppføringer",
"fix_unlinked.scan_library.label": "Skanner bibliotek for ulenkede oppføringer …",
"fix_unlinked.scan_library.title": "Skanning av bibliotek",
"folders_to_tags.folders_to_tags": "Konverterer mapper til etiketter",
"folders_to_tags.title": "Opprett etiketter fra mapper",
"generic.add": "Legg til",
"generic.apply": "Bruk",
"generic.cancel": "Avbryt",
"generic.close_all": "Lukk alle",
"generic.color": "Farge",
"generic.delete": "Slett",
"generic.done": "Ferdig",
"generic.exclude": "Utelat",
"generic.file_extension": "Filutvidelse",
"generic.include": "Inkluder",
"generic.mirror": "Speil",
"generic.name": "Navn",
"generic.open_all": "Åpne alle",
"generic.open_file": "Åpne fil",
"generic.open_file_explorer": "Åpne fil i utforsker",
"generic.refresh_all": "Gjenoppfrisk alle",
"generic.remove_field": "Fjern felt",
"generic.search_tags": "Søk etter etiketter",
"home.main_window": "Hovedvindu",
"home.search": "Søk",
"home.search_entries": "Søk etter oppføringer",
"home.thumbnail_size": "Miniatyrbildestørrelse",
"landing.open_button": "Åpne/opprett bibliotek %{open_shortcut_text}",
"library.Artist": "Artist",
"library.archived": "Arkiveringsdato",
"library.author": "Forfatter",
"library.book": "Bok",
"library.comic": "Tegneserie",
"library.comments": "Kommentarer",
"library.content_tags": "Innholdsetiketter",
"library.date": "Dato",
"library.date_created": "Dato opprettet",
"library.date_modified": "Endringsdato",
"library.date_published": "Publiseringsdato",
"library.date_released": "Slippdato",
"library.date_taken": "Dato knipset",
"library.date_uploaded": "Opplastingsdato",
"library.description": "Beskrivelse",
"library.favorite": "Favoritt",
"library.guest_artist": "Gjesteartist",
"library.magazine": "Magasin",
"library.manga": "Manga",
"library.meta_tags": "Metaetiketter",
"library.notes": "Notater",
"library.publisher": "Utgiver",
"library.series": "Serie",
"library.source": "Kilde",
"library.tags": "Etiketter",
"library.url": "Nettadresse",
"menu.edit": "Rediger",
"menu.file": "Fil",
"menu.help": "Hjelp",
"menu.macros": "Makroer",
"menu.tools": "Verktøy",
"menu.window": "Vindu",
"merge.merge_dupe_entries": "Fletter duplikatoppføringer …",
"merge.window_title": "Fletter duplikatoppføringer …",
"open_library.title": "Bibliotek",
"preview.dimensions": "Dimensjoner",
"preview.recent": "Nylige bibliotek",
"preview_panel.confirm_remove": "Fjern dette «%{self.lib.get_field_attr(field, \"name\")}»-feltet?",
"preview_panel.edit_name": "Rediger",
"preview_panel.missing_location": "Posisjon mangler",
"preview_panel.mixed_data": "Blandet data",
"preview_panel.no_items_selected": "Ingen elementer valgt",
"preview_panel.unknown_field_type": "Ukjent felttype",
"splash.open_library": "Åpner bibliotek …",
"status.backup_success": "Kopi av bibliotek lagret i:",
"status.results_found": "Resultat",
"status.save_success": "Bibliotek lagret og lukket.",
"status.search_library_query": "Søker i biblioteket etter",
"tag.add": "Legg til etikett",
"tag.add_search": "Legg til søk",
"tag.library": "Biblioteksetiketter",
"tag.new": "Ny etikett",
"tag.search_for_tag": "Søk etter etikett",
"text_line_edit.unknown_event_type": "ukjent begivenhetstype: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+1
View File
@@ -0,0 +1 @@
{}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "Adicionar Campo",
"build_tags.add_parent_tags": "Adicionar Rótulo Pai",
"build_tags.parent_tags": "Rótulos Pai",
"delete_unlinked.confirm": "Tem certeza que deseja deletar as seguintes %{len(self.lib.missing_files)} entradas?",
"delete_unlinked.delete_entries": "Deletando Entradas",
"delete_unlinked.delete_unlinked": "Deletar Entradas Não Linkada",
"delete_unlinked.deleting_number_entries": "Deletando %{x[0]+1}/{len(self.lib.missing_files)} Entradas Não Linkadas",
"dialog.open_create_library": "Abrir/Criar Biblioteca",
"dialog.refresh_directories": "Atualizando Diretórios",
"dialog.save_library": "Salvar Biblioteca",
"dialog.scan_directories": "Escaneando Diretórios por Novos Arquivos...\nPreparando...",
"dialog.scan_directories.new_files": "Escaneando Diretórios por Novos Arquivos...\n%{x + 1} Arquivo%{\"s\" if x + 1 != 1 else \"\"} Pesquisado%{\"s\" if x + 1 != 1 else \"\"}, %{len(self.lib.files_not_in_library)} Novo(s) Arquivo(s) Encontrado(s)",
"file_extension.add_extension": "Adicionar Extensão",
"file_extension.list_mode": "Modo de Lista:",
"file_opener.command_not_found": "Não foi possível encontrar %{command_name} na PATH do sistema",
"file_opener.not_found": "Arquivo não encontrado:",
"file_opener.open_file": "Abrindo Arquivo:",
"fix_dupes.advice_label": "Após espelhagem, você estará livre para usar DupeGuru para deletar arquivos indesejados. Após, use a função \"Consertar Entradas Não Linkadas\" do TagStudio no menu de Ferramentas para deletar entradas não linkadas.",
"fix_dupes.fix_dupes": "Corrigir Arquivos Duplicados",
"fix_dupes.load_file": "Carregar Aquivo DupeGuru",
"fix_dupes.mirror_description": "Espelhe os ados de entrada em cada conjunto de correspondência duplicado, combinando todos os dados sem remover ou duplicar campos. Esta operação não excluirá nenhum arquivo ou dado.",
"fix_dupes.mirror_entries": "Entradas Espelhadas",
"fix_dupes.name_filter": "Arquivos DupeGuru (*.dupeguru)",
"fix_dupes.no_file_match": "Correspondências de Arquivos Duplicados: N/A",
"fix_dupes.no_file_selected": "Nenhum Arquivo DupeGuru Selecionado",
"fix_dupes.number_file_match": "Correspondências de Arquivos Duplicados: %{count}",
"fix_dupes.open_result_files": "Abrir Arquivo de Resultados do DupeGuru",
"fix_unlinked.delete_unlinked": "Escluir Entradas Não Linkadas",
"fix_unlinked.description": "Cada entrada na biblioteca está linkada a um arquivo em um dos seus diretórios. Se um arquivo linkado a uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não linkado. Entradas não linkadas podem ser automaticamente re-linkadas por buscas nos seus diretórios, manualmente re-linkadas pelo usuário, ou deletada se for desejada.",
"fix_unlinked.duplicate_description": "Entradas duplicadas são definidas como multiplas entradas que levam ao mesmo arquivo no disco. Mergir essas entradas irá combinar as tags e metadados de todas as duplicatas em uma única entrada consolidada. Não confundir com \"Arquivos Duplicados\" que são duplicatas dos seus arquivos fora do TagStudio.",
"fix_unlinked.fix_unlinked": "Corrigir Entradas Não Linkadas",
"fix_unlinked.manual_relink": "Relink Manual",
"fix_unlinked.merge_dupes": "Mesclar Entradas Duplicadas",
"fix_unlinked.refresh_dupes": "Atualizar Entradas Duplicadas",
"fix_unlinked.scan_library.label": "Escaneando Bibliotecada para Entradas Não Linkadas...",
"fix_unlinked.scan_library.title": "Escaneando Biblioteca",
"fix_unlinked.search_and_relink": "Buscar && Relinkar",
"folders_to_tags.description": "Cria rótulos baseado na sua estrutura de arquivos e aplica elas nas suas entradas\nA estrutura abaixo mostra todos os rótulos que irão ser criados e a quais entradas eles serão aplicados.",
"folders_to_tags.folders_to_tags": "Convertendo pastas para Rótulos",
"folders_to_tags.title": "Criar rótulos a partir das pastas",
"generic.add": "Adicionar",
"generic.aliases": "Alias",
"generic.apply": "Aplicar",
"generic.cancel": "Cancelar",
"generic.close_all": "Fechar Tudo",
"generic.color": "Cor",
"generic.delete": "Deletar",
"generic.done": "Completo",
"generic.exclude": "Excluir",
"generic.file_extension": "Extensões de Arquivo",
"generic.include": "Incluir",
"generic.mirror": "Espelho",
"generic.name": "Nome",
"generic.open_all": "Abrir Tudo",
"generic.open_file": "Abrir arquivo",
"generic.open_file_explorer": "Abrir no explorador de arquivos",
"generic.refresh_all": "Atualizar_Tudo",
"generic.remove_field": "Remover Campo",
"generic.search_tags": "Buscar Rótulos",
"generic.shorthand": "Taquigrafia",
"home.base_title": "TagStudio Alfa",
"home.include_all_tags": "E (Inclui todos os rótulos)",
"home.include_any_tag": "Ou (Inclui qualquer rótulo)",
"home.main_window": "Janela Principal",
"home.search": "Buscar",
"home.search_entries": "Buscar Entradas",
"home.thumbnail_size": "Tamanho de miniatura",
"landing.open_button": "Abrir/Criar Bliblioteca %{open_shortcut_text}",
"library.Artist": "Artista",
"library.anthology": "Coletânea",
"library.archived": "Data de Arquivação",
"library.author": "Autor",
"library.book": "Livro",
"library.collation": "Colagem",
"library.comic": "Quadrinho",
"library.comments": "Comentários",
"library.composer": "Compositor",
"library.content_tags": "Rótulos de conteúdo",
"library.date": "Data",
"library.date_created": "Data de Criação",
"library.date_modified": "Dada de Modificação",
"library.date_published": "Data de Publicação",
"library.date_released": "Data de Lançamento",
"library.date_taken": "Data de Criação",
"library.date_uploaded": "Data de Envio",
"library.description": "Descrição",
"library.favorite": "Favorito",
"library.guest_artist": "Artista Convidado",
"library.magazine": "Revista",
"library.manga": "Manga",
"library.meta_tags": "Meta Rótulos",
"library.notes": "Notas",
"library.publisher": "Editora",
"library.series": "Séries",
"library.source": "Fonte",
"library.tags": "Rótulos",
"library.title": "Título",
"library.url": "URL",
"library.volume": "Volume",
"menu.edit": "Editar",
"menu.file": "Arquivo",
"menu.help": "Ajuda",
"menu.macros": "Macros",
"menu.tools": "Ferramentas",
"menu.window": "Janela",
"merge.merge_dupe_entries": "Mesclando Entradas Duplicadas",
"merge.window_title": "Mesclando Entradas Duplicadas",
"mirror_entities.are_you_sure": "Tem certeza que você deseja espelhar os seguintes %{len(self.lib.dupe_files)} entradas?",
"mirror_entities.label": "Espelhando 1/%{count} Entradas...",
"mirror_entities.title": "Espelhando Entradas",
"open_library.library_creation_return_code": "Código de Retorno da Criação da Biblioteca:",
"open_library.no_tagstudio_library_found": "Nenhuma biblioteca do TagStudio existente foi encontrada em '%{path}'. Criando uma.",
"open_library.title": "Biblioteca",
"preview.dimensions": "Dimensões",
"preview.recent": "Bibliotecas recentes",
"preview_panel.confirm_remove": "Você tem certeza de que quer remover o campo \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"preview_panel.edit_name": "Editar",
"preview_panel.missing_location": "Localização Ausente",
"preview_panel.mixed_data": "Dados Mistos",
"preview_panel.no_items_selected": "Nenhum Item Selecionado",
"preview_panel.unknown_field_type": "Tipo de Campo Desconhecido",
"preview_panel.update_widgets": "[ENTRY PANEL] ATULIZAR WIDGETS (%{self.driver.selected})",
"progression.running_macros.new_entries": "Executando Macros nas Novas Entradas",
"progression.running_macros.one_new_entry": "Executando Macros Configurados em 1/%{len(new_ids)} Novas Entradas",
"progression.running_macros.several_new_entry": "Executando Macros Configurados em %{x + 1}/%{len(new_ids)} Novas Entradas",
"relink_unlinked.attempt_relink": "Tentando Relinkar %{x[0]+1}/%{len(self.lib.missing_files)} Entradas, %{self.fixed} Relinkadas com Sucesso",
"relink_unlinked.title": "Relinkando Entradas",
"splash.open_library": "Abrindo Biblioteca",
"status.backup_success": "Backup da Biblioteca Salvo em:",
"status.enumerate_query": "Busca:%{query}, Quadro: %{i}, Tamanho: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Resultados Encontrados para \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "Resultados",
"status.save_success": "Biblioteca Salva e Fechada!",
"status.search_library_query": "Procurando na Biblioteca por",
"tag.add": "Adicionar Rótulo",
"tag.add_search": "Adicionar a Pesquisa",
"tag.library": "Rótulos da biblioteca",
"tag.new": "Novo Rótulo",
"tag.search_for_tag": "Procurar por Rótulo",
"text_line_edit.unknown_event_type": "tipo de evento desconhecido: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "Добавить Категорию",
"build_tags.add_parent_tags": "Добавить Теги-родители",
"build_tags.parent_tags": "Теги-родители",
"delete_unlinked.confirm": "Вы уверены в том, что желаете удалить %{len(self.lib.missing_files)} записи?",
"delete_unlinked.delete_entries": "Удаление записей",
"delete_unlinked.delete_unlinked": "Удалить откреплённые записи",
"delete_unlinked.deleting_number_entries": "Удалено %{x[0]+1}/{len(self.lib.missing_files)} откреплённых записей",
"dialog.open_create_library": "Открыть/Создать Библиотеку",
"dialog.refresh_directories": "Обновление Каталога",
"dialog.save_library": "Сохранить Библиотеку",
"dialog.scan_directories": "Сканирование каталога на наличие новых файлов...\nПодготовка...",
"dialog.scan_directories.new_files": "Сканирование на наличие новых файлов...\n%{x + 1} File%{\"s\" if x + 1 != 1 else \"\"} Searched, %{len(self.lib.files_not_in_library)} Найдены новые файлы",
"file_extension.add_extension": "Добавить расширение",
"file_extension.list_mode": "Список режимов:",
"file_opener.command_not_found": "Не смог найти %{command_name} в системе PATH",
"file_opener.not_found": "Файл не найден:",
"file_opener.open_file": "Открытие файла:}",
"fix_dupes.advice_label": "После отзеркаливания, вы можете использовать DupeGuru, чтобы удалить ненужные файлы. После этого, используйте функцию \"Исправить откреплённые записи\" внутри панели \"Инструменты\" TagStudio, чтобы удалить откреплённые записи.",
"fix_dupes.fix_dupes": "Исправить дубликаты",
"fix_dupes.load_file": "Загрузить файл DupeGuru",
"fix_dupes.mirror_description": "Отзеркалить данные записи внутри каждого указанного набора дубликатов, объединяя все данные внутри без удаления или дублирования категорий. Эта операция не удалит какие-либо файлы или данные.",
"fix_dupes.mirror_entries": "Отзеркалить записи",
"fix_dupes.name_filter": "Файлы DupeGuru (*.dupeguru)",
"fix_dupes.no_file_match": "Совпадение файлов дубликатов: N/A",
"fix_dupes.no_file_selected": "Файл DupeGuru не выбран",
"fix_dupes.number_file_match": "Совпадения файлов дубликатов: %{count}",
"fix_dupes.open_result_files": "Открыть файл результатов DupeGuru",
"fix_unlinked.delete_unlinked": "Удалить откреплённые записи",
"fix_unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\". Откреплённые записи могут быть прикреплены обратно через автоматический рескан, вручную прикреплены обратно пользователем, либо же удалены если в них нет надобности.",
"fix_unlinked.duplicate_description": "Записи-дубликаты это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с несколькими копиями самого файла, которые могут существовать вне TagStudio.",
"fix_unlinked.fix_unlinked": "Исправить откреплённые записи",
"fix_unlinked.manual_relink": "Ручная привязка",
"fix_unlinked.merge_dupes": "Объединить записи дубликаты",
"fix_unlinked.refresh_dupes": "Обновить записи дубликаты",
"fix_unlinked.scan_library.label": "Сканирование Библиотеки на наличие откреплённых записей...",
"fix_unlinked.scan_library.title": "Сканирование Библиотеки",
"fix_unlinked.search_and_relink": "Поиск и Привязка",
"folders_to_tags.description": "Создаёт теги для записей согласно имеющейся иерархии папок.\nВнизу указаны все теги, которые будут созданы, а также записи к которым они будут применены.",
"folders_to_tags.folders_to_tags": "Конвертировать папки в теги",
"folders_to_tags.title": "Создать теги из папок",
"generic.add": "Добавить",
"generic.aliases": "Псевдонимы",
"generic.apply": "Применить",
"generic.cancel": "Отмена",
"generic.close_all": "Закрыть Всё",
"generic.color": "Цвет",
"generic.delete": "Удалить",
"generic.done": "Завершено",
"generic.exclude": "Исключить",
"generic.file_extension": "Расширения Файлов",
"generic.include": "Включить",
"generic.mirror": "Отзеркалить",
"generic.name": "Имя",
"generic.open_all": "Открыть Всё",
"generic.open_file": "Открыть файл",
"generic.open_file_explorer": "Открыть файл в проводнике",
"generic.refresh_all": "Обновить Всё",
"generic.remove_field": "Удалить Категорию",
"generic.search_tags": "Поиск тегов",
"generic.shorthand": "Сокращённое название",
"home.base_title": "TagStudio Альфа Версия",
"home.include_all_tags": "И (Файл содержит все теги)",
"home.include_any_tag": "Или (Файл содержит любой из тегов)",
"home.main_window": "Основное Окно",
"home.search": "Поиск",
"home.search_entries": "Поисковые запросы",
"home.thumbnail_size": "Размер иконок",
"landing.open_button": "Открыть/Создать Библиотеку %{open_shortcut_text}",
"library.Artist": "Художник",
"library.anthology": "Антология",
"library.archived": "Дата архивации",
"library.author": "Автор",
"library.book": "Книги",
"library.collation": "Сопоставление",
"library.comic": "Комиксы",
"library.comments": "Комментарии",
"library.composer": "Композитор",
"library.content_tags": "Теги содержимого",
"library.date": "Дата",
"library.date_created": "Дата создания",
"library.date_modified": "Дата изменения",
"library.date_published": "Дата публикации",
"library.date_released": "Дата выпуска",
"library.date_taken": "Дата съёмки",
"library.date_uploaded": "Дата загрузки",
"library.description": "Описание",
"library.favorite": "Избранное",
"library.guest_artist": "Соавтор",
"library.magazine": "Журнал",
"library.manga": "Манга",
"library.meta_tags": "Мета Теги",
"library.notes": "Заметки",
"library.publisher": "Издатель",
"library.series": "Серии",
"library.source": "Источники",
"library.tags": "Теги",
"library.title": "Название",
"library.url": "URL",
"library.volume": "Том",
"menu.edit": "Редактировать",
"menu.file": "Файл",
"menu.help": "Помощь",
"menu.macros": "Макросы",
"menu.tools": "Инструменты",
"menu.window": "Окно",
"merge.merge_dupe_entries": "Объединить повторяющиеся записи",
"merge.window_title": "Объединить повторяющиеся записи",
"mirror_entities.are_you_sure": "Вы уверенны, что хотите отзеркалить следующие %{len(self.lib.dupe_files)} записи?",
"mirror_entities.label": "Отзеркаливание 1/%{count} записей...",
"mirror_entities.title": "Отзеркаливание записей",
"open_library.library_creation_return_code": "Возвратный код создания библиотеки:",
"open_library.no_tagstudio_library_found": "Существующая библиотека TagStudio не найдена внутри '%{path}'. Создаём новую.",
"open_library.title": "Библиотека",
"preview.dimensions": "Соотношения сторон",
"preview.recent": "Недавние библиотеки",
"preview_panel.confirm_remove": "Вы уверенны, что хотите удалить эту категорию \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"preview_panel.edit_name": "Редактировать",
"preview_panel.missing_location": "Путь не найден",
"preview_panel.mixed_data": "Смешанные данные",
"preview_panel.no_items_selected": "Ничего не выбрано",
"preview_panel.unknown_field_type": "Неизвестный тип категории",
"preview_panel.update_widgets": "[ENTRY PANEL] ОБНОВИТЬ ВИДЖЕТЫ (%{self.driver.selected})",
"progression.running_macros.new_entries": "Использование макросов на новых файлах",
"progression.running_macros.one_new_entry": "Запуск сконфигурированных макросов в 1/%{len(new_ids)} Новые файлы",
"progression.running_macros.several_new_entry": "Запуск сконфигурированных макросов в %{x + 1}/%{len(new_ids)} Новые файлы",
"relink_unlinked.attempt_relink": "Пытаемся вновь привязать %{x[0]+1}/%{len(self.lib.missing_files)} записей, %{self.fixed} Успешно привязано",
"relink_unlinked.title": "Привязка записей",
"splash.open_library": "Открытие Библиотеки",
"status.backup_success": "Резервная копия библиотеки сохранена по адресу:",
"status.enumerate_query": "Query:%{query}, Frame: %{i}, Length: %{len(f)}",
"status.number_results_found": "%{len(all_items)} Было найдено \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "Результаты",
"status.save_success": "Библиотека сохранена и закрыта!",
"status.search_library_query": "Поиск по библиотеке",
"tag.add": "Добавить тег",
"tag.add_search": "Добавить к поисковому запросу",
"tag.library": "Библиотека тегов",
"tag.new": "Новый тег",
"tag.search_for_tag": "Поиск тега",
"text_line_edit.unknown_event_type": "unknown event type: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+114
View File
@@ -0,0 +1,114 @@
{
"add_field.add": "Lägg till fält",
"build_tags.add_parent_tags": "Lägg till förälderetikett",
"build_tags.parent_tags": "Förälderetiketter",
"delete_unlinked.delete_entries": "Tar bort poster",
"dialog.open_create_library": "Öppna/skapa bibliotek",
"dialog.refresh_directories": "Uppdaterar kataloger",
"dialog.save_library": "Spara bibliotek",
"dialog.scan_directories": "Skannar kataloger efter nya filer...\nFörbereder...",
"file_extension.add_extension": "Lägg till tillägg",
"file_opener.not_found": "Kunde inte hitta filen:",
"file_opener.open_file": "Öppnar fil:}",
"fix_dupes.fix_dupes": "Fixa dubbla filer",
"fix_dupes.mirror_entries": "Spegla poster",
"fix_unlinked.delete_unlinked": "Ta bort olänkade poster",
"fix_unlinked.description": "Varje post i biblioteket är länkad till en fil i en av dina kataloger. Om en fil länkad till en post är flyttad eller borttagen utanför TagStudio blir den olänkad. Olänkade poster kan automatiskt bli omlänkade genom att söka genom dina kataloger, manuellt omlänkade av användaren eller tas bort om så önskas.",
"fix_unlinked.duplicate_description": "Dubbla poster är definierade som flera poster som pekar på samma fil på datorn. Genom att slå ihop dessa poster kommer deras etiketter och metadata från dubbletterna att kombineras till en post. Dessa ska inte förväxlas med \"dubbla filer\", som är dubbletter av dina filer utanför TagStudio.",
"fix_unlinked.fix_unlinked": "Fixa olänkade poster",
"fix_unlinked.manual_relink": "Länka om manuellt",
"fix_unlinked.merge_dupes": "Slå ihop dubbla poster",
"fix_unlinked.refresh_dupes": "Uppdatera dubbla poster",
"fix_unlinked.scan_library.label": "Skannar bibliotek efter olänkade poster...",
"fix_unlinked.scan_library.title": "Skannar bibliotek",
"fix_unlinked.search_and_relink": "Sök && Länka om",
"folders_to_tags.description": "Skapar etiketter baserat på din mappstruktur och tillämpar dem till dina poster.\nStrukturen nedan visar vilka etiketter som kommer skapas och vilka filer de kommer tillämpas på.",
"folders_to_tags.folders_to_tags": "Konverterar mappar till etiketter",
"folders_to_tags.title": "Skapa etiketter från mappar",
"generic.add": "Lägg till",
"generic.aliases": "Alias",
"generic.apply": "Tillämpa",
"generic.cancel": "Avbryt",
"generic.close_all": "Stäng alla",
"generic.color": "Färg",
"generic.delete": "Ta bort",
"generic.done": "Klar",
"generic.exclude": "Exkludera",
"generic.file_extension": "Filnamnstillägg",
"generic.include": "Inkludera",
"generic.mirror": "Spegla",
"generic.name": "Namn",
"generic.open_all": "Öppna alla",
"generic.open_file": "Öppna fil",
"generic.open_file_explorer": "Öppna fil i utforskaren",
"generic.refresh_all": "Uppdatera alla",
"generic.remove_field": "Ta bort fält",
"generic.search_tags": "Sök etikett",
"generic.shorthand": "Förkortning",
"home.base_title": "TagStudio Alfa",
"home.include_all_tags": "Och (Inkluderar alla etiketter)",
"home.include_any_tag": "Eller (Inkluderar alla etiketter)",
"home.main_window": "Huvudfönster",
"home.search": "Sök",
"home.search_entries": "Sök poster",
"home.thumbnail_size": "Miniatyrbildsstorlek",
"library.Artist": "Artist",
"library.anthology": "Antologi",
"library.archived": "Arkiveringsdatum",
"library.author": "Författare",
"library.book": "Bok",
"library.collation": "Kollation",
"library.comic": "Serietidning",
"library.comments": "Kommentarer",
"library.composer": "Kompositör",
"library.content_tags": "Innehållsetiketter",
"library.date": "Datum",
"library.date_created": "Skapad den",
"library.date_modified": "Senast ändrad",
"library.date_published": "Publiceringsdatum",
"library.date_released": "Utgivningsdatum",
"library.date_uploaded": "Uppladdningsdatum",
"library.description": "Beskrivning",
"library.favorite": "Favorit",
"library.guest_artist": "Gästartist",
"library.magazine": "Magasin",
"library.manga": "Manga",
"library.meta_tags": "Meta-etiketter",
"library.notes": "Anteckningar",
"library.publisher": "Utgivare",
"library.series": "Serie",
"library.source": "Källa",
"library.tags": "Etiketter",
"library.title": "Titel",
"library.url": "URL",
"library.volume": "Volym",
"menu.edit": "Redigera",
"menu.file": "Fil",
"menu.help": "Hjälp",
"menu.macros": "Makron",
"menu.tools": "Verktyg",
"menu.window": "Fönster",
"merge.merge_dupe_entries": "Slår ihop dubbla poster",
"merge.window_title": "Slår ihop dubbla poster",
"mirror_entities.title": "Speglar poster",
"open_library.title": "Bibliotek",
"preview.dimensions": "Dimensioner",
"preview.recent": "Senaste biblioteken",
"preview_panel.edit_name": "Redigera",
"preview_panel.missing_location": "Platsen saknas",
"preview_panel.no_items_selected": "Inga valda objekt",
"progression.running_macros.new_entries": "Kör makros på nya poster",
"relink_unlinked.title": "Länkar om poster",
"splash.open_library": "Öppnar bibliotek",
"status.backup_success": "Bibliotekets säkerhetskopia sparad i:",
"status.results_found": "Resultat",
"status.save_success": "Bibliotek sparat och stängt!",
"status.search_library_query": "Söker i biblioteket efter",
"tag.add": "Lägg till etikett",
"tag.add_search": "Lägg till i Sök",
"tag.library": "Biblioteksetiketter",
"tag.new": "Ny etikett",
"tag.search_for_tag": "Sök efter etikett",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "புலத்தைச் சேர்க்க",
"build_tags.add_parent_tags": "பெற்றோர் குறிச்சொற்களைச் சேர்க்க",
"build_tags.parent_tags": "பெற்றோர் குறிச்சொற்கள்",
"delete_unlinked.confirm": "பின்வரும் உள்ளீடுகளை நிச்சயமாக நீக்க விரும்புகிறீர்களா %{len(self.lib.missing_files)}?",
"delete_unlinked.delete_entries": "உள்ளீடுகள் நீக்கப்படுகிறது",
"delete_unlinked.delete_unlinked": "இணைக்கப்படாத உள்ளீடுகளை நீக்கு",
"delete_unlinked.deleting_number_entries": "%{x[0]+1}/{len(self.lib.missing_files)} இணைக்கப்படாத உள்ளீடுகள் நீக்கப்படுகிறது",
"dialog.open_create_library": "நூலகத்தைத் திற/உருவாக்கு",
"dialog.refresh_directories": "கோப்பகங்கள் புதுப்பிக்கப்படுகின்றன",
"dialog.save_library": "நூலகத்தைச் சேமி",
"dialog.scan_directories": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\nதயாராகிறது...",
"dialog.scan_directories.new_files": "புதிய கோப்புகளுக்கான அடைவுகள் சோதனை செய்யப்படுகின்றது...\n%{x + 1} கோப்பு%{\"s\" if x + 1 != 1 else \"\"} தேடப்பட்டது, %{len(self.lib.files_not_in_library)} புதிய கோப்புகள் கிடைத்தன",
"file_extension.add_extension": "நீட்டிப்பைச் சேர்க்க",
"file_extension.list_mode": "பட்டியல் முறை:",
"file_opener.command_not_found": "%{command_name} ஐ system PATH இல் கண்டுபிடிக்க முடியவில்லை",
"file_opener.not_found": "கோப்பு கிடைக்கவில்லை:",
"file_opener.open_file": "கோப்பைத் திறக்கிறது:}",
"fix_dupes.advice_label": "படிமம் முடிந்தவுடன், தேவையற்ற கோப்புகளை நீக்க DupeGuru ஐ பயன்படுத்தலாம். அதற்குப் பிறகு, இணைக்காத நுழைவுகளை நீக்க 'டாக் ஸ்டுடியோ' வின் 'இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்' அம்சத்தைக் கருவிகள் பட்டியில் பயன்படுத்தவும்.",
"fix_dupes.fix_dupes": "நகல் கோப்புகளைச் சரிசெய்",
"fix_dupes.load_file": "DupeGuru கோப்பை ஏற்றவும்",
"fix_dupes.mirror_description": "ஒவ்வொரு மறுநுழைவு பொருத்தத் தொகுப்பிலும் நுழைவு தரவுகளைப் பிரதிபலிக்கவும், அனைத்து தரவுகளையும் இணைக்கவும், எந்தத் தகவல்களையும் நீக்காமலும் மறு செய்யாமலும். இந்தச் செயலில் எந்தக் கோப்புகள் அல்லது தரவுகளும் நீக்கப்பட மாட்டாது.",
"fix_dupes.mirror_entries": "படிம நுழைவுகள்",
"fix_dupes.name_filter": "DupeGuru கோப்புகள் (*.dupeguru)",
"fix_dupes.no_file_match": "நகல் கோப்பு பொருத்தங்கள்: ஒன்றும் இல்லை",
"fix_dupes.no_file_selected": "DupeGuru கோப்பு எதுவும் தேர்ந்தெடுக்கப்படவில்லை",
"fix_dupes.number_file_match": "நகல் கோப்பு பொருத்தங்கள்: %{count}",
"fix_dupes.open_result_files": "DupeGuru முடிவுகள் கோப்பைத் திறக்க",
"fix_unlinked.delete_unlinked": "இணைக்கப்படாத உள்ளீடுகளை நீக்கு",
"fix_unlinked.description": "ஒவ்வொரு புத்தககல்லரி நுழைவும் உங்கள் அடைவுகளில் உள்ள ஒரு கோப்புடன் இணைக்கப்பட்டுள்ளது. டாக் ஸ்டுடியோ-வைத் தவிர கோப்புகள் நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அவை இணைக்கப்படாதவையாகக் கருதப்படும். இணைக்கப்படாத நுழைவுகளை உங்கள் அடைவுகளில் தேடுவதன் மூலம் தானாகவே மீண்டும் இணைக்கலாம், பயனர் கைமுறையாக இணைக்கலாம் அல்லது விருப்பப்படி நீக்கலாம்.",
"fix_unlinked.duplicate_description": "மறுநுழைவுகள் என்பது, ஒரே கோப்பை குறிக்கும் பல நுழைவுகளை குறிக்கும். இவற்றை இணைப்பதால், அனைத்து மறுநுழைவுகளின் குறிச்சொற்களும் மெட்டாடேட்டாவும் ஒரே ஒட்டுமொத்த நுழைவாகச் சேர்க்கப்படும். இவற்றை 'மறுகோப்புகள்' என்பதுடன் குழப்பக் கூடாது, ஏனெனில் அவை டாக் ஸ்டுடியோவுக்கு வெளியேயுள்ள கோப்புகளின் நகல்களாகும்.",
"fix_unlinked.fix_unlinked": "இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்யவும்",
"fix_unlinked.manual_relink": "கைமுறை மறு இணைப்பு",
"fix_unlinked.merge_dupes": "நகல் உள்ளீடுகளை ஒன்றிணை",
"fix_unlinked.refresh_dupes": "நகல் உள்ளீடுகளைப் புதுப்பி",
"fix_unlinked.scan_library.label": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...",
"fix_unlinked.scan_library.title": "புத்தககல்லரி சோதனை செய்யப்படுகிறது",
"fix_unlinked.search_and_relink": "தேடல் && மீண்டும் இணை",
"folders_to_tags.description": "உங்கள் அடைவு கட்டமைப்பின் அடிப்படையில் குறிச்சொற்களை உருவாக்கி, அவற்றை உங்கள் நுழைவுகளில் பயன்படுத்துகிறது.\nகீழே காணப்படும் கட்டமைப்பானது உருவாக்கப்படும் அனைத்து குறிச்சொற்களையும், அவை எந்த நுழைவுகளில் பயன்படுத்தப்படும் என்பதையும் காட்டுகிறது.",
"folders_to_tags.folders_to_tags": "கோப்புறைகளை குறிச்சொற்களாக மாற்றப்படுகிறது",
"folders_to_tags.title": "கோப்புறைகளிலிருந்து குறிச்சொற்களை உருவாக்கு",
"generic.add": "சேர்",
"generic.aliases": "மாற்றுப்பெயர்கள்",
"generic.apply": "விண்ணப்பிக்க",
"generic.cancel": "ரத்து செய்",
"generic.close_all": "அனைத்தையும் மூடு",
"generic.color": "நிறம்",
"generic.delete": "நீக்கு",
"generic.done": "முடிந்தது",
"generic.exclude": "தவிர்",
"generic.file_extension": "கோப்பு நீட்டிப்புகள்",
"generic.include": "உள்ளடக்கு",
"generic.mirror": "படிமம்",
"generic.name": "பெயர்",
"generic.open_all": "அனைத்தையும் திற",
"generic.open_file": "கோப்பைத் திறக்கவும்",
"generic.open_file_explorer": "Explorer இல் கோப்பைத் திறக்கவும்",
"generic.refresh_all": "அனைத்தையும் புதுப்பி",
"generic.remove_field": "புலத்தை அகற்று",
"generic.search_tags": "குறிச்சொற்களைத் தேடு",
"generic.shorthand": "சுருக்கெழுத்து",
"home.base_title": "TagStudio ஆல்பா",
"home.include_all_tags": "மற்றும் (அனைத்து குறிச்சொற்களையும் உள்ளடக்கியது)",
"home.include_any_tag": "அல்லது (எந்தக் குறிச்சொல்லையும் உள்ளடக்கியது)",
"home.main_window": "பிரதான சாளரம்",
"home.search": "தேடு",
"home.search_entries": "தேடல் உள்ளீடுகள்",
"home.thumbnail_size": "சின்னப்பட அளவு",
"landing.open_button": "நூலகத்தைத் திற/உருவாக்கு %{open_shortcut_text}",
"library.Artist": "கலைஞர்",
"library.anthology": "தொகுப்பியல்",
"library.archived": "காப்பகப்படுத்தப்பட்ட தேதி",
"library.author": "ஆக்கியோன்",
"library.book": "புத்தகம்",
"library.collation": "தொகுத்தல்",
"library.comic": "நகைச்சுவை",
"library.comments": "கருத்துகள்",
"library.composer": "இசையமைப்பாளர்",
"library.content_tags": "உள்ளடக்கக் குறிச்சொற்கள்",
"library.date": "தேதி",
"library.date_created": "உருவாக்கப்பட்ட தேதி",
"library.date_modified": "மாற்றப்பட்ட தேதி",
"library.date_published": "வெளியிடப்பட்ட தேதி",
"library.date_released": "வெளியான தேதி",
"library.date_taken": "எடுக்கப்பட்ட தேதி",
"library.date_uploaded": "பதிவேற்றிய தேதி",
"library.description": "விளக்கம்",
"library.favorite": "பிடித்தது",
"library.guest_artist": "விருந்தினர் கலைஞர்",
"library.magazine": "இதழ்",
"library.manga": "மங்கா",
"library.meta_tags": "மெட்டா குறிச்சொற்கள்",
"library.notes": "குறிப்புகள்",
"library.publisher": "பதிப்பாளர்",
"library.series": "தொடர்",
"library.source": "ஆதாரம்",
"library.tags": "குறிச்சொற்கள்",
"library.title": "தலைப்பு",
"library.url": "இணைய முகவரி",
"library.volume": "தொகுப்பு",
"menu.edit": "திருத்து",
"menu.file": "கோப்பு",
"menu.help": "உதவி",
"menu.macros": "செயல்முறை",
"menu.tools": "கருவிகள்",
"menu.window": "சாளரம்",
"merge.merge_dupe_entries": "மறுநுழைவுகளை ஒன்றுசேர்த்தல்",
"merge.window_title": "மறுநுழைவுகளை ஒன்றுசேர்த்தல்",
"mirror_entities.are_you_sure": "பின்வரும் உள்ளீடுகளைப் பிரதிபலிக்க விரும்புகிறீர்களா %{len(self.lib.dupe_files)}?",
"mirror_entities.label": "1/%{count} உள்ளீடுகளைப் பிரதிபலிக்கப்படுகின்றது...",
"mirror_entities.title": "உள்ளீடுகள் பிரதிபழிக்கப்படுகின்றது",
"open_library.library_creation_return_code": "நூலக உருவாக்கம் திரும்பக் குறியீடு:",
"open_library.no_tagstudio_library_found": "'%{path}' இல் ஏற்கனவே உள்ள டாக் ஸ்டுடியோ புத்தககல்லரி காணப்படவில்லை. ஒன்று உருவாக்கப்படுகிறது.",
"open_library.title": "நூலகம்",
"preview.dimensions": "பரிமாணங்கள்",
"preview.recent": "சமீபத்திய நூலகங்கள்",
"preview_panel.confirm_remove": "இந்த \"%{self.lib.get_field_attr(field, \"name\")}\" புலத்தை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?",
"preview_panel.edit_name": "திருத்து",
"preview_panel.missing_location": "இடம் காணவில்லை",
"preview_panel.mixed_data": "கலப்பு தரவு",
"preview_panel.no_items_selected": "உருப்படிகள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை",
"preview_panel.unknown_field_type": "அறியப்படாத புல வகை",
"preview_panel.update_widgets": "[நுழைவு குழு] விட்ஜெட்டுகளை புதுப்பிக்கவும் (%{self.driver.selected})",
"progression.running_macros.new_entries": "புதிய நுழைவுகளில் செயல்முறைகளை இயக்கப்படுகின்றது",
"progression.running_macros.one_new_entry": "1/ இல் கட்டமைக்கப்பட்ட செயல்முறைகளை இயக்கப்படுகிறது %{len(new_ids)} புதிய பதிவுகள்",
"progression.running_macros.several_new_entry": "%{x + 1} இல் கட்டமைக்கப்பட்ட செயல்முறைகளை இயக்கப்படுகிறது / %{len(new_ids)} புதிய பதிவுகள்",
"relink_unlinked.attempt_relink": "%{x[0]+1}/%{len(self.lib.missing_files)} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, %{self.fixed} மீண்டும் இணைக்கப்பட்டது",
"relink_unlinked.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது",
"splash.open_library": "நூலகம் திறக்கப்படுகின்றது",
"status.backup_success": "நூலக காப்புப் பிரதி சேமிக்கப்பட்டது:",
"status.enumerate_query": "வினவு:%{query}, சட்டகம்: %{i}, நீளம்: %{len(f)}",
"status.number_results_found": "\"%{query}\" இல் %{len(all_items)} முடிவுகள் கிடைத்தன (%{format_timespan(end_time - start_time)})",
"status.results_found": "முடிவுகள்",
"status.save_success": "நூலகம் சேமிக்கப்பட்டு மூடப்பட்டது!",
"status.search_library_query": "நூலகத்தைத் தேடுகிறது",
"tag.add": "குறிச்சொல் சேர்க்க",
"tag.add_search": "தேடலில் சேர்",
"tag.library": "நூலக குறிச்சொற்கள்",
"tag.new": "புதிய குறிச்சொல்",
"tag.search_for_tag": "குறிச்சொல்லைத் தேடு",
"text_line_edit.unknown_event_type": "அறியப்படாத நிகழ்வு வகை: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "pana e sona",
"build_tags.add_parent_tags": "o pana e poki mama",
"build_tags.parent_tags": "poki mama",
"delete_unlinked.confirm": "mi weka e ijo %{len(self.lib.missing_files)}. ni li pona anu seme?",
"delete_unlinked.delete_entries": "mi weka e ijo",
"delete_unlinked.delete_unlinked": "o weka e ijo pi ijo lon ala",
"delete_unlinked.deleting_number_entries": "mi weka e ijo %{x[0]+1}/{len(self.lib.missing_files)} pi ijo lon ala",
"dialog.open_create_library": "open/sin e tomo",
"dialog.refresh_directories": "mi kama jo e sin lon tomo",
"dialog.save_library": "awen e tomo",
"dialog.scan_directories": "mi alasa e ijo sin lon tomo...\nmi kama pona...",
"dialog.scan_directories.new_files": "mi alasa e ijo sin lon tomo...\nmi lukin e ijo %{x + 1}. ijo %{len(self.lib.files_not_in_library)} li sin",
"file_extension.add_extension": "o pana e nimi anpa",
"file_extension.list_mode": "nasin kulupu:",
"file_opener.command_not_found": "mi ken ala alasa e toki lawa '%{command_name}' lon PATH",
"file_opener.not_found": "mi ken ala alasa e ijo:",
"file_opener.open_file": "mi open e ijo:}",
"fix_dupes.advice_label": "jasima li pini la, sina ken kepeken ilo DupeGuru. ilo DupeGuru li ken weka e ijo ike. ni li pini la, o kepeken e nasin \"o pona e ijo pi ijo lon ala\" lon ilo TagStudio. ni li weka e ijo pi ijo lon ala.",
"fix_dupes.fix_dupes": "pona e ijo sama",
"fix_dupes.load_file": "o kama sona e ijo DupeGuru",
"fix_dupes.mirror_description": "o jasima e sona kama lon kulupu sama ale. sona ale li kama wan li weka ala li kama mute ala. ni li weka ala e sona e ijo.",
"fix_dupes.mirror_entries": "o jasima e ijo",
"fix_dupes.name_filter": "ijo DupeGuru (*.dupeguru)",
"fix_dupes.no_file_match": "ijo sama: ala",
"fix_dupes.no_file_selected": "sina o anu e ijo DupeGuru",
"fix_dupes.number_file_match": "ijo sama: %{count}",
"fix_dupes.open_result_files": "o open e sona pini tan ilo DupeGuru",
"fix_unlinked.delete_unlinked": "o weka e ijo pi ijo lon ala",
"fix_unlinked.description": "ijo ale li jo e ijo lon. ona li tawa anu weka, ona li jo ala e ijo lon. ijo pi ijo lon li ken alasa e tomo li ken kama jo e ijo lon. ante la sina ken pana ijo lon tawa ijo. ante la sina ken weka e ijo.",
"fix_unlinked.duplicate_description": "ken la, ijo mute li jo e ijo lon sama. ni li \"ijo sama\". sina wan e ona la, ijo sama li kama wan li jo e sona ale tan ijo sama ale.",
"fix_unlinked.fix_unlinked": "o pona e ijo pi ijo lon ala",
"fix_unlinked.manual_relink": "sina o pana e ijo lon tawa ijo",
"fix_unlinked.merge_dupes": "o kama wan e ijo sama",
"fix_unlinked.refresh_dupes": "o kama jo e sona tan ijo sama",
"fix_unlinked.scan_library.label": "mi o alasa e ijo pi ijo lon ala...",
"fix_unlinked.scan_library.title": "mi o lukin e tomo",
"fix_unlinked.search_and_relink": "o alasa o pana e ijo lon tawa ijo",
"folders_to_tags.description": "ni li pali e poki tan poki tona li pana e poki sin tawa ijo lon tomo.\n ilo ni li pali e anpa.",
"folders_to_tags.folders_to_tags": "mi o pali e poki tan poki tomo",
"folders_to_tags.title": "o pali e poki tan poki tomo",
"generic.add": "o pana",
"generic.aliases": "nimi ante",
"generic.apply": "o pana",
"generic.cancel": "o ala",
"generic.close_all": "weka e ale",
"generic.color": "kule",
"generic.delete": "o weka",
"generic.done": "pona",
"generic.exclude": "o kepeken ala",
"generic.file_extension": "nimi lon nimi ijo anpa",
"generic.include": "o kepeken",
"generic.mirror": "jasima",
"generic.name": "nimi",
"generic.open_all": "open e ale",
"generic.open_file": "open e ijo",
"generic.open_file_explorer": "open e ijo lon ilo alasa",
"generic.refresh_all": "o kama jo sin tan ale",
"generic.remove_field": "weka e sona",
"generic.search_tags": "o alasa e poki",
"generic.shorthand": "nimi lili",
"home.base_title": "ilo TagStudio pi pini ala",
"home.include_all_tags": "AND (kepeken poki ale)",
"home.include_any_tag": "OR (kepeken poki wan. poki ale li ken)",
"home.main_window": "lipu suli",
"home.search": "alasa",
"home.search_entries": "ijo alasa",
"home.thumbnail_size": "suli sitelen",
"landing.open_button": "open/sin e tomo %{open_shortcut_text}",
"library.Artist": "jan pali sitelen",
"library.anthology": "kulupu toki",
"library.archived": "tenpo pi kama awen",
"library.author": "jan pali toki",
"library.book": "lipu toki",
"library.collation": "poki ijo",
"library.comic": "lipu sitelen",
"library.comments": "toki isipin",
"library.composer": "jan pali pi kalama musi",
"library.content_tags": "poki pi jo ijo",
"library.date": "tenpo",
"library.date_created": "tenpo pi kama sin",
"library.date_modified": "tenpo pi kama ante",
"library.date_published": "tenpo pana",
"library.date_released": "tenpo pi kama open",
"library.date_taken": "tenpo lanpan",
"library.date_uploaded": "tenpo pi kama lon lipu",
"library.description": "toki ni",
"library.favorite": "pona mute",
"library.guest_artist": "jan pali namako",
"library.magazine": "lipu sitelen toki",
"library.manga": "kulupu pi lipu sitelen",
"library.meta_tags": "poki pi sona ijo",
"library.notes": "toki sina",
"library.publisher": "jan esun",
"library.series": "kulupu",
"library.source": "tan",
"library.tags": "poki",
"library.title": "nimi",
"library.url": "nimi linluwi",
"library.volume": "nanpa",
"menu.edit": "ante",
"menu.file": "ijo",
"menu.help": "mi jo e toki seme",
"menu.macros": "ilo pali",
"menu.tools": "ilo",
"menu.window": "lipu",
"merge.merge_dupe_entries": "ijo sama li kama wan",
"merge.window_title": "ijo sama li kama wan",
"mirror_entities.are_you_sure": "mi jasima e ijo %{len(self.lib.dupe_files)}. ni li pona anu seme?",
"mirror_entities.label": "mi jasima e ijo 1/%{count}...",
"mirror_entities.title": "mi jasima e ijo",
"open_library.library_creation_return_code": "tomo li open li toki e ni:",
"open_library.no_tagstudio_library_found": "tomo lon '%{path}' li lon ala. mi pali e tomo.",
"open_library.title": "tomo",
"preview.dimensions": "suli",
"preview.recent": "tomo pi tenpo poka",
"preview_panel.confirm_remove": "sina weka e sona poki \"%{self.lib.get_field_attr(field, \"name\")}\". ni li pona anu seme?",
"preview_panel.edit_name": "ante",
"preview_panel.missing_location": "ma li lon ala",
"preview_panel.mixed_data": "sona ante",
"preview_panel.no_items_selected": "ijo ala li anu",
"preview_panel.unknown_field_type": "mi sona ala e kule pi sona poki",
"preview_panel.update_widgets": "[LIPU OPEN] O SIN E LIPU LILI (%{self.driver.selected})",
"progression.running_macros.new_entries": "mi pali lon ijo sin",
"progression.running_macros.one_new_entry": "mi pali lon ijo sin 1/%{len(new_ids)}",
"progression.running_macros.several_new_entry": "mi pali lon ijo sin %{x + 1}/%{len(new_ids)}",
"relink_unlinked.attempt_relink": "mi o pana e ijo lon tawa ijo %{x[0]+1}/%{len(self.lib.missing_files)}. mi pana e ijo lon tawa ijo %{self.fixed}",
"relink_unlinked.title": "mi pana e ijo lon tawa ijo",
"splash.open_library": "mi open e tomo",
"status.backup_success": "tomo sama li lon:",
"status.enumerate_query": "seme: %{query}. sitelen: %{i}. nanpa: %{len(f)}",
"status.number_results_found": "%{len(all_items)} mi jo e ijo \"%{query}\" (%{format_timespan(end_time - start_time)})",
"status.results_found": "jo",
"status.save_success": "tomo li awen li weka!",
"status.search_library_query": "mi alasa e",
"tag.add": "o pana e poki",
"tag.add_search": "pana tawa alasa",
"tag.library": "poki tomo",
"tag.new": "poki sin",
"tag.search_for_tag": "o alasa e poki",
"text_line_edit.unknown_event_type": "mi sona ala e ni: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
+144
View File
@@ -0,0 +1,144 @@
{
"add_field.add": "Alan Ekle",
"build_tags.add_parent_tags": "Üst Etiketler Ekle",
"build_tags.parent_tags": "Üst Etiketler",
"delete_unlinked.confirm": "%{len(self.lib.missing_files) tane kayıtları silmek istediğinden emin misin?",
"delete_unlinked.delete_entries": "Kayıtlar Siliniyor",
"delete_unlinked.delete_unlinked": "Kopuk Kayıtları Sil",
"delete_unlinked.deleting_number_entries": "%{x[0]+1}/{len(self.lib.missing_files)} Kopuk Kayıt Siliniyor",
"dialog.open_create_library": "Kütüphane Aç/Oluştur",
"dialog.refresh_directories": "Dizinler Yenileniyor",
"dialog.save_library": "Kütüphaneyi Kaydet",
"dialog.scan_directories": "Yeni Dosyalar için Dizinler Taranıyor...\nHazırlanıyor...",
"dialog.scan_directories.new_files": "Yeni Dosyalar için Dizinler Taranıyor...\n%{x + 1} File%{\"s\" if x + 1 != 1 else \"\"} Arandı, %{len(self.lib.files_not_in_library)} Yeni Dosya Bulundu",
"file_extension.add_extension": "Dosya Uzantısı Ekle",
"file_extension.list_mode": "Listeleme Modu:",
"file_opener.command_not_found": "Sistem PATH'inde %{command_name} komutu bulunamadı",
"file_opener.not_found": "Dosya bulunamadı:",
"file_opener.open_file": "Dosya açılıyor:}",
"fix_dupes.advice_label": "Yansıtma işleminden sonra, DupeGuru'yu kullanarak istenmeyen dosyaları silebilirsin. İşlem sonrasında, kopuk kayıtları silmek için TagStudio'nun Araçlar menüsünden \"Kopuk Kayıtları Düzelt\" özelliğini kullanabilirsin.",
"fix_dupes.fix_dupes": "Yinelenen Dosyaları Düzelt",
"fix_dupes.load_file": "DupeGuru Dosyasını Yükle",
"fix_dupes.mirror_description": "Kayıt verilerini bulunan her bir yinelemeye yansıtır, alanları kopyalamadan veya silmeden tüm verileri birleştirir. Bu operasyon herhangi bir dosya veya veri silmeyecek.",
"fix_dupes.mirror_entries": "Kayıtları Yansıt",
"fix_dupes.name_filter": "DupeGuru Dosyaları (*.dupeguru)",
"fix_dupes.no_file_match": "Bulunan Yinelenen Dosyalar: Yok",
"fix_dupes.no_file_selected": "Seçili DupeGuru Dosyası Yok",
"fix_dupes.number_file_match": "Bulunan Yinelenen Dosyalar: %{count}",
"fix_dupes.open_result_files": "DupeGuru Sonuçlar Dosyasını Aç",
"fix_unlinked.delete_unlinked": "Kopuk Kayıtları Sil",
"fix_unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopuk olarak sayılır. Kopuk kayıtlar dizinlerinizde arama yapılırken otomatik olarak tekrar eşleştirilebilir, manuel olarak sizin tarafınızdan eşleştirilebilir veya isteğiniz üzere silinebilir.",
"fix_unlinked.duplicate_description": "Yinelenen kayıtlar, diskinizde aynı dosyaya işaret eden birden fazla kayıt olarak tanımlanmaktadır. Bu kayıtları birleştirdiğinizde, yinelenen tüm kayıtların içerisindeki etiketler ve metadata bilgisi tek bir tane kayıt üzerinde birleştirilecektir. Bu, \"yinelenen dosyalar\" ile karıştırılmamalıdır. Yinelenen dosyalar, TagStudio'nun dışında birden fazla kere bulunan dosyalarınızdır.",
"fix_unlinked.fix_unlinked": "Kopuk Kayıtları Düzelt",
"fix_unlinked.manual_relink": "Manuel Yeniden Eşleştirme",
"fix_unlinked.merge_dupes": "Yinelenen Kayıtları Birleştir",
"fix_unlinked.refresh_dupes": "Yinelenen Kayıtları Yenile",
"fix_unlinked.scan_library.label": "Kütüphane Kopuk Kayıtlar için Taranıyor...",
"fix_unlinked.scan_library.title": "Kütüphane Taranıyor",
"fix_unlinked.search_and_relink": "Ara && Yeniden Eşleştir",
"folders_to_tags.description": "Klasörlerinin yapısına bakarak etiketler oluşturur ve bu etiketleri kayıtlarına uygular.\nAşağıdaki yapı, oluşturulacak tüm etiketleri ve o etiketlerin hangi kayıtlarına uygulanacağını göstermektedir.",
"folders_to_tags.folders_to_tags": "Klasörler Etiketlere Dönüştürülüyor",
"folders_to_tags.title": "Klasörlerden Etiketler Oluştur",
"generic.add": "Ekle",
"generic.aliases": "Takma Adlar",
"generic.apply": "Uygula",
"generic.cancel": "İptal",
"generic.close_all": "Tümünü Kapat",
"generic.color": "Renk",
"generic.delete": "Sil",
"generic.done": "Tamamlandı",
"generic.exclude": "Hariç Tut",
"generic.file_extension": "Dosya Uzantıları",
"generic.include": "Dahil Et",
"generic.mirror": "Yansıt",
"generic.name": "İsim",
"generic.open_all": "Tümünü Aç",
"generic.open_file": "Dosya aç",
"generic.open_file_explorer": "Dosyayı Dosya Gezgininde Aç",
"generic.refresh_all": "Tümünü Yenile",
"generic.remove_field": "Alan Kaldır",
"generic.search_tags": "Etiketleri Araştır",
"generic.shorthand": "Kısaltma",
"home.base_title": "TagStudio Alpha",
"home.include_all_tags": "Ve (Tüm Etiketleri Dikkate Alır)",
"home.include_any_tag": "Veya (Herhangi Bir Etiketi Dikkate Alır)",
"home.main_window": "Ana Pencere",
"home.search": "Ara",
"home.search_entries": "library",
"home.thumbnail_size": "Küçük Resim Boyutu",
"landing.open_button": "Kütüphane Aç/Oluştur %{open_shortcut_text}",
"library.Artist": "Sanatçı",
"library.anthology": "Antoloji",
"library.archived": "Arşivlenme Tarihi",
"library.author": "Sahibi",
"library.book": "Kitap",
"library.collation": "Sıralama",
"library.comic": "Dergi",
"library.comments": "Yorumlar",
"library.composer": "Besteci",
"library.content_tags": "İçerik Etiketleri",
"library.date": "Tarih",
"library.date_created": "Oluşturulma Tarihi",
"library.date_modified": "Değiştirilme Tarihi",
"library.date_published": "Yayınlanma Tarihi",
"library.date_released": "Çıkış Tarihi",
"library.date_taken": "Alınma Tarihi",
"library.date_uploaded": "Yüklenme Tarihi",
"library.description": "Açıklama",
"library.favorite": "Favori",
"library.guest_artist": "Konuk Sanatçı",
"library.magazine": "Magazin",
"library.manga": "Manga",
"library.meta_tags": "Meta Etiketler",
"library.notes": "Notlar",
"library.publisher": "Yayımcı",
"library.series": "Dizi",
"library.source": "Kaynak",
"library.tags": "Etiketler",
"library.title": "Başlık",
"library.url": "URL",
"library.volume": "Ses Seviyesi",
"menu.edit": "Düzenle",
"menu.file": "Dosya",
"menu.help": "Yardım",
"menu.macros": "Makrolar",
"menu.tools": "Araçlar",
"menu.window": "Pencere",
"merge.merge_dupe_entries": "Yinelenen Kayıtlar Birleştiriliyor",
"merge.window_title": "Yinelenen Kayıtlar Birleştiriliyor",
"mirror_entities.are_you_sure": "%{len(self.lib.dupe_files)} kaydı yansıtmak istediğinden emin misin?",
"mirror_entities.label": "1/%{count} Kayıt Yansıtılıyor...",
"mirror_entities.title": "Kayıtlar Yansıtılıyor",
"open_library.library_creation_return_code": "Kütüphane Oluşturmaktan Dönen Kod:",
"open_library.no_tagstudio_library_found": "'%{path}' konumunda herhangi bir TagStudio kütüphanesi bulunamadı. Yeni bir tane oluşturuluyor.",
"open_library.title": "Kütüphane",
"preview.dimensions": "Ölçüler",
"preview.recent": "Son Kütüphaneler",
"preview_panel.confirm_remove": "Bu \"%{self.lib.get_field_attr(field, \"name\")}\" alanını silmek istediğinden emin misin?",
"preview_panel.edit_name": "Düzenle",
"preview_panel.missing_location": "Lokasyon bulunamadı",
"preview_panel.mixed_data": "Karışık Veri",
"preview_panel.no_items_selected": "Hiçbir Öğe Seçilmedi",
"preview_panel.unknown_field_type": "Bilinmeyen Alan Türü",
"preview_panel.update_widgets": "[KAYIT PANELİ] WIDGET'LARI GÜNCELLE (%{self.driver.selected})",
"progression.running_macros.new_entries": "Yeni Kayıtlar Üzerinde Makrolar Çalıştırılıyor",
"progression.running_macros.one_new_entry": "1/%{len(new_ids)} Tane Yeni Kayıtlarda Yapılandırılmış Makrolar Çalıştırılıyor",
"progression.running_macros.several_new_entry": "%{x + 1}/%{len(new_ids)} Tane Yeni Kayıt Üzerinde Yapılandırılmış Makrolar Çalıştırılıyor",
"relink_unlinked.attempt_relink": "%{x[0]+1}/%{len(self.lib.missing_files)} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, %{self.fixed} Başarıyla Yeniden Eşleştirildi",
"relink_unlinked.title": "Kayıtlar Yeniden Eşleştiriliyor",
"splash.open_library": "Kütüphane Açılıyor",
"status.backup_success": "Kütüphane Yedeklemesi Şuraya Kaydedildi:",
"status.enumerate_query": "Sorgu:%{query}, Kare: %{i}, Uzunluk: %{len(f)}",
"status.number_results_found": "\"%{query}\" (%{format_timespan(end_time - start_time)}) Sorgusu için %{len(all_items)} Sonuç Bulundu",
"status.results_found": "Sonuçlar",
"status.save_success": "Kütüphane Kaydedildi ve Çıkış Yapıldı!",
"status.search_library_query": "Kütüphane Aranıyor",
"tag.add": "Etiket Ekle",
"tag.add_search": "Aramaya Ekle",
"tag.library": "Kütüphane Etiketleri",
"tag.new": "Yeni Etiket",
"tag.search_for_tag": "Etiket Ara",
"text_line_edit.unknown_event_type": "bilinmeyen event türü: %{event}",
"tooltip.open_library": "Ctrl+O",
"tooltip.save_library": "Ctrl+S"
}
@@ -0,0 +1 @@
{}
+7 -124
View File
@@ -1,134 +1,17 @@
from enum import Enum
VERSION: str = "9.3.2" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
VERSION: str = "9.5.0" # Major.Minor.Patch
VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
BACKUP_FOLDER_NAME: str = "backups"
COLLAGE_FOLDER_NAME: str = "collages"
LIBRARY_FILENAME: str = "ts_library.json"
# TODO: Turn this whitelist into a user-configurable blacklist.
IMAGE_TYPES: list[str] = [
".png",
".jpg",
".jpeg",
".jpg_large",
".jpeg_large",
".jfif",
".gif",
".tif",
".tiff",
".heic",
".heif",
".webp",
".bmp",
".svg",
".avif",
".apng",
".jp2",
".j2k",
".jpg2",
]
RAW_IMAGE_TYPES: list[str] = [
".raw",
".dng",
".rw2",
".nef",
".arw",
".crw",
".cr2",
".cr3",
]
VIDEO_TYPES: list[str] = [
".mp4",
".webm",
".mov",
".hevc",
".mkv",
".avi",
".wmv",
".flv",
".gifv",
".m4p",
".m4v",
".3gp",
]
AUDIO_TYPES: list[str] = [
".mp3",
".mp4",
".mpeg4",
".m4a",
".aac",
".wav",
".flac",
".alac",
".wma",
".ogg",
".aiff",
]
DOC_TYPES: list[str] = [
".txt",
".rtf",
".md",
".doc",
".docx",
".pdf",
".tex",
".odt",
".pages",
]
PLAINTEXT_TYPES: list[str] = [
".txt",
".md",
".css",
".html",
".xml",
".json",
".js",
".ts",
".ini",
".htm",
".csv",
".php",
".sh",
".bat",
]
SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"]
PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"]
ARCHIVE_TYPES: list[str] = [
".zip",
".rar",
".tar",
".tar",
".gz",
".tgz",
".7z",
".s7z",
]
PROGRAM_TYPES: list[str] = [".exe", ".app"]
SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"]
ALL_FILE_TYPES: list[str] = (
IMAGE_TYPES
+ VIDEO_TYPES
+ AUDIO_TYPES
+ DOC_TYPES
+ SPREADSHEET_TYPES
+ PRESENTATION_TYPES
+ ARCHIVE_TYPES
+ PROGRAM_TYPES
+ SHORTCUT_TYPES
FONT_SAMPLE_TEXT: str = (
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
)
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
TAG_FAVORITE = 1
TAG_ARCHIVED = 0
class LibraryPrefs(Enum):
IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
DB_VERSION: int = 1
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
import structlog
from PySide6.QtCore import QSettings
from src.core.constants import TS_FOLDER_NAME
from src.core.enums import SettingItems
from src.core.library.alchemy.library import LibraryStatus
logger = structlog.get_logger(__name__)
class DriverMixin:
settings: QSettings
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
"""Check if the path of library is valid."""
library_path: Path | None = None
if open_path:
library_path = Path(open_path)
if not library_path.exists():
logger.error("Path does not exist.", open_path=open_path)
return LibraryStatus(success=False, message="Path does not exist.")
elif self.settings.value(
SettingItems.START_LOAD_LAST, defaultValue=True, type=bool
) and self.settings.value(SettingItems.LAST_LIBRARY):
library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY)))
if not (library_path / TS_FOLDER_NAME).exists():
logger.error(
"TagStudio folder does not exist.",
library_path=library_path,
ts_folder=TS_FOLDER_NAME,
)
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
# dont consider this a fatal error, just skip opening the library
library_path = None
return LibraryStatus(
success=True,
library_path=library_path,
)
+35
View File
@@ -1,4 +1,6 @@
import enum
from typing import Any
from uuid import uuid4
class SettingItems(str, enum.Enum):
@@ -8,11 +10,16 @@ class SettingItems(str, enum.Enum):
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
WINDOW_SHOW_LIBS = "window_show_libs"
SHOW_FILENAMES = "show_filenames"
AUTOPLAY = "autoplay_videos"
class Theme(str, enum.Enum):
COLOR_BG_DARK = "#65000000"
COLOR_BG_LIGHT = "#22000000"
COLOR_DARK_LABEL = "#DD000000"
COLOR_BG = "#65000000"
COLOR_HOVER = "#65AAAAAA"
COLOR_PRESSED = "#65EEEEEE"
COLOR_DISABLED = "#65F39CAA"
@@ -31,3 +38,31 @@ class MacroID(enum.Enum):
BUILD_URL = "build_url"
MATCH = "match"
CLEAN_URL = "clean_url"
class DefaultEnum(enum.Enum):
"""Allow saving multiple identical values in property called .default."""
default: Any
def __new__(cls, value):
# Create the enum instance
obj = object.__new__(cls)
# make value random
obj._value_ = uuid4()
# assign the actual value into .default property
obj.default = value
return obj
@property
def value(self):
raise AttributeError("access the value via .default property instead")
class LibraryPrefs(DefaultEnum):
"""Library preferences with default value accessible via .default property."""
IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
DB_VERSION: int = 2
+6
View File
@@ -0,0 +1,6 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
class NoRendererError(Exception): ...
+10 -3
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import structlog
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
from sqlalchemy.orm import DeclarativeBase
from src.core.constants import RESERVED_TAG_END
logger = structlog.getLogger(__name__)
@@ -37,10 +38,16 @@ def make_tables(engine: Engine) -> None:
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
with engine.connect() as conn:
conn.execute(text("INSERT INTO tags (id, name, color) VALUES (999, 'temp', 1)"))
conn.execute(text("DELETE FROM tags WHERE id = 999"))
conn.commit()
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
conn.execute(
text(f"INSERT INTO tags (id, name, color) VALUES ({RESERVED_TAG_END}, 'temp', 1)")
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()
def drop_tables(engine: Engine) -> None:
+45 -62
View File
@@ -1,7 +1,10 @@
import enum
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from src.core.query_lang import AST as Query # noqa: N811
from src.core.query_lang import Constraint, ConstraintType, Parser
class TagColor(enum.IntEnum):
DEFAULT = 1
@@ -42,12 +45,12 @@ class TagColor(enum.IntEnum):
COOL_GRAY = 36
OLIVE = 37
class SearchMode(enum.IntEnum):
"""Operational modes for item searching."""
AND = 0
OR = 1
@staticmethod
def get_color_from_str(color_name: str) -> "TagColor":
for color in TagColor:
if color.name == color_name.upper().replace(" ", "_"):
return color
return TagColor.DEFAULT
class ItemType(enum.Enum):
@@ -61,63 +64,12 @@ class FilterState:
"""Represent a state of the Library grid view."""
# these should remain
page_index: int | None = None
page_size: int | None = None
search_mode: SearchMode = SearchMode.AND # TODO - actually implement this
page_index: int | None = 0
page_size: int | None = 500
# these should be erased on update
# tag name
tag: str | None = None
# tag ID
tag_id: int | None = None
# entry id
id: int | None = None
# whole path
path: Path | str | None = None
# file name
name: str | None = None
# a generic query to be parsed
query: str | None = None
def __post_init__(self):
# strip values automatically
if query := (self.query and self.query.strip()):
# parse the value
if ":" in query:
kind, _, value = query.partition(":")
else:
# default to tag search
kind, value = "tag", query
if kind == "tag_id":
self.tag_id = int(value)
elif kind == "tag":
self.tag = value
elif kind == "path":
self.path = value
elif kind == "name":
self.name = value
elif kind == "id":
self.id = int(self.id) if str(self.id).isnumeric() else self.id
else:
self.tag = self.tag and self.tag.strip()
self.tag_id = int(self.tag_id) if str(self.tag_id).isnumeric() else self.tag_id
self.path = self.path and str(self.path).strip()
self.name = self.name and self.name.strip()
self.id = int(self.id) if str(self.id).isnumeric() else self.id
if self.page_index is None:
self.page_index = 0
if self.page_size is None:
self.page_size = 500
@property
def summary(self):
"""Show query summary."""
return self.query or self.tag or self.name or self.tag_id or self.path or self.id
# Abstract Syntax Tree Of the current Search Query
ast: Query = None
@property
def limit(self):
@@ -127,6 +79,37 @@ class FilterState:
def offset(self):
return self.page_size * self.page_index
@classmethod
def show_all(cls) -> "FilterState":
return FilterState()
@classmethod
def from_search_query(cls, search_query: str) -> "FilterState":
return cls(ast=Parser(search_query).parse())
@classmethod
def from_tag_id(cls, tag_id: int | str) -> "FilterState":
return cls(ast=Constraint(ConstraintType.TagID, str(tag_id), []))
@classmethod
def from_path(cls, path: Path | str) -> "FilterState":
return cls(ast=Constraint(ConstraintType.Path, str(path).strip(), []))
@classmethod
def from_mediatype(cls, mediatype: str) -> "FilterState":
return cls(ast=Constraint(ConstraintType.MediaType, mediatype, []))
@classmethod
def from_filetype(cls, filetype: str) -> "FilterState":
return cls(ast=Constraint(ConstraintType.FileType, filetype, []))
@classmethod
def from_tag_name(cls, tag_name: str) -> "FilterState":
return cls(ast=Constraint(ConstraintType.Tag, tag_name, []))
def with_page_size(self, page_size: int) -> "FilterState":
return replace(self, page_size=page_size)
class FieldTypeEnum(enum.Enum):
TEXT_LINE = "Text Line"
+8 -8
View File
@@ -18,27 +18,27 @@ class BaseField(Base):
__abstract__ = True
@declared_attr
def id(cls) -> Mapped[int]: # noqa: N805
def id(self) -> Mapped[int]:
return mapped_column(primary_key=True, autoincrement=True)
@declared_attr
def type_key(cls) -> Mapped[str]: # noqa: N805
def type_key(self) -> Mapped[str]:
return mapped_column(ForeignKey("value_type.key"))
@declared_attr
def type(cls) -> Mapped[ValueType]: # noqa: N805
return relationship(foreign_keys=[cls.type_key], lazy=False) # type: ignore
def type(self) -> Mapped[ValueType]:
return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore
@declared_attr
def entry_id(cls) -> Mapped[int]: # noqa: N805
def entry_id(self) -> Mapped[int]:
return mapped_column(ForeignKey("entries.id"))
@declared_attr
def entry(cls) -> Mapped[Entry]: # noqa: N805
return relationship(foreign_keys=[cls.entry_id]) # type: ignore
def entry(self) -> Mapped[Entry]:
return relationship(foreign_keys=[self.entry_id]) # type: ignore
@declared_attr
def position(cls) -> Mapped[int]: # noqa: N805
def position(self) -> Mapped[int]:
return mapped_column(default=0)
def __hash__(self):
+399 -126
View File
@@ -1,17 +1,21 @@
import re
import shutil
import time
import unicodedata
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import Any, Iterator, Type
from typing import Any
from uuid import uuid4
import structlog
from humanfriendly import format_timespan
from sqlalchemy import (
URL,
Engine,
NullPool,
and_,
create_engine,
delete,
@@ -28,14 +32,15 @@ from sqlalchemy.orm import (
make_transient,
selectinload,
)
from src.core.library.json.library import Library as JsonLibrary # type: ignore
from ...constants import (
BACKUP_FOLDER_NAME,
TAG_ARCHIVED,
TAG_FAVORITE,
TS_FOLDER_NAME,
LibraryPrefs,
)
from ...enums import LibraryPrefs
from .db import make_tables
from .enums import FieldTypeEnum, FilterState, TagColor
from .fields import (
@@ -47,8 +52,7 @@ from .fields import (
)
from .joins import TagField, TagSubtag
from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType
LIBRARY_FILENAME: str = "ts_library.sqlite"
from .visitors import SQLBoolExpressionBuilder
logger = structlog.get_logger(__name__)
@@ -115,6 +119,16 @@ class SearchResult:
return self.items[index]
@dataclass
class LibraryStatus:
"""Keep status of library opening operation."""
success: bool
library_path: Path | None = None
message: str | None = None
json_migration_req: bool = False
class Library:
"""Class for the Library object, and all CRUD operations made upon it."""
@@ -122,6 +136,10 @@ class Library:
storage_path: Path | str | None
engine: Engine | None
folder: Folder | None
included_files: set[Path] = set()
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
def close(self):
if self.engine:
@@ -129,39 +147,142 @@ class Library:
self.library_dir = None
self.storage_path = None
self.folder = None
self.included_files = set()
def open_library(self, library_dir: Path | str, storage_path: str | None = None) -> None:
if isinstance(library_dir, str):
library_dir = Path(library_dir)
def migrate_json_to_sqlite(self, json_lib: JsonLibrary):
"""Migrate JSON library data to the SQLite database."""
logger.info("Starting Library Conversion...")
start_time = time.time()
folder: Folder = Folder(path=self.library_dir, uuid=str(uuid4()))
self.library_dir = library_dir
# Tags
for tag in json_lib.tags:
self.add_tag(
Tag(
id=tag.id,
name=tag.name,
shorthand=tag.shorthand,
color=TagColor.get_color_from_str(tag.color),
)
)
# Tag Aliases
for tag in json_lib.tags:
for alias in tag.aliases:
if not alias:
break
self.add_alias(name=alias, tag_id=tag.id)
# Tag Subtags
for tag in json_lib.tags:
for subtag_id in tag.subtag_ids:
self.add_subtag(parent_id=tag.id, child_id=subtag_id)
# Entries
self.add_entries(
[
Entry(
path=entry.path / entry.filename,
folder=folder,
fields=[],
id=entry.id + 1, # JSON IDs start at 0 instead of 1
)
for entry in json_lib.entries
]
)
for entry in json_lib.entries:
for field in entry.fields:
for k, v in field.items():
self.add_entry_field_type(
entry_ids=(entry.id + 1), # JSON IDs start at 0 instead of 1
field_id=self.get_field_name_from_id(k),
value=v,
)
# Preferences
self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list])
self.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, json_lib.is_exclude_list)
end_time = time.time()
logger.info(f"Library Converted! ({format_timespan(end_time-start_time)})")
def get_field_name_from_id(self, field_id: int) -> _FieldID:
for f in _FieldID:
if field_id == f.value.id:
return f
return None
def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus:
is_new: bool = True
if storage_path == ":memory:":
self.storage_path = storage_path
is_new = True
return self.open_sqlite_library(library_dir, is_new)
else:
self.verify_ts_folders(self.library_dir)
self.storage_path = self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME
self.storage_path = library_dir / TS_FOLDER_NAME / self.SQL_FILENAME
if self.verify_ts_folder(library_dir) and (is_new := not self.storage_path.exists()):
json_path = library_dir / TS_FOLDER_NAME / self.JSON_FILENAME
if json_path.exists():
return LibraryStatus(
success=False,
library_path=library_dir,
message="[JSON] Legacy v9.4 library requires conversion to v9.5+",
json_migration_req=True,
)
return self.open_sqlite_library(library_dir, is_new)
def open_sqlite_library(
self, library_dir: Path, is_new: bool, add_default_data: bool = True
) -> LibraryStatus:
connection_string = URL.create(
drivername="sqlite",
database=str(self.storage_path),
)
# NOTE: File-based databases should use NullPool to create new DB connection in order to
# keep connections on separate threads, which prevents the DB files from being locked
# even after a connection has been closed.
# SingletonThreadPool (the default for :memory:) should still be used for in-memory DBs.
# More info can be found on the SQLAlchemy docs:
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
poolclass = None if self.storage_path == ":memory:" else NullPool
logger.info("opening library", connection_string=connection_string)
self.engine = create_engine(connection_string)
logger.info(
"Opening SQLite Library", library_dir=library_dir, connection_string=connection_string
)
self.engine = create_engine(connection_string, poolclass=poolclass)
with Session(self.engine) as session:
make_tables(self.engine)
tags = get_default_tags()
try:
session.add_all(tags)
session.commit()
except IntegrityError:
# default tags may exist already
session.rollback()
if add_default_data:
tags = get_default_tags()
try:
session.add_all(tags)
session.commit()
except IntegrityError:
# default tags may exist already
session.rollback()
# dont check db version when creating new library
if not is_new:
db_version = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
)
if not db_version:
return LibraryStatus(
success=False,
message=(
"Library version mismatch.\n"
f"Found: v0, expected: v{LibraryPrefs.DB_VERSION.default}"
),
)
for pref in LibraryPrefs:
try:
session.add(Preferences(key=pref.name, value=pref.value))
session.add(Preferences(key=pref.name, value=pref.default))
session.commit()
except IntegrityError:
logger.debug("preference already exists", pref=pref)
@@ -183,11 +304,29 @@ class Library:
logger.debug("ValueType already exists", field=field)
session.rollback()
db_version = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
)
# if the db version is different, we cant proceed
if db_version.value != LibraryPrefs.DB_VERSION.default:
logger.error(
"DB version mismatch",
db_version=db_version.value,
expected=LibraryPrefs.DB_VERSION.default,
)
return LibraryStatus(
success=False,
message=(
"Library version mismatch.\n"
f"Found: v{db_version.value}, expected: v{LibraryPrefs.DB_VERSION.default}"
),
)
# check if folder matching current path exists already
self.folder = session.scalar(select(Folder).where(Folder.path == self.library_dir))
self.folder = session.scalar(select(Folder).where(Folder.path == library_dir))
if not self.folder:
folder = Folder(
path=self.library_dir,
path=library_dir,
uuid=str(uuid4()),
)
session.add(folder)
@@ -196,6 +335,10 @@ class Library:
session.commit()
self.folder = folder
# everything is fine, set the library path
self.library_dir = library_dir
return LibraryStatus(success=True, library_path=library_dir)
@property
def default_fields(self) -> list[BaseField]:
with Session(self.engine) as session:
@@ -260,6 +403,29 @@ class Library:
make_transient(entry)
return entry
def get_entry_full(self, entry_id: int) -> Entry | None:
"""Load entry an join with all joins and all tags."""
with Session(self.engine) as session:
statement = select(Entry).where(Entry.id == entry_id)
statement = (
statement.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tag_box_fields)
)
statement = statement.options(
selectinload(Entry.text_fields),
selectinload(Entry.datetime_fields),
selectinload(Entry.tag_box_fields)
.joinedload(TagBoxField.tags)
.options(selectinload(Tag.aliases), selectinload(Tag.subtags)),
)
entry = session.scalar(statement)
if not entry:
return None
session.expunge(entry)
make_transient(entry)
return entry
@property
def entries_count(self) -> int:
with Session(self.engine) as session:
@@ -305,8 +471,12 @@ class Library:
return list(tags_list)
def verify_ts_folders(self, library_dir: Path) -> None:
"""Verify/create folders required by TagStudio."""
def verify_ts_folder(self, library_dir: Path) -> bool:
"""Verify/create folders required by TagStudio.
Returns:
bool: True if path exists, False if it needed to be created.
"""
if library_dir is None:
raise ValueError("No path set.")
@@ -317,6 +487,8 @@ class Library:
if not full_ts_path.exists():
logger.info("creating library directory", dir=full_ts_path)
full_ts_path.mkdir(parents=True, exist_ok=True)
return False
return True
def add_entries(self, items: list[Entry]) -> list[int]:
"""Add multiple Entry records to the Library."""
@@ -324,15 +496,18 @@ class Library:
with Session(self.engine) as session:
# add all items
session.add_all(items)
session.flush()
try:
session.add_all(items)
session.commit()
except IntegrityError:
session.rollback()
logger.exception("IntegrityError")
return []
new_ids = [item.id for item in items]
session.expunge_all()
session.commit()
return new_ids
def remove_entries(self, entry_ids: list[int]) -> None:
@@ -346,6 +521,13 @@ class Library:
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()
def get_paths(self, glob: str | None = None) -> list[str]:
with Session(self.engine) as session:
paths = session.scalars(select(Entry.path)).unique()
path_strings: list[str] = list(map(lambda x: x.as_posix(), paths))
return path_strings
def search_library(
self,
search: FilterState,
@@ -360,45 +542,18 @@ class Library:
with Session(self.engine, expire_on_commit=False) as session:
statement = select(Entry)
if search.tag:
statement = (
statement.join(Entry.tag_box_fields)
.join(TagBoxField.tags)
.where(
or_(
Tag.name.ilike(search.tag),
Tag.shorthand.ilike(search.tag),
)
)
if search.ast:
statement = statement.outerjoin(Entry.tag_box_fields).where(
SQLBoolExpressionBuilder(self).visit(search.ast)
)
elif search.tag_id:
statement = (
statement.join(Entry.tag_box_fields)
.join(TagBoxField.tags)
.where(Tag.id == search.tag_id)
)
elif search.id:
statement = statement.where(Entry.id == search.id)
elif search.name:
statement = select(Entry).where(
and_(
Entry.path.ilike(f"%{search.name}%"),
# dont match directory name (ie. has following slash)
~Entry.path.ilike(f"%{search.name}%/%"),
)
)
elif search.path:
statement = statement.where(Entry.path.ilike(f"%{search.path}%"))
extensions = self.prefs(LibraryPrefs.EXTENSION_LIST)
is_exclude_list = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST)
if not search.id: # if `id` is set, we don't need to filter by extensions
if extensions and is_exclude_list:
statement = statement.where(Entry.path.notilike(f"%.{','.join(extensions)}"))
elif extensions:
statement = statement.where(Entry.path.ilike(f"%.{','.join(extensions)}"))
if extensions and is_exclude_list:
statement = statement.where(Entry.suffix.notin_(extensions))
elif extensions:
statement = statement.where(Entry.suffix.in_(extensions))
statement = statement.options(
selectinload(Entry.text_fields),
@@ -408,6 +563,8 @@ class Library:
.options(selectinload(Tag.aliases), selectinload(Tag.subtags)),
)
statement = statement.distinct(Entry.id)
query_count = select(func.count()).select_from(statement.alias("entries"))
count_all: int = session.execute(query_count).scalar()
@@ -421,7 +578,7 @@ class Library:
res = SearchResult(
total_count=count_all,
items=list(session.scalars(statement).unique()),
items=list(session.scalars(statement)),
)
session.expunge_all()
@@ -430,21 +587,23 @@ class Library:
def search_tags(
self,
search: FilterState,
name: str,
) -> list[Tag]:
"""Return a list of Tag records matching the query."""
tag_limit = 100
with Session(self.engine) as session:
query = select(Tag)
query = query.options(
selectinload(Tag.subtags),
selectinload(Tag.aliases),
)
).limit(tag_limit)
if search.tag:
if name:
query = query.where(
or_(
Tag.name.icontains(search.tag),
Tag.shorthand.icontains(search.tag),
Tag.name.icontains(name),
Tag.shorthand.icontains(name),
)
)
@@ -454,7 +613,7 @@ class Library:
logger.info(
"searching tags",
search=search,
search=name,
statement=str(query),
results=len(res),
)
@@ -498,6 +657,37 @@ class Library:
session.execute(update_stmt)
session.commit()
def remove_tag(self, tag: Tag):
with Session(self.engine, expire_on_commit=False) as session:
try:
subtags = session.scalars(
select(TagSubtag).where(TagSubtag.parent_id == tag.id)
).all()
tags_query = select(Tag).options(
selectinload(Tag.subtags), selectinload(Tag.aliases)
)
tag = session.scalar(tags_query.where(Tag.id == tag.id))
aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id))
for alias in aliases or []:
session.delete(alias)
for subtag in subtags or []:
session.delete(subtag)
session.expunge(subtag)
session.delete(tag)
session.commit()
session.expunge(tag)
return tag
except IntegrityError as e:
logger.exception(e)
session.rollback()
return None
def remove_tag_from_field(self, tag: Tag, field: TagBoxField) -> None:
with Session(self.engine) as session:
field_ = session.scalars(select(TagBoxField).where(TagBoxField.id == field.id)).one()
@@ -510,7 +700,7 @@ class Library:
def update_field_position(
self,
field_class: Type[BaseField],
field_class: type[BaseField],
field_type: str,
entry_ids: list[int] | int,
):
@@ -617,7 +807,7 @@ class Library:
*,
field: ValueType | None = None,
field_id: _FieldID | str | None = None,
value: str | datetime | list[str] | None = None,
value: str | datetime | list[int] | None = None,
) -> bool:
logger.info(
"add_field_to_entry",
@@ -650,8 +840,11 @@ class Library:
if value:
assert isinstance(value, list)
for tag in value:
field_model.tags.add(Tag(name=tag))
with Session(self.engine) as session:
for tag_id in list(set(value)):
tag = session.scalar(select(Tag).where(Tag.id == tag_id))
field_model.tags.add(tag)
session.flush()
elif field.type == FieldTypeEnum.DATETIME:
field_model = DatetimeField(
@@ -683,21 +876,47 @@ class Library:
)
return True
def add_tag(self, tag: Tag, subtag_ids: list[int] | None = None) -> Tag | None:
def tag_from_strings(self, strings: list[str] | str) -> list[int]:
"""Create a Tag from a given string."""
# TODO: Port over tag searching with aliases fallbacks
# and context clue ranking for string searches.
tags: list[int] = []
if isinstance(strings, str):
strings = [strings]
with Session(self.engine) as session:
for string in strings:
tag = session.scalar(select(Tag).where(Tag.name == string))
if tag:
tags.append(tag.id)
else:
new = session.add(Tag(name=string))
if new:
tags.append(new.id)
session.flush()
session.commit()
return tags
def add_tag(
self,
tag: Tag,
subtag_ids: set[int] | None = None,
alias_names: set[str] | None = None,
alias_ids: set[int] | None = None,
) -> Tag | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
session.add(tag)
session.flush()
for subtag_id in subtag_ids or []:
subtag = TagSubtag(
parent_id=tag.id,
child_id=subtag_id,
)
session.add(subtag)
if subtag_ids is not None:
self.update_subtags(tag, subtag_ids, session)
if alias_ids is not None and alias_names is not None:
self.update_aliases(tag, alias_ids, alias_names, session)
session.commit()
session.expunge(tag)
return tag
@@ -770,7 +989,7 @@ class Library:
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
shutil.copy2(
self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME,
self.library_dir / TS_FOLDER_NAME / self.SQL_FILENAME,
target_path,
)
@@ -778,25 +997,47 @@ class Library:
def get_tag(self, tag_id: int) -> Tag:
with Session(self.engine) as session:
tags_query = select(Tag).options(selectinload(Tag.subtags))
tags_query = select(Tag).options(selectinload(Tag.subtags), selectinload(Tag.aliases))
tag = session.scalar(tags_query.where(Tag.id == tag_id))
session.expunge(tag)
for subtag in tag.subtags:
session.expunge(subtag)
for alias in tag.aliases:
session.expunge(alias)
return tag
def add_subtag(self, base_id: int, new_tag_id: int) -> bool:
def get_tag_by_name(self, tag_name: str) -> Tag | None:
with Session(self.engine) as session:
statement = (
select(Tag)
.outerjoin(TagAlias)
.where(or_(Tag.name == tag_name, TagAlias.name == tag_name))
)
return session.scalar(statement)
def get_alias(self, tag_id: int, alias_id: int) -> TagAlias:
with Session(self.engine) as session:
alias_query = select(TagAlias).where(TagAlias.id == alias_id, TagAlias.tag_id == tag_id)
alias = session.scalar(alias_query.where(TagAlias.id == alias_id))
return alias
def add_subtag(self, parent_id: int, child_id: int) -> bool:
if parent_id == child_id:
return False
# open session and save as parent tag
with Session(self.engine) as session:
tag = TagSubtag(
parent_id=base_id,
child_id=new_tag_id,
subtag = TagSubtag(
parent_id=parent_id,
child_id=child_id,
)
try:
session.add(tag)
session.add(subtag)
session.commit()
return True
except IntegrityError:
@@ -804,49 +1045,81 @@ class Library:
logger.exception("IntegrityError")
return False
def update_tag(self, tag: Tag, subtag_ids: list[int]) -> None:
"""Edit a Tag in the Library."""
# TODO - maybe merge this with add_tag?
if tag.shorthand:
tag.shorthand = slugify(tag.shorthand)
if tag.aliases:
# TODO
...
# save the tag
def add_alias(self, name: str, tag_id: int) -> bool:
with Session(self.engine) as session:
if not name:
logger.warning("[LIBRARY][add_alias] Alias value must not be empty")
return False
alias = TagAlias(
name=name,
tag_id=tag_id,
)
try:
# update the existing tag
session.add(tag)
session.flush()
# load all tag's subtag to know which to remove
prev_subtags = session.scalars(
select(TagSubtag).where(TagSubtag.parent_id == tag.id)
).all()
for subtag in prev_subtags:
if subtag.child_id not in subtag_ids:
session.delete(subtag)
else:
# no change, remove from list
subtag_ids.remove(subtag.child_id)
# create remaining items
for subtag_id in subtag_ids:
# add new subtag
subtag = TagSubtag(
parent_id=tag.id,
child_id=subtag_id,
)
session.add(subtag)
session.add(alias)
session.commit()
return True
except IntegrityError:
session.rollback()
logger.exception("IntegrityError")
return False
def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool:
with Session(self.engine) as session:
p_id = base_id
r_id = remove_tag_id
remove = session.query(TagSubtag).filter_by(parent_id=p_id, child_id=r_id).one()
session.delete(remove)
session.commit()
return True
def update_tag(
self,
tag: Tag,
subtag_ids: set[int] | None = None,
alias_names: set[str] | None = None,
alias_ids: set[int] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, subtag_ids, alias_names, alias_ids)
def update_aliases(self, tag, alias_ids, alias_names, session):
prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
for alias in prev_aliases:
if alias.id not in alias_ids or alias.name not in alias_names:
session.delete(alias)
else:
alias_ids.remove(alias.id)
alias_names.remove(alias.name)
for alias_name in alias_names:
alias = TagAlias(alias_name, tag.id)
session.add(alias)
def update_subtags(self, tag, subtag_ids, session):
if tag.id in subtag_ids:
subtag_ids.remove(tag.id)
# load all tag's subtag to know which to remove
prev_subtags = session.scalars(select(TagSubtag).where(TagSubtag.parent_id == tag.id)).all()
for subtag in prev_subtags:
if subtag.child_id not in subtag_ids:
session.delete(subtag)
else:
# no change, remove from list
subtag_ids.remove(subtag.child_id)
# create remaining items
for subtag_id in subtag_ids:
# add new subtag
subtag = TagSubtag(
parent_id=tag.id,
child_id=subtag_id,
)
session.add(subtag)
def prefs(self, key: LibraryPrefs) -> Any:
# load given item from Preferences table
+15 -8
View File
@@ -1,5 +1,4 @@
from pathlib import Path
from typing import Optional
from sqlalchemy import JSON, ForeignKey, Integer, event
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -24,16 +23,16 @@ class TagAlias(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
name: Mapped[str] = mapped_column(nullable=False)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
tag: Mapped["Tag"] = relationship(back_populates="aliases")
def __init__(self, name: str, tag: Optional["Tag"] = None):
def __init__(self, name: str, tag_id: int | None = None):
self.name = name
if tag:
self.tag = tag
if tag_id is not None:
self.tag_id = tag_id
super().__init__()
@@ -44,7 +43,7 @@ class Tag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(unique=True)
name: Mapped[str]
shorthand: Mapped[str | None]
color: Mapped[TagColor]
icon: Mapped[str | None]
@@ -73,16 +72,20 @@ class Tag(Base):
def alias_strings(self) -> list[str]:
return [alias.name for alias in self.aliases]
@property
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]
def __init__(
self,
name: str,
id: int | None = None,
name: str | None = None,
shorthand: str | None = None,
aliases: set[TagAlias] | None = None,
parent_tags: set["Tag"] | None = None,
subtags: set["Tag"] | None = None,
icon: str | None = None,
color: TagColor = TagColor.DEFAULT,
id: int | None = None,
):
self.name = name
self.aliases = aliases or set()
@@ -120,6 +123,7 @@ class Entry(Base):
folder: Mapped[Folder] = relationship("Folder")
path: Mapped[Path] = mapped_column(PathType, unique=True)
suffix: Mapped[str] = mapped_column()
text_fields: Mapped[list[TextField]] = relationship(
back_populates="entry",
@@ -173,9 +177,12 @@ class Entry(Base):
path: Path,
folder: Folder,
fields: list[BaseField],
id: int | None = None,
) -> None:
self.path = path
self.folder = folder
self.id = id
self.suffix = path.suffix.lstrip(".").lower()
for field in fields:
if isinstance(field, TextField):
@@ -0,0 +1,125 @@
from typing import TYPE_CHECKING
from sqlalchemy import and_, distinct, func, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import BinaryExpression, ColumnExpressionArgument
from src.core.media_types import MediaCategories
from src.core.query_lang import BaseVisitor
from src.core.query_lang.ast import AST, ANDList, Constraint, ConstraintType, Not, ORList, Property
from .joins import TagField
from .models import Entry, Tag, TagAlias, TagBoxField
# workaround to have autocompletion in the Editor
if TYPE_CHECKING:
from .library import Library
else:
Library = None # don't import .library because of circular imports
class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
def __init__(self, lib: Library) -> None:
super().__init__()
self.lib = lib
def visit_or_list(self, node: ORList) -> ColumnExpressionArgument:
return or_(*[self.visit(element) for element in node.elements])
def visit_and_list(self, node: ANDList) -> ColumnExpressionArgument:
tag_ids: list[int] = []
bool_expressions: list[ColumnExpressionArgument] = []
# Search for TagID / unambigous Tag Constraints and store the respective tag ids seperately
for term in node.terms:
if isinstance(term, Constraint) and len(term.properties) == 0:
match term.type:
case ConstraintType.TagID:
tag_ids.append(int(term.value))
continue
case ConstraintType.Tag:
if len(ids := self.__get_tag_ids(term.value)) == 1:
tag_ids.append(ids[0])
continue
bool_expressions.append(self.__entry_satisfies_ast(term))
# If there are at least two tag ids use a relational division query
# to efficiently check all of them
if len(tag_ids) > 1:
bool_expressions.append(self.__entry_has_all_tags(tag_ids))
# If there is just one tag id, check the normal way
elif len(tag_ids) == 1:
bool_expressions.append(
self.__entry_satisfies_expression(TagField.tag_id == tag_ids[0])
)
return and_(*bool_expressions)
def visit_constraint(self, node: Constraint) -> ColumnExpressionArgument:
if len(node.properties) != 0:
raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG
if node.type == ConstraintType.Tag:
return TagBoxField.tags.any(Tag.id.in_(self.__get_tag_ids(node.value)))
elif node.type == ConstraintType.TagID:
return TagBoxField.tags.any(Tag.id == int(node.value))
elif node.type == ConstraintType.Path:
return Entry.path.op("GLOB")(node.value)
elif node.type == ConstraintType.MediaType:
extensions: set[str] = set[str]()
for media_cat in MediaCategories.ALL_CATEGORIES:
if node.value == media_cat.name:
extensions = extensions | media_cat.extensions
break
return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions))
elif node.type == ConstraintType.FileType:
return Entry.suffix.ilike(node.value)
elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint
if node.value.lower() == "untagged":
return ~Entry.id.in_(
select(Entry.id).join(Entry.tag_box_fields).join(TagBoxField.tags)
)
# raise exception if Constraint stays unhandled
raise NotImplementedError("This type of constraint is not implemented yet")
def visit_property(self, node: Property) -> None:
raise NotImplementedError("This should never be reached!")
def visit_not(self, node: Not) -> ColumnExpressionArgument:
return ~self.__entry_satisfies_ast(node.child)
def __get_tag_ids(self, tag_name: str) -> list[int]:
"""Given a tag name find the ids of all tags that this name could refer to."""
with Session(self.lib.engine, expire_on_commit=False) as session:
return list(
session.scalars(
select(Tag.id)
.where(or_(Tag.name.ilike(tag_name), Tag.shorthand.ilike(tag_name)))
.union(select(TagAlias.tag_id).where(TagAlias.name.ilike(tag_name)))
)
)
def __entry_has_all_tags(self, tag_ids: list[int]) -> BinaryExpression[bool]:
"""Returns Binary Expression that is true if the Entry has all provided tag ids."""
# Relational Division Query
return Entry.id.in_(
select(Entry.id)
.outerjoin(TagBoxField)
.outerjoin(TagField)
.where(TagField.tag_id.in_(tag_ids))
.group_by(Entry.id)
.having(func.count(distinct(TagField.tag_id)) == len(tag_ids))
)
def __entry_satisfies_ast(self, partial_query: AST) -> BinaryExpression[bool]:
"""Returns Binary Expression that is true if the Entry satisfies the partial query."""
return self.__entry_satisfies_expression(self.visit(partial_query))
def __entry_satisfies_expression(
self, expr: ColumnExpressionArgument
) -> BinaryExpression[bool]:
"""Returns Binary Expression that is true if the Entry satisfies the column expression."""
return Entry.id.in_(
select(Entry.id).outerjoin(Entry.tag_box_fields).outerjoin(TagField).where(expr)
)
+10 -8
View File
@@ -299,6 +299,8 @@ class Collation:
class Library:
"""Class for the Library object, and all CRUD operations made upon it."""
FILENAME: str = "ts_library.json"
def __init__(self) -> None:
# Library Info =========================================================
self.library_dir: Path = None
@@ -412,17 +414,17 @@ class Library:
"""Verifies/creates folders required by TagStudio."""
full_ts_path = self.library_dir / TS_FOLDER_NAME
full_backup_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
full_collage_path = self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME
# full_backup_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
# full_collage_path = self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME
if not os.path.isdir(full_ts_path):
os.mkdir(full_ts_path)
if not os.path.isdir(full_backup_path):
os.mkdir(full_backup_path)
# if not os.path.isdir(full_backup_path):
# os.mkdir(full_backup_path)
if not os.path.isdir(full_collage_path):
os.mkdir(full_collage_path)
# if not os.path.isdir(full_collage_path):
# os.mkdir(full_collage_path)
def verify_default_tags(self, tag_list: list) -> list:
"""
@@ -447,7 +449,7 @@ class Library:
return_code = OpenStatus.CORRUPTED
_path: Path = self._fix_lib_path(path)
logger.info("opening library", path=_path)
logger.info("Opening JSON Library", path=_path)
if (_path / TS_FOLDER_NAME / "ts_library.json").exists():
try:
with open(
@@ -552,7 +554,7 @@ class Library:
self._next_entry_id += 1
filename = entry.get("filename", "")
e_path = entry.get("path", "")
e_path = entry.get("path", "").replace("\\", "/")
fields: list = []
if "fields" in entry:
# Cast JSON str keys to ints
+587
View File
@@ -0,0 +1,587 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import mimetypes
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
logging.basicConfig(format="%(message)s", level=logging.INFO)
class MediaType(str, Enum):
"""Names of media types."""
ADOBE_PHOTOSHOP: str = "adobe_photoshop"
AFFINITY_PHOTO: str = "affinity_photo"
ARCHIVE: str = "archive"
AUDIO_MIDI: str = "audio_midi"
AUDIO: str = "audio"
BLENDER: str = "blender"
DATABASE: str = "database"
DISK_IMAGE: str = "disk_image"
DOCUMENT: str = "document"
EBOOK: str = "ebook"
FONT: str = "font"
IMAGE_ANIMATED: str = "image_animated"
IMAGE_RAW: str = "image_raw"
IMAGE_VECTOR: str = "image_vector"
IMAGE: str = "image"
INSTALLER: str = "installer"
MATERIAL: str = "material"
MODEL: str = "model"
OPEN_DOCUMENT: str = "open_document"
PACKAGE: str = "package"
PDF: str = "pdf"
PLAINTEXT: str = "plaintext"
PRESENTATION: str = "presentation"
PROGRAM: str = "program"
SHORTCUT: str = "shortcut"
SOURCE_ENGINE: str = "source_engine"
SPREADSHEET: str = "spreadsheet"
TEXT: str = "text"
VIDEO: str = "video"
@dataclass(frozen=True)
class MediaCategory:
"""An object representing a category of media.
Includes a MediaType identifier, extensions set, and IANA status flag.
Args:
media_type (MediaType): The MediaType Enum representing this category.
extensions (set[str]): The set of file extensions associated with this category.
Includes leading ".", all lowercase, and does not need to be unique to this category.
is_iana (bool): Represents whether or not this is an IANA registered category.
"""
media_type: MediaType
extensions: set[str]
name: str
is_iana: bool = False
class MediaCategories:
"""Contain pre-made MediaCategory objects as well as methods to interact with them."""
# These sets are used either individually or together to form the final sets
# for the MediaCategory(s).
# These sets may be combined and are NOT 1:1 with the final categories.
_ADOBE_PHOTOSHOP_SET: set[str] = {
".pdd",
".psb",
".psd",
}
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
_ARCHIVE_SET: set[str] = {
".7z",
".gz",
".rar",
".s7z",
".tar",
".tgz",
".zip",
}
_AUDIO_MIDI_SET: set[str] = {
".mid",
".midi",
}
_AUDIO_SET: set[str] = {
".aac",
".aif",
".aiff",
".alac",
".flac",
".m4a",
".m4p",
".mp3",
".mpeg4",
".ogg",
".wav",
".wma",
}
_BLENDER_SET: set[str] = {
".blen_tc",
".blend",
".blend1",
".blend2",
".blend3",
".blend4",
".blend5",
".blend6",
".blend7",
".blend8",
".blend9",
".blend10",
".blend11",
".blend12",
".blend13",
".blend14",
".blend15",
".blend16",
".blend17",
".blend18",
".blend19",
".blend20",
".blend21",
".blend22",
".blend23",
".blend24",
".blend25",
".blend26",
".blend27",
".blend28",
".blend29",
".blend30",
".blend31",
".blend32",
}
_DATABASE_SET: set[str] = {
".accdb",
".mdb",
".sqlite",
".sqlite3",
}
_DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"}
_DOCUMENT_SET: set[str] = {
".doc",
".docm",
".docx",
".dot",
".dotm",
".dotx",
".odt",
".pages",
".pdf",
".rtf",
".tex",
".wpd",
".wps",
}
_EBOOK_SET: set[str] = {
".epub",
# ".azw",
# ".azw3",
# ".cb7",
# ".cba",
# ".cbr",
# ".cbt",
# ".cbz",
# ".djvu",
# ".fb2",
# ".ibook",
# ".inf",
# ".kfx",
# ".lit",
# ".mobi",
# ".pdb"
# ".prc",
}
_FONT_SET: set[str] = {
".fon",
".otf",
".ttc",
".ttf",
".woff",
".woff2",
}
_IMAGE_ANIMATED_SET: set[str] = {
".apng",
".gif",
".webp",
}
_IMAGE_RAW_SET: set[str] = {
".arw",
".cr2",
".cr3",
".crw",
".dng",
".nef",
".orf",
".raf",
".raw",
".rw2",
}
_IMAGE_VECTOR_SET: set[str] = {".svg"}
_IMAGE_RASTER_SET: set[str] = {
".apng",
".avif",
".bmp",
".exr",
".gif",
".heic",
".heif",
".j2k",
".jfif",
".jp2",
".jpeg_large",
".jpeg",
".jpg_large",
".jpg",
".jpg2",
".jxl",
".png",
".psb",
".psd",
".tif",
".tiff",
".webp",
}
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
_MATERIAL_SET: set[str] = {".mtl"}
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
_OPEN_DOCUMENT_SET: set[str] = {
".fodg",
".fodp",
".fods",
".fodt",
".mscz",
".odf",
".odg",
".odp",
".ods",
".odt",
}
_PACKAGE_SET: set[str] = {
".aab",
".akp",
".apk",
".apkm",
".apks",
".pkg",
".xapk",
}
_PDF_SET: set[str] = {
".pdf",
}
_PLAINTEXT_SET: set[str] = {
".bat",
".cfg",
".conf",
".cpp",
".cs",
".css",
".csv",
".fgd",
".gi",
".h",
".hpp",
".htm",
".html",
".inf",
".ini",
".js",
".json",
".jsonc",
".kv3",
".lua",
".md",
".nut",
".php",
".plist",
".prefs",
".py",
".pyc",
".qss",
".sh",
".toml",
".ts",
".txt",
".vcfg",
".vdf",
".vmt",
".vqlayout",
".vsc",
".vsnd_template",
".xml",
".yaml",
".yml",
}
_PRESENTATION_SET: set[str] = {
".key",
".odp",
".ppt",
".pptx",
}
_PROGRAM_SET: set[str] = {".app", ".exe"}
_SOURCE_ENGINE_SET: set[str] = {
".vtf",
}
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
_SPREADSHEET_SET: set[str] = {
".csv",
".numbers",
".ods",
".xls",
".xlsx",
}
_VIDEO_SET: set[str] = {
".3gp",
".avi",
".flv",
".gifv",
".hevc",
".m4p",
".m4v",
".mkv",
".mov",
".mp4",
".webm",
".wmv",
}
ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.ADOBE_PHOTOSHOP,
extensions=_ADOBE_PHOTOSHOP_SET,
is_iana=False,
name="photoshop",
)
AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.AFFINITY_PHOTO,
extensions=_AFFINITY_PHOTO_SET,
is_iana=False,
name="affinity photo",
)
ARCHIVE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.ARCHIVE,
extensions=_ARCHIVE_SET,
is_iana=False,
name="archive",
)
AUDIO_MIDI_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.AUDIO_MIDI,
extensions=_AUDIO_MIDI_SET,
is_iana=False,
name="audio midi",
)
AUDIO_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.AUDIO,
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
is_iana=True,
name="audio",
)
BLENDER_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.BLENDER,
extensions=_BLENDER_SET,
is_iana=False,
name="blender",
)
DATABASE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.DATABASE,
extensions=_DATABASE_SET,
is_iana=False,
name="database",
)
DISK_IMAGE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.DISK_IMAGE,
extensions=_DISK_IMAGE_SET,
is_iana=False,
name="disk image",
)
DOCUMENT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.DOCUMENT,
extensions=_DOCUMENT_SET,
is_iana=False,
name="document",
)
EBOOK_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.EBOOK,
extensions=_EBOOK_SET,
is_iana=False,
name="ebook",
)
FONT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.FONT,
extensions=_FONT_SET,
is_iana=True,
name="font",
)
IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_ANIMATED,
extensions=_IMAGE_ANIMATED_SET,
is_iana=False,
name="animated image",
)
IMAGE_RAW_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_RAW,
extensions=_IMAGE_RAW_SET,
is_iana=False,
name="raw image",
)
IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_VECTOR,
extensions=_IMAGE_VECTOR_SET,
is_iana=False,
name="vector image",
)
IMAGE_RASTER_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE,
extensions=_IMAGE_RASTER_SET,
is_iana=False,
name="raster image",
)
IMAGE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE,
extensions=_IMAGE_RASTER_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET,
is_iana=True,
name="image",
)
INSTALLER_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.INSTALLER,
extensions=_INSTALLER_SET,
is_iana=False,
name="installer",
)
MATERIAL_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.MATERIAL,
extensions=_MATERIAL_SET,
is_iana=False,
name="material",
)
MODEL_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.MODEL,
extensions=_MODEL_SET,
is_iana=True,
name="model",
)
OPEN_DOCUMENT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.OPEN_DOCUMENT,
extensions=_OPEN_DOCUMENT_SET,
is_iana=False,
name="open document",
)
PACKAGE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PACKAGE,
extensions=_PACKAGE_SET,
is_iana=False,
name="package",
)
PDF_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PDF,
extensions=_PDF_SET,
is_iana=False,
name="pdf",
)
PLAINTEXT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PLAINTEXT,
extensions=_PLAINTEXT_SET,
is_iana=False,
name="plaintext",
)
PRESENTATION_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PRESENTATION,
extensions=_PRESENTATION_SET,
is_iana=False,
name="presentation",
)
PROGRAM_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.PROGRAM,
extensions=_PROGRAM_SET,
is_iana=False,
name="program",
)
SHORTCUT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SHORTCUT,
extensions=_SHORTCUT_SET,
is_iana=False,
name="shortcut",
)
SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SOURCE_ENGINE,
extensions=_SOURCE_ENGINE_SET,
is_iana=False,
name="source engine",
)
SPREADSHEET_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.SPREADSHEET,
extensions=_SPREADSHEET_SET,
is_iana=False,
name="spreadsheet",
)
TEXT_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.TEXT,
extensions=_DOCUMENT_SET | _PLAINTEXT_SET,
is_iana=True,
name="text",
)
VIDEO_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.VIDEO,
extensions=_VIDEO_SET,
is_iana=True,
name="video",
)
ALL_CATEGORIES: list[MediaCategory] = [
ADOBE_PHOTOSHOP_TYPES,
AFFINITY_PHOTO_TYPES,
ARCHIVE_TYPES,
AUDIO_MIDI_TYPES,
AUDIO_TYPES,
BLENDER_TYPES,
DATABASE_TYPES,
DISK_IMAGE_TYPES,
DOCUMENT_TYPES,
EBOOK_TYPES,
FONT_TYPES,
IMAGE_ANIMATED_TYPES,
IMAGE_RAW_TYPES,
IMAGE_TYPES,
IMAGE_VECTOR_TYPES,
INSTALLER_TYPES,
MATERIAL_TYPES,
MODEL_TYPES,
OPEN_DOCUMENT_TYPES,
PACKAGE_TYPES,
PDF_TYPES,
PLAINTEXT_TYPES,
PRESENTATION_TYPES,
PROGRAM_TYPES,
SHORTCUT_TYPES,
SOURCE_ENGINE_TYPES,
SPREADSHEET_TYPES,
TEXT_TYPES,
VIDEO_TYPES,
]
@staticmethod
def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]:
"""Return a set of MediaTypes given a file extension.
Args:
ext (str): File extension with a leading "." and in all lowercase.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
media_types: set[MediaType] = set()
# mime_guess: bool = False
for cat in MediaCategories.ALL_CATEGORIES:
if ext in cat.extensions:
media_types.add(cat.media_type)
elif mime_fallback and cat.is_iana:
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if mime_type and mime_type.startswith(cat.media_type.value):
media_types.add(cat.media_type)
# mime_guess = True
return media_types
@staticmethod
def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool:
"""Check if an extension is a member of a MediaCategory.
Args:
ext (str): File extension with a leading "." and in all lowercase.
media_cat (MediaCategory): The MediaCategory to to check for extension membership.
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
"""
if ext in media_cat.extensions:
return True
elif mime_fallback and media_cat.is_iana:
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
if mime_type and mime_type.startswith(media_cat.media_type.value):
return True
return False
+76 -1
View File
@@ -19,6 +19,15 @@ class ColorType(IntEnum):
DARK_ACCENT = 4
class UiColor(IntEnum):
DEFAULT = 0
THEME_DARK = 1
THEME_LIGHT = 2
RED = 3
GREEN = 4
PURPLE = 5
TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = {
TagColor.DEFAULT: {
ColorType.PRIMARY: "#1e1e1e",
@@ -283,8 +292,56 @@ TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = {
},
}
UI_COLORS: dict[UiColor, dict[ColorType, Any]] = {
UiColor.DEFAULT: {
ColorType.PRIMARY: "#333333",
ColorType.BORDER: "#555555",
ColorType.LIGHT_ACCENT: "#FFFFFF",
ColorType.DARK_ACCENT: "#1e1e1e",
},
UiColor.RED: {
ColorType.PRIMARY: "#e22c3c",
ColorType.BORDER: "#e54252",
ColorType.LIGHT_ACCENT: "#f39caa",
ColorType.DARK_ACCENT: "#440d12",
},
UiColor.GREEN: {
ColorType.PRIMARY: "#28bb48",
ColorType.BORDER: "#43c568",
ColorType.LIGHT_ACCENT: "#DDFFCC",
ColorType.DARK_ACCENT: "#0d3828",
},
UiColor.PURPLE: {
ColorType.PRIMARY: "#C76FF3",
ColorType.BORDER: "#c364f2",
ColorType.LIGHT_ACCENT: "#EFD4FB",
ColorType.DARK_ACCENT: "#3E1555",
},
UiColor.THEME_DARK: {
ColorType.PRIMARY: "#333333",
ColorType.BORDER: "#555555",
ColorType.LIGHT_ACCENT: "#FFFFFF",
ColorType.DARK_ACCENT: "#1e1e1e",
},
UiColor.THEME_LIGHT: {
ColorType.PRIMARY: "#FFFFFF",
ColorType.BORDER: "#333333",
ColorType.LIGHT_ACCENT: "#999999",
ColorType.DARK_ACCENT: "#888888",
},
}
def get_tag_color(color_type: ColorType, color_id: TagColor) -> str:
"""Return a hex value given a tag color name and ColorType.
Args:
color_type (ColorType): The ColorType category to retrieve from.
color_id (ColorType): The color name enum to retrieve from.
Return:
A hex value string representing a color with a leading "#".
"""
try:
if color_type == ColorType.TEXT:
text_account: ColorType = TAG_COLORS[color_id][color_type]
@@ -293,5 +350,23 @@ def get_tag_color(color_type: ColorType, color_id: TagColor) -> str:
return TAG_COLORS[color_id][color_type]
except KeyError:
traceback.print_stack()
logger.error("Color not found", color_id=color_id)
logger.error("[PALETTE] Tag color not found.", color_id=color_id)
return "#FF00FF"
def get_ui_color(color_type: ColorType, color_id: UiColor) -> str:
"""Return a hex value given a UI color name and ColorType.
Args:
color_type (ColorType): The ColorType category to retrieve from.
color_id (UiColor): The color name enum to retrieve from.
Return:
A hex value string representing a color with a leading "#".
"""
try:
return UI_COLORS[color_id][color_type]
except KeyError:
traceback.print_stack()
logger.error("[PALETTE] UI color not found", color_id=color_id)
return "#FF00FF"
+11
View File
@@ -0,0 +1,11 @@
from src.core.query_lang.ast import ( # noqa
AST,
ANDList,
BaseVisitor,
Constraint,
ConstraintType,
ORList,
Property,
)
from src.core.query_lang.parser import Parser # noqa
from src.core.query_lang.util import ParsingError # noqa
+126
View File
@@ -0,0 +1,126 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import Generic, TypeVar
class ConstraintType(Enum):
Tag = 0
TagID = 1
MediaType = 2
FileType = 3
Path = 4
Special = 5
@staticmethod
def from_string(text: str) -> "ConstraintType":
return {
"tag": ConstraintType.Tag,
"tag_id": ConstraintType.TagID,
"mediatype": ConstraintType.MediaType,
"filetype": ConstraintType.FileType,
"path": ConstraintType.Path,
"special": ConstraintType.Special,
}.get(text.lower(), None)
class AST:
parent: "AST" = None
def __str__(self):
class_name = self.__class__.__name__
fields = vars(self) # Get all instance variables as a dictionary
field_str = ", ".join(f"{key}={value}" for key, value in fields.items())
return f"{class_name}({field_str})"
def __repr__(self) -> str:
return self.__str__()
class ANDList(AST):
terms: list[AST]
def __init__(self, terms: list[AST]) -> None:
super().__init__()
for term in terms:
term.parent = self
self.terms = terms
class ORList(AST):
elements: list[AST]
def __init__(self, elements: list[AST]) -> None:
super().__init__()
for element in elements:
element.parent = self
self.elements = elements
class Constraint(AST):
type: ConstraintType
value: str
properties: list["Property"]
def __init__(self, type: ConstraintType, value: str, properties: list["Property"]) -> None:
super().__init__()
for prop in properties:
prop.parent = self
self.type = type
self.value = value
self.properties = properties
class Property(AST):
key: str
value: str
def __init__(self, key: str, value: str) -> None:
super().__init__()
self.key = key
self.value = value
class Not(AST):
child: AST
def __init__(self, child: AST) -> None:
super().__init__()
self.child = child
T = TypeVar("T")
class BaseVisitor(ABC, Generic[T]):
def visit(self, node: AST) -> T:
if isinstance(node, ANDList):
return self.visit_and_list(node)
elif isinstance(node, ORList):
return self.visit_or_list(node)
elif isinstance(node, Constraint):
return self.visit_constraint(node)
elif isinstance(node, Property):
return self.visit_property(node)
elif isinstance(node, Not):
return self.visit_not(node)
raise Exception(f"Unknown Node Type of {node}") # pragma: nocover
@abstractmethod
def visit_and_list(self, node: ANDList) -> T:
raise NotImplementedError() # pragma: nocover
@abstractmethod
def visit_or_list(self, node: ORList) -> T:
raise NotImplementedError() # pragma: nocover
@abstractmethod
def visit_constraint(self, node: Constraint) -> T:
raise NotImplementedError() # pragma: nocover
@abstractmethod
def visit_property(self, node: Property) -> T:
raise NotImplementedError() # pragma: nocover
@abstractmethod
def visit_not(self, node: Not) -> T:
raise NotImplementedError() # pragma: nocover
+120
View File
@@ -0,0 +1,120 @@
from .ast import AST, ANDList, Constraint, Not, ORList, Property
from .tokenizer import ConstraintType, Token, Tokenizer, TokenType
from .util import ParsingError
class Parser:
text: str
tokenizer: Tokenizer
next_token: Token
last_constraint_type: ConstraintType = ConstraintType.Tag
def __init__(self, text: str) -> None:
self.text = text
self.tokenizer = Tokenizer(self.text)
self.next_token = self.tokenizer.get_next_token()
def parse(self) -> AST:
if self.next_token.type == TokenType.EOF:
return ORList([])
out = self.__or_list()
if self.next_token.type != TokenType.EOF:
raise ParsingError(self.next_token.start, self.next_token.end, "Syntax Error")
return out
def __or_list(self) -> AST:
terms = [self.__and_list()]
while self.__is_next_or():
self.__eat(TokenType.ULITERAL)
terms.append(self.__and_list())
return ORList(terms) if len(terms) > 1 else terms[0]
def __is_next_or(self) -> bool:
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "OR"
def __and_list(self) -> AST:
elements = [self.__term()]
while (
self.next_token.type
in [
TokenType.QLITERAL,
TokenType.ULITERAL,
TokenType.CONSTRAINTTYPE,
TokenType.RBRACKETO,
]
and not self.__is_next_or()
):
self.__skip_and()
elements.append(self.__term())
return ANDList(elements) if len(elements) > 1 else elements[0]
def __skip_and(self) -> None:
if self.__is_next_and():
self.__eat(TokenType.ULITERAL)
if self.__is_next_and():
raise self.__syntax_error("Unexpected AND")
def __is_next_and(self) -> bool:
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "AND"
def __term(self) -> AST:
if self.__is_next_not():
self.__eat(TokenType.ULITERAL)
term = self.__term()
if isinstance(term, Not): # instead of Not(Not(child)) return child
return term.child
return Not(term)
if self.next_token.type == TokenType.RBRACKETO:
self.__eat(TokenType.RBRACKETO)
out = self.__or_list()
self.__eat(TokenType.RBRACKETC)
return out
else:
return self.__constraint()
def __is_next_not(self) -> bool:
return self.next_token.type == TokenType.ULITERAL and self.next_token.value.upper() == "NOT"
def __constraint(self) -> Constraint:
if self.next_token.type == TokenType.CONSTRAINTTYPE:
self.last_constraint_type = self.__eat(TokenType.CONSTRAINTTYPE).value
value = self.__literal()
properties = []
if self.next_token.type == TokenType.SBRACKETO:
self.__eat(TokenType.SBRACKETO)
properties.append(self.__property())
while self.next_token.type == TokenType.COMMA:
self.__eat(TokenType.COMMA)
properties.append(self.__property())
self.__eat(TokenType.SBRACKETC)
return Constraint(self.last_constraint_type, value, properties)
def __property(self) -> Property:
key = self.__eat(TokenType.ULITERAL).value
self.__eat(TokenType.EQUALS)
value = self.__literal()
return Property(key, value)
def __literal(self) -> str:
if self.next_token.type in [TokenType.QLITERAL, TokenType.ULITERAL]:
return self.__eat(self.next_token.type).value
raise self.__syntax_error()
def __eat(self, type: TokenType) -> Token:
if self.next_token.type != type:
raise self.__syntax_error(f"expected {type} found {self.next_token.type}")
out = self.next_token
self.next_token = self.tokenizer.get_next_token()
return out
def __syntax_error(self, msg: str = "Syntax Error") -> ParsingError:
return ParsingError(self.next_token.start, self.next_token.end, msg)
+147
View File
@@ -0,0 +1,147 @@
from enum import Enum
from typing import Any
from .ast import ConstraintType
from .util import ParsingError
class TokenType(Enum):
EOF = -1
QLITERAL = 0 # Quoted Literal
ULITERAL = 1 # Unquoted Literal (does not contain ":", " ", "[", "]", "(", ")", "=", ",")
RBRACKETO = 2 # Round Bracket Open
RBRACKETC = 3 # Round Bracket Close
SBRACKETO = 4 # Square Bracket Open
SBRACKETC = 5 # Square Bracket Close
CONSTRAINTTYPE = 6
COLON = 10
COMMA = 11
EQUALS = 12
class Token:
type: TokenType
value: Any
start: int
end: int
def __init__(self, type: TokenType, value: Any, start: int = None, end: int = None) -> None:
self.type = type
self.value = value
self.start = start
self.end = end
@staticmethod
def from_type(type: TokenType, pos: int = None) -> "Token":
return Token(type, None, pos, pos)
@staticmethod
def EOF() -> "Token": # noqa: N802
return Token.from_type(TokenType.EOF)
def __str__(self) -> str:
return f"Token({self.type}, {self.value}, {self.start}, {self.end})" # pragma: nocover
def __repr__(self) -> str:
return self.__str__() # pragma: nocover
class Tokenizer:
text: str
pos: int
current_char: str
ESCAPABLE_CHARS = ["\\", '"', '"']
NOT_IN_ULITERAL = [":", " ", "[", "]", "(", ")", "=", ","]
def __init__(self, text: str) -> None:
self.text = text
self.pos = 0
self.current_char = self.text[self.pos] if len(text) > 0 else None
def get_next_token(self) -> Token:
self.__skip_whitespace()
if self.current_char is None:
return Token.EOF()
if self.current_char in ("'", '"'):
return self.__quoted_string()
elif self.current_char == "(":
self.__advance()
return Token.from_type(TokenType.RBRACKETO, self.pos - 1)
elif self.current_char == ")":
self.__advance()
return Token.from_type(TokenType.RBRACKETC, self.pos - 1)
elif self.current_char == "[":
self.__advance()
return Token.from_type(TokenType.SBRACKETO, self.pos - 1)
elif self.current_char == "]":
self.__advance()
return Token.from_type(TokenType.SBRACKETC, self.pos - 1)
elif self.current_char == ",":
self.__advance()
return Token.from_type(TokenType.COMMA, self.pos - 1)
elif self.current_char == "=":
self.__advance()
return Token.from_type(TokenType.EQUALS, self.pos - 1)
else:
return self.__unquoted_string_or_constraint_type()
def __unquoted_string_or_constraint_type(self) -> Token:
out = ""
start = self.pos
while self.current_char not in self.NOT_IN_ULITERAL and self.current_char is not None:
out += self.current_char
self.__advance()
end = self.pos - 1
if self.current_char == ":":
if len(out) == 0:
raise ParsingError(self.pos, self.pos)
self.__advance()
constraint_type = ConstraintType.from_string(out)
if constraint_type is None:
raise ParsingError(start, end, f'Invalid ContraintType "{out}"')
return Token(TokenType.CONSTRAINTTYPE, constraint_type, start, end)
else:
return Token(TokenType.ULITERAL, out, start, end)
def __quoted_string(self) -> Token:
start = self.pos
quote = self.current_char
self.__advance()
escape = False
out = ""
while escape or self.current_char != quote:
if escape:
escape = False
if self.current_char not in Tokenizer.ESCAPABLE_CHARS:
out += "\\"
else:
out += self.current_char
self.__advance()
continue
if self.current_char == "\\":
escape = True
else:
out += self.current_char
self.__advance()
end = self.pos
self.__advance()
return Token(TokenType.QLITERAL, out, start, end)
def __advance(self) -> None:
if self.pos < len(self.text) - 1:
self.pos += 1
self.current_char = self.text[self.pos]
else:
self.current_char = None
def __skip_whitespace(self) -> None:
while self.current_char is not None and self.current_char.isspace():
self.__advance()
+15
View File
@@ -0,0 +1,15 @@
class ParsingError(BaseException):
start: int
end: int
msg: str
def __init__(self, start: int, end: int, msg: str = "Syntax Error") -> None:
self.start = start
self.end = end
self.msg = msg
def __str__(self) -> str:
return f"Syntax Error {self.start}->{self.end}: {self.msg}" # pragma: nocover
def __repr__(self) -> str:
return self.__str__() # pragma: nocover
+1 -1
View File
@@ -24,7 +24,7 @@ class TagStudioCore:
Return a formatted object with notable values or an empty object if none is found.
"""
info = {}
_filepath = filepath.parent / (filepath.stem + ".json")
_filepath = filepath.parent / (filepath.name + ".json")
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
+1 -1
View File
@@ -50,7 +50,7 @@ class DupeRegistry:
continue
results = self.library.search_library(
FilterState(path=path_relative),
FilterState.from_path(path_relative),
)
if not results:
+41 -11
View File
@@ -9,6 +9,20 @@ from src.core.library import Entry, Library
logger = structlog.get_logger(__name__)
GLOBAL_IGNORE_SET: set[str] = set(
[
TS_FOLDER_NAME,
"$RECYCLE.BIN",
".Trashes",
".Trash",
"tagstudio_thumbs",
".fseventsd",
".Spotlight-V100",
"System Volume Information",
".DS_Store",
]
)
@dataclass
class RefreshDirTracker:
@@ -49,29 +63,45 @@ class RefreshDirTracker:
self.files_not_in_library = []
dir_file_count = 0
for path in lib_path.glob("**/*"):
str_path = str(path)
if path.is_dir():
for f in lib_path.glob("**/*"):
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
if "$RECYCLE.BIN" in str_path or TS_FOLDER_NAME in str_path:
# Ignore if the file is a directory
if f.is_dir():
continue
# Ensure new file isn't in a globally ignored folder
skip: bool = False
for part in f.parts:
if part in GLOBAL_IGNORE_SET:
skip = True
break
if skip:
continue
dir_file_count += 1
relative_path = path.relative_to(lib_path)
self.library.included_files.add(f)
relative_path = f.relative_to(lib_path)
# TODO - load these in batch somehow
if not self.library.has_path_entry(relative_path):
self.files_not_in_library.append(relative_path)
# Yield output every 1/30 of a second
if (time() - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
end_time_total = time()
yield dir_file_count
logger.info(
"Directory scan time",
path=lib_path,
duration=(end_time_total - start_time_total),
new_files_count=dir_file_count,
files_not_in_lib=self.files_not_in_library,
files_scanned=dir_file_count,
)
+3
View File
@@ -140,4 +140,7 @@ class FlowLayout(QLayout):
x = next_x
line_height = max(line_height, item.sizeHint().height())
if len(self._item_list) == 0:
return 0
return y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list))
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
## This file is a modified script that gets the thumbnail data stored in a blend file
import gzip
import os
import struct
from io import BufferedReader
from PIL import (
Image,
ImageOps,
)
def blend_extract_thumb(path):
rend = b"REND"
test = b"TEST"
blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115
head = blendfile.read(12)
if head[0:2] == b"\x1f\x8b": # gzip magic
blendfile.close()
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115
head = blendfile.read(12)
if not head.startswith(b"BLENDER"):
blendfile.close()
return None, 0, 0
is_64_bit = head[7] == b"-"[0]
# true for PPC, false for X86
is_big_endian = head[8] == b"V"[0]
# blender pre 2.5 had no thumbs
if head[9:11] <= b"24":
return None, 0, 0
sizeof_bhead = 24 if is_64_bit else 20
int_endian = ">i" if is_big_endian else "<i"
int_endian_pair = int_endian + "i"
while True:
bhead = blendfile.read(sizeof_bhead)
if len(bhead) < sizeof_bhead:
return None, 0, 0
code = bhead[:4]
length = struct.unpack(int_endian, bhead[4:8])[0] # 4 == sizeof(int)
if code == rend:
blendfile.seek(length, os.SEEK_CUR)
else:
break
if code != test:
return None, 0, 0
try:
x, y = struct.unpack(int_endian_pair, blendfile.read(8)) # 8 == sizeof(int) * 2
except struct.error:
return None, 0, 0
length -= 8 # sizeof(int) * 2
if length != x * y * 4:
return None, 0, 0
image_buffer = blendfile.read(length)
if len(image_buffer) != length:
return None, 0, 0
return image_buffer, x, y
def blend_thumb(file_in):
buf, width, height = blend_extract_thumb(file_in)
image = Image.frombuffer(
"RGBA",
(width, height),
buf,
)
image = ImageOps.flip(image)
return image
+11 -6
View File
@@ -10,21 +10,26 @@ from src.qt.helpers.gradient import linear_gradient
# TODO: Consolidate the built-in QT theme values with the values
# here, in enums.py, and in palette.py.
_THEME_DARK_FG: str = "#FFFFFF55"
_THEME_DARK_FG: str = "#FFFFFF77"
_THEME_LIGHT_FG: str = "#000000DD"
_THEME_DARK_BG: str = "#000000DD"
_THEME_LIGHT_BG: str = "#FFFFFF55"
def theme_fg_overlay(image: Image.Image) -> Image.Image:
def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
"""Overlay the foreground theme color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
"""
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
light_fg: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG
overlay_color = (
_THEME_DARK_FG
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else _THEME_LIGHT_FG
dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg
)
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
return _apply_overlay(image, im)
@@ -42,7 +47,7 @@ def gradient_overlay(image: Image.Image, gradient=list[str]) -> Image.Image:
def _apply_overlay(image: Image.Image, overlay: Image.Image) -> Image.Image:
"""Apply an overlay on top of an image, using the image's alpha channel as a mask.
"""Apply an overlay on top of an image using the image's alpha channel as a mask.
Args:
image (Image): The PIL Image object to apply an overlay to.
+14 -14
View File
@@ -19,9 +19,9 @@ def open_file(path: str | Path, file_manager: bool = False):
"""Open a file in the default application or file explorer.
Args:
path (str): The path to the file to open.
file_manager (bool, optional): Whether to open the file in the file manager
(e.g. Finder on macOS). Defaults to False.
path (str): The path to the file to open.
file_manager (bool, optional): Whether to open the file in the file manager
(e.g. Finder on macOS). Defaults to False.
"""
path = Path(path)
logger.info("Opening file", path=path)
@@ -31,14 +31,15 @@ def open_file(path: str | Path, file_manager: bool = False):
try:
if sys.platform == "win32":
normpath = Path(path).resolve().as_posix()
normpath = str(Path(path).resolve())
if file_manager:
command_name = "explorer"
command_args = '/select,"' + normpath + '"'
command_arg = f'/select,"{normpath}"'
# For some reason, if the args are passed in a list, this will error when the
# path has spaces, even while surrounded in double quotes.
subprocess.Popen(
command_name + command_args,
command_name + command_arg,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
@@ -92,7 +93,7 @@ class FileOpenerHelper:
"""Initialize the FileOpenerHelper.
Args:
filepath (str): The path to the file to open.
filepath (str): The path to the file to open.
"""
self.filepath = str(filepath)
@@ -100,7 +101,7 @@ class FileOpenerHelper:
"""Set the filepath to open.
Args:
filepath (str): The path to the file to open.
filepath (str): The path to the file to open.
"""
self.filepath = str(filepath)
@@ -114,20 +115,19 @@ class FileOpenerHelper:
class FileOpenerLabel(QLabel):
def __init__(self, text, parent=None):
def __init__(self, parent=None):
"""Initialize the FileOpenerLabel.
Args:
text (str): The text to display.
parent (QWidget, optional): The parent widget. Defaults to None.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(text, parent)
super().__init__(parent)
def set_file_path(self, filepath):
"""Set the filepath to open.
Args:
filepath (str): The path to the file to open.
filepath (str): The path to the file to open.
"""
self.filepath = filepath
@@ -138,7 +138,7 @@ class FileOpenerLabel(QLabel):
On a right click, show a context menu.
Args:
event (QMouseEvent): The mouse press event.
event (QMouseEvent): The mouse press event.
"""
super().mousePressEvent(event)
+32
View File
@@ -0,0 +1,32 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from pathlib import Path
import ffmpeg
from src.qt.helpers.vendored.ffmpeg import _probe
def is_readable_video(filepath: Path | str):
"""Test if a video is in a readable format.
Examples of unreadable videos include files with undetermined codecs and DRM-protected content.
Args:
filepath (Path | str): The filepath of the video to check.
"""
try:
probe = _probe(Path(filepath))
for stream in probe["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
"drma",
"drms",
"drmi",
]:
return False
except ffmpeg.Error:
return False
return True
+15 -28
View File
@@ -2,26 +2,14 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PIL import Image, ImageChops, ImageEnhance
from PIL import Image
def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> Image.Image:
if image.size != (adj_size, adj_size):
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = (
# image.copy()
# .resize((2, 2), resample=Image.Resampling.BILINEAR)
# .resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR)
# )
def four_corner_gradient(
image: Image.Image, size: tuple[int, int], mask: Image.Image
) -> Image.Image:
if image.size != size:
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0] - 1), 0))
bl = image.getpixel((0, (image.size[1] - 1)))
@@ -31,26 +19,25 @@ def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> I
bg.paste(tr, (1, 0, 2, 2))
bg.paste(bl, (0, 1, 2, 2))
bg.paste(br, (1, 1, 2, 2))
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
bg = bg.resize(size, resample=Image.Resampling.BICUBIC)
bg.paste(
image,
box=(
(adj_size - image.size[0]) // 2,
(adj_size - image.size[1]) // 2,
(size[0] - image.size[0]) // 2,
(size[1] - image.size[1]) // 2,
),
)
bg.putalpha(mask)
final = bg
final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
final.paste(bg, mask=mask.getchannel(0))
else:
image.putalpha(mask)
final = image
final = Image.new("RGBA", size, (0, 0, 0, 0))
final.paste(image, mask=mask.getchannel(0))
if final.mode != "RGBA":
final = final.convert("RGBA")
hl_soft = hl.copy()
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
return final
+27
View File
@@ -0,0 +1,27 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import numpy as np
from PIL import Image
def replace_transparent_pixels(
img: Image.Image, color: tuple[int, int, int, int] = (255, 255, 255, 255)
) -> Image.Image:
"""Replace (copying/without mutating) all transparent pixels in an image with the color.
Args:
img (Image.Image):
The source image
color (tuple[int, int, int, int]):
The color (RGBA, 0 to 255) which transparent pixels should be set to.
Defaults to white (255, 255, 255, 255)
Returns:
Image.Image:
A copy of img with the pixels replaced.
"""
pixel_array = np.asarray(img.convert("RGBA")).copy()
pixel_array[pixel_array[:, :, 3] == 0] = color
return Image.fromarray(pixel_array)
@@ -0,0 +1,29 @@
# Based on the implementation by eyllanesc:
# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius
# Licensed under the Creative Commons CC BY-SA 4.0 License:
# https://creativecommons.org/licenses/by-sa/4.0/
# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap
from PySide6.QtWidgets import (
QProxyStyle,
)
class RoundedPixmapStyle(QProxyStyle):
def __init__(self, radius=8):
super().__init__()
self._radius = radius
def drawItemPixmap(self, painter, rectangle, alignment, pixmap): # noqa: N802
painter.save()
pix = QPixmap(pixmap.size())
pix.fill(QColor("transparent"))
p = QPainter(pix)
p.setBrush(QBrush(pixmap))
p.setPen(QColor("transparent"))
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
p.end()
super().drawItemPixmap(painter, rectangle, alignment, pix)
painter.restore()
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import subprocess
import sys
"""Implementation of subprocess.Popen that does not spawn console windows or log output."""
def promptless_Popen( # noqa: N802
args,
bufsize=-1,
executable=None,
stdin=None,
stdout=None,
stderr=None,
preexec_fn=None,
close_fds=True,
shell=False,
cwd=None,
env=None,
universal_newlines=None,
startupinfo=None,
restore_signals=True,
start_new_session=False,
pass_fds=(),
*,
group=None,
extra_groups=None,
user=None,
umask=-1,
encoding=None,
errors=None,
text=None,
pipesize=-1,
process_group=None,
):
"""Call subprocess.Popen without creating a console window."""
creation_flags = 0
if sys.platform == "win32":
creation_flags = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(
args=args,
bufsize=bufsize,
executable=executable,
stdin=stdin,
stdout=stdout,
stderr=stderr,
preexec_fn=preexec_fn,
close_fds=close_fds,
shell=shell,
cwd=cwd,
env=env,
universal_newlines=universal_newlines,
startupinfo=startupinfo,
creationflags=creation_flags,
restore_signals=restore_signals,
start_new_session=start_new_session,
pass_fds=pass_fds,
group=group,
extra_groups=extra_groups,
user=user,
umask=umask,
encoding=encoding,
errors=errors,
text=text,
pipesize=pipesize,
process_group=process_group,
)
+49
View File
@@ -0,0 +1,49 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PIL import Image, ImageDraw, ImageFont
def wrap_line( # type: ignore
text: str,
font: ImageFont.ImageFont,
width: int = 256,
draw: ImageDraw.ImageDraw = None,
) -> int:
"""Take in a single text line and return the index it should be broken up at.
Only splits once.
"""
if draw is None:
bg = Image.new("RGB", (width, width), color="#1e1e1e")
draw = ImageDraw.Draw(bg)
if draw.textlength(text, font=font) > width:
for i in range(
int(len(text) / int(draw.textlength(text, font=font)) * width) - 2,
0,
-1,
):
if draw.textlength(text[:i], font=font) < width:
return i
else:
return -1
def wrap_full_text(
text: str,
font: ImageFont.ImageFont,
width: int = 256,
draw: ImageDraw.ImageDraw = None,
) -> str:
"""Break up a string to fit the canvas given a kerning value, font size, etc."""
lines = []
i = 0
last_i = 0
while wrap_line(text[i:], font=font, width=width, draw=draw) > 0:
i = wrap_line(text[i:], font=font, width=width, draw=draw) + last_i
lines.append(text[last_i:i])
last_i = i
lines.append(text[last_i:])
text_wrapped = "\n".join(lines)
return text_wrapped
@@ -0,0 +1,54 @@
# Copyright (C) 2022 Karl Kroening (kkroening).
# Licensed under the GPL-3.0 License.
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
import json
import platform
import shutil
import subprocess
import ffmpeg
import structlog
from src.qt.helpers.silent_popen import promptless_Popen
logger = structlog.get_logger(__name__)
FFMPEG_MACOS_LOCATIONS: list[str] = ["", "/opt/homebrew/bin/", "/usr/local/bin/"]
def _get_ffprobe_location() -> str:
cmd: str = "ffprobe"
if platform.system() == "Darwin":
for loc in FFMPEG_MACOS_LOCATIONS:
if shutil.which(loc + cmd):
cmd = loc + cmd
break
logger.info(f"[FFPROBE] Using FFmpeg location: {cmd}")
return cmd
FFPROBE_CMD = _get_ffprobe_location()
def _probe(filename, cmd=FFPROBE_CMD, timeout=None, **kwargs):
"""Run ffprobe on the specified file and return a JSON representation of the output.
Raises:
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
an :class:`Error` is returned with a generic error message.
The stderr output can be retrieved by accessing the
``stderr`` property of the exception.
"""
args = [cmd, "-show_format", "-show_streams", "-of", "json"]
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs)
args += [filename]
# PATCHED
p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
communicate_kwargs = {}
if timeout is not None:
communicate_kwargs["timeout"] = timeout
out, err = p.communicate(**communicate_kwargs)
if p.returncode != 0:
raise ffmpeg.Error("ffprobe", out, err)
return json.loads(out.decode("utf-8"))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
import json
import re
import subprocess
from pydub.utils import (
_fd_or_path_or_tempfile,
fsdecode,
get_extra_info,
get_prober_name,
)
from src.qt.helpers.silent_popen import promptless_Popen
def _mediainfo_json(filepath, read_ahead_limit=-1):
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
prober = get_prober_name()
command_args = [
"-v",
"info",
"-show_format",
"-show_streams",
]
try:
command_args += [fsdecode(filepath)]
stdin_parameter = None
stdin_data = None
except TypeError:
if prober == "ffprobe":
command_args += ["-read_ahead_limit", str(read_ahead_limit), "cache:pipe:0"]
else:
command_args += ["-"]
stdin_parameter = subprocess.PIPE
file, close_file = _fd_or_path_or_tempfile(filepath, "rb", tempfile=False)
file.seek(0)
stdin_data = file.read()
if close_file:
file.close()
command = [prober, "-of", "json"] + command_args
# PATCHED
res = promptless_Popen(
command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
output, stderr = res.communicate(input=stdin_data)
output = output.decode("utf-8", "ignore")
stderr = stderr.decode("utf-8", "ignore")
try:
info = json.loads(output)
except json.decoder.JSONDecodeError:
# If ffprobe didn't give any information, just return it
# (for example, because the file doesn't exist)
return None
if not info:
return info
extra_info = get_extra_info(stderr)
audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"]
if len(audio_streams) == 0:
return info
# We just operate on the first audio stream in case there are more
stream = audio_streams[0]
def set_property(stream, prop, value):
if prop not in stream or stream[prop] == 0:
stream[prop] = value
for token in extra_info[stream["index"]]:
m = re.match(r"([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$", token)
m2 = re.match(r"([su]([0-9]{1,2})p?)( \(default\))?$", token)
if m:
set_property(stream, "sample_fmt", m.group(1))
set_property(stream, "bits_per_sample", int(m.group(2)))
set_property(stream, "bits_per_raw_sample", int(m.group(3)))
elif m2:
set_property(stream, "sample_fmt", m2.group(1))
set_property(stream, "bits_per_sample", int(m2.group(2)))
set_property(stream, "bits_per_raw_sample", int(m2.group(2)))
elif re.match(r"(flt)p?( \(default\))?$", token):
set_property(stream, "sample_fmt", token)
set_property(stream, "bits_per_sample", 32)
set_property(stream, "bits_per_raw_sample", 32)
elif re.match(r"(dbl)p?( \(default\))?$", token):
set_property(stream, "sample_fmt", token)
set_property(stream, "bits_per_sample", 64)
set_property(stream, "bits_per_raw_sample", 64)
return info
+19 -24
View File
@@ -15,13 +15,13 @@
import logging
import typing
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt)
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt, QStringListModel)
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget, QSplitter, QCheckBox,
QSpacerItem)
QSpacerItem, QCompleter)
from src.qt.pagination import Pagination
from src.qt.widgets.landing import LandingWidget
@@ -36,7 +36,7 @@ class Ui_MainWindow(QMainWindow):
def __init__(self, driver: "QtDriver", parent=None) -> None:
super().__init__(parent)
self.driver = driver
self.driver: "QtDriver" = driver
self.setupUi(self)
# NOTE: These are old attempts to allow for a translucent/acrylic
@@ -66,7 +66,7 @@ class Ui_MainWindow(QMainWindow):
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
# ComboBox goup for search type and thumbnail size
# ComboBox group for search type and thumbnail size
self.horizontalLayout_3 = QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
@@ -74,26 +74,18 @@ class Ui_MainWindow(QMainWindow):
spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_3.addItem(spacerItem)
# Search type selector
self.comboBox_2 = QComboBox(self.centralwidget)
self.comboBox_2.setMinimumSize(QSize(165, 0))
self.comboBox_2.setObjectName("comboBox_2")
self.comboBox_2.addItem("")
self.comboBox_2.addItem("")
self.horizontalLayout_3.addWidget(self.comboBox_2)
# Thumbnail Size placeholder
self.comboBox = QComboBox(self.centralwidget)
self.comboBox.setObjectName(u"comboBox")
self.thumb_size_combobox = QComboBox(self.centralwidget)
self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox")
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.comboBox.sizePolicy().hasHeightForWidth())
self.comboBox.setSizePolicy(sizePolicy)
self.comboBox.setMinimumWidth(128)
self.comboBox.setMaximumWidth(128)
self.horizontalLayout_3.addWidget(self.comboBox)
self.thumb_size_combobox.sizePolicy().hasHeightForWidth())
self.thumb_size_combobox.setSizePolicy(sizePolicy)
self.thumb_size_combobox.setMinimumWidth(128)
self.thumb_size_combobox.setMaximumWidth(352)
self.horizontalLayout_3.addWidget(self.thumb_size_combobox)
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
self.splitter = QSplitter()
@@ -167,6 +159,11 @@ class Ui_MainWindow(QMainWindow):
font2.setBold(False)
self.searchField.setFont(font2)
self.searchFieldCompletionList = QStringListModel()
self.searchFieldCompleter = QCompleter(self.searchFieldCompletionList, self.searchField)
self.searchFieldCompleter.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.searchField.setCompleter(self.searchFieldCompleter)
self.horizontalLayout_2.addWidget(self.searchField)
self.searchButton = QPushButton(self.centralwidget)
@@ -209,13 +206,10 @@ class Ui_MainWindow(QMainWindow):
self.searchButton.setText(
QCoreApplication.translate("MainWindow", u"Search", None))
# Search type selector
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)"))
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)"))
self.comboBox.setCurrentText("")
self.thumb_size_combobox.setCurrentText("")
# Thumbnail size selector
self.comboBox.setPlaceholderText(
self.thumb_size_combobox.setPlaceholderText(
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
# retranslateUi
@@ -236,3 +230,4 @@ class Ui_MainWindow(QMainWindow):
self.landing_widget.setHidden(True)
self.landing_widget.set_status_label("")
self.scrollArea.setHidden(False)
+264 -45
View File
@@ -3,25 +3,31 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
from typing import cast
import structlog
from PySide6 import QtCore
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import (
QAction,
)
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.library import Library, Tag
from src.core.library.alchemy.enums import TagColor
from src.core.palette import ColorType, get_tag_color
from src.core.palette import ColorType, UiColor, get_tag_color, get_ui_color
from src.qt.flowlayout import FlowLayout
from src.qt.modals.tag_search import TagSearchPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import TagWidget
from src.qt.widgets.tag import TagAliasWidget, TagWidget
logger = structlog.get_logger(__name__)
@@ -51,6 +57,9 @@ class BuildTagPanel(PanelWidget):
self.name_title.setText("Name")
self.name_layout.addWidget(self.name_title)
self.name_field = QLineEdit()
self.name_field.setFixedHeight(24)
self.name_field.textChanged.connect(self.on_name_changed)
self.name_field.setPlaceholderText("Tag Name (Required)")
self.name_layout.addWidget(self.name_field)
# Shorthand ------------------------------------------------------------
@@ -76,13 +85,47 @@ class BuildTagPanel(PanelWidget):
self.aliases_title = QLabel()
self.aliases_title.setText("Aliases")
self.aliases_layout.addWidget(self.aliases_title)
self.aliases_field = QTextEdit()
self.aliases_field.setAcceptRichText(False)
self.aliases_field.setMinimumHeight(40)
self.aliases_field.setTabChangesFocus(True)
self.aliases_layout.addWidget(self.aliases_field)
self.aliases_flow_widget = QWidget()
self.aliases_flow_layout = FlowLayout(self.aliases_flow_widget)
self.aliases_flow_layout.setContentsMargins(0, 0, 0, 0)
self.aliases_flow_layout.enable_grid_optimizations(value=False)
self.alias_add_button = QPushButton()
self.alias_add_button.setMinimumSize(23, 23)
self.alias_add_button.setMaximumSize(23, 23)
self.alias_add_button.setText("+")
self.alias_add_button.setToolTip("CTRL + A")
self.alias_add_button.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_A,
)
)
self.alias_add_button.setStyleSheet(
f"QPushButton{{"
f"background: #1e1e1e;"
f"color: #FFFFFF;"
f"font-weight: bold;"
f"border-color: #333333;"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width:{math.ceil(self.devicePixelRatio())}px;"
f"padding-bottom: 5px;"
f"font-size: 20px;"
f"}}"
f"QPushButton::hover"
f"{{"
f"border-color: #CCCCCC;"
f"background: #555555;"
f"}}"
)
self.alias_add_button.clicked.connect(lambda: self.add_alias_callback())
self.aliases_flow_layout.addWidget(self.alias_add_button)
# Subtags ------------------------------------------------------------
self.subtags_widget = QWidget()
self.subtags_layout = QVBoxLayout(self.subtags_widget)
self.subtags_layout.setStretch(1, 1)
@@ -94,28 +137,52 @@ class BuildTagPanel(PanelWidget):
self.subtags_title.setText("Parent Tags")
self.subtags_layout.addWidget(self.subtags_title)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
# self.scroll_area.setMinimumHeight(60)
self.subtags_layout.addWidget(self.scroll_area)
self.subtag_flow_widget = QWidget()
self.subtag_flow_layout = FlowLayout(self.subtag_flow_widget)
self.subtag_flow_layout.setContentsMargins(0, 0, 0, 0)
self.subtag_flow_layout.enable_grid_optimizations(value=False)
self.subtags_add_button = QPushButton()
self.subtags_add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.subtags_add_button.setText("+")
tsp = TagSearchPanel(self.lib)
self.subtags_add_button.setToolTip("CTRL + P")
self.subtags_add_button.setMinimumSize(23, 23)
self.subtags_add_button.setMaximumSize(23, 23)
self.subtags_add_button.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_P,
)
)
self.subtags_add_button.setStyleSheet(
f"QPushButton{{"
f"background: #1e1e1e;"
f"color: #FFFFFF;"
f"font-weight: bold;"
f"border-color: #333333;"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width:{math.ceil(self.devicePixelRatio())}px;"
f"padding-bottom: 5px;"
f"font-size: 20px;"
f"}}"
f"QPushButton::hover"
f"{{"
f"border-color: #CCCCCC;"
f"background: #555555;"
f"}}"
)
self.subtag_flow_layout.addWidget(self.subtags_add_button)
exclude_ids: list[int] = list()
if tag is not None:
exclude_ids.append(tag.id)
tsp = TagSearchPanel(self.lib, exclude_ids)
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.subtags_layout.addWidget(self.subtags_add_button)
# self.subtags_layout.addWidget(self.subtags_add_button)
# self.subtags_field = TagBoxWidget()
# self.subtags_field.setMinimumHeight(60)
@@ -136,7 +203,7 @@ class BuildTagPanel(PanelWidget):
self.color_field.setMaxVisibleItems(10)
self.color_field.setStyleSheet("combobox-popup:0;")
for color in TagColor:
self.color_field.addItem(color.name, userData=color.value)
self.color_field.addItem(color.name.replace("_", " ").title(), userData=color.value)
# self.color_field.setProperty("appearance", "flat")
self.color_field.currentIndexChanged.connect(
lambda c: (
@@ -145,71 +212,223 @@ class BuildTagPanel(PanelWidget):
"font-weight:600;"
f"color:{get_tag_color(ColorType.TEXT, self.color_field.currentData())};"
f"background-color:{get_tag_color(
ColorType.PRIMARY,
ColorType.PRIMARY,
self.color_field.currentData())};"
)
)
)
self.color_layout.addWidget(self.color_field)
remove_selected_alias_action = QAction("remove selected alias", self)
remove_selected_alias_action.triggered.connect(self.remove_selected_alias)
remove_selected_alias_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_D,
)
)
self.addAction(remove_selected_alias_action)
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.shorthand_widget)
self.root_layout.addWidget(self.aliases_widget)
self.root_layout.addWidget(self.aliases_flow_widget)
self.root_layout.addWidget(self.subtags_widget)
self.root_layout.addWidget(self.subtag_flow_widget)
self.root_layout.addWidget(self.color_widget)
# self.parent().done.connect(self.update_tag)
# TODO - fill subtags
self.subtags: set[int] = set()
self.subtag_ids: set[int] = set()
self.alias_ids: set[int] = set()
self.alias_names: set[str] = set()
self.new_alias_names: dict = dict()
self.set_tag(tag or Tag(name="New Tag"))
def keyPressEvent(self, event): # noqa: N802
if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: # type: ignore
focused_widget = QApplication.focusWidget()
if isinstance(focused_widget.parent(), TagAliasWidget):
self.add_alias_callback()
def remove_selected_alias(self):
count = self.aliases_flow_layout.count()
if count <= 0:
return
focused_widget = QApplication.focusWidget()
if focused_widget is None:
return
if isinstance(focused_widget.parent(), TagAliasWidget):
cast(TagAliasWidget, focused_widget.parent()).on_remove.emit()
count = self.aliases_flow_layout.count()
if count > 1:
cast(
TagAliasWidget, self.aliases_flow_layout.itemAt(count - 2).widget()
).text_field.setFocus()
else:
self.alias_add_button.setFocus()
def add_subtag_callback(self, tag_id: int):
logger.info("add_subtag_callback", tag_id=tag_id)
self.subtags.add(tag_id)
self.subtag_ids.add(tag_id)
self.set_subtags()
def remove_subtag_callback(self, tag_id: int):
logger.info("removing subtag", tag_id=tag_id)
self.subtags.remove(tag_id)
self.subtag_ids.remove(tag_id)
self.set_subtags()
def set_subtags(self):
while self.scroll_layout.itemAt(0):
self.scroll_layout.takeAt(0).widget().deleteLater()
def add_alias_callback(self):
logger.info("add_alias_callback")
# bug passing in the text for a here means when the text changes
# the remove callback uses what a whas initialy assigned
new_field = TagAliasWidget()
id = new_field.__hash__()
new_field.id = id
c = QWidget()
layout = QVBoxLayout(c)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
for tag_id in self.subtags:
new_field.on_remove.connect(lambda a="": self.remove_alias_callback(a, id))
new_field.setMaximumHeight(25)
new_field.setMinimumHeight(25)
self.alias_ids.add(id)
self.new_alias_names[id] = ""
self.aliases_flow_layout.addWidget(new_field)
new_field.text_field.setFocus()
self.aliases_flow_layout.addWidget(self.alias_add_button)
def remove_alias_callback(self, alias_name: str, alias_id: int | None = None):
logger.info("remove_alias_callback")
self.alias_ids.remove(alias_id)
self._set_aliases()
def set_subtags(self):
while self.subtag_flow_layout.itemAt(1):
self.subtag_flow_layout.takeAt(0).widget().deleteLater()
for tag_id in self.subtag_ids:
tag = self.lib.get_tag(tag_id)
tw = TagWidget(tag, has_edit=False, has_remove=True)
tw.on_remove.connect(lambda t=tag_id: self.remove_subtag_callback(t))
layout.addWidget(tw)
self.scroll_layout.addWidget(c)
self.subtag_flow_layout.addWidget(tw)
self.subtag_flow_layout.addWidget(self.subtags_add_button)
def add_aliases(self):
fields: set[TagAliasWidget] = set()
for i in range(0, self.aliases_flow_layout.count() - 1):
widget = self.aliases_flow_layout.itemAt(i).widget()
if not isinstance(widget, TagAliasWidget):
return
field: TagAliasWidget = cast(TagAliasWidget, widget)
fields.add(field)
remove: set[str] = self.alias_names - set([a.text_field.text() for a in fields])
self.alias_names = self.alias_names - remove
for field in fields:
# add new aliases
if field.text_field.text() != "":
self.alias_names.add(field.text_field.text())
def _update_new_alias_name_dict(self):
for i in range(0, self.aliases_flow_layout.count() - 1):
widget = self.aliases_flow_layout.itemAt(i).widget()
if not isinstance(widget, TagAliasWidget):
return
field: TagAliasWidget = cast(TagAliasWidget, widget)
text_field_text = field.text_field.text()
self.new_alias_names[field.id] = text_field_text
def _set_aliases(self):
self._update_new_alias_name_dict()
while self.aliases_flow_layout.itemAt(1):
self.aliases_flow_layout.takeAt(0).widget().deleteLater()
self.alias_names.clear()
for alias_id in self.alias_ids:
alias = self.lib.get_alias(self.tag.id, alias_id)
alias_name = alias.name if alias else self.new_alias_names[alias_id]
new_field = TagAliasWidget(
alias_id,
alias_name,
lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id),
)
new_field.setMaximumHeight(25)
new_field.setMinimumHeight(25)
self.aliases_flow_layout.addWidget(new_field)
self.alias_names.add(alias_name)
self.aliases_flow_layout.addWidget(self.alias_add_button)
def set_tag(self, tag: Tag):
self.tag = tag
self.tag = tag
logger.info("setting tag", tag=tag)
self.name_field.setText(tag.name)
self.shorthand_field.setText(tag.shorthand or "")
# TODO: Implement aliases
# self.aliases_field.setText("\n".join(tag.aliases))
for alias_id in tag.alias_ids:
self.alias_ids.add(alias_id)
self._set_aliases()
for subtag in tag.subtag_ids:
self.subtag_ids.add(subtag)
for alias_id in tag.alias_ids:
self.alias_ids.add(alias_id)
self._set_aliases()
for subtag in tag.subtag_ids:
self.subtag_ids.add(subtag)
self.set_subtags()
# select item in self.color_field where the userData value matched tag.color
for i in range(self.color_field.count()):
if self.color_field.itemData(i) == tag.color:
self.color_field.setCurrentIndex(i)
break
self.name_field.selectAll()
self.tag = tag
def on_name_changed(self):
is_empty = not self.name_field.text().strip()
self.name_field.setStyleSheet(
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
if is_empty
else ""
)
if self.panel_save_button is not None:
self.panel_save_button.setDisabled(is_empty)
def build_tag(self) -> Tag:
color = self.color_field.currentData() or TagColor.DEFAULT
tag = self.tag
self.add_aliases()
tag.name = self.name_field.text()
tag.shorthand = self.shorthand_field.text()
tag.color = color
+8 -23
View File
@@ -4,7 +4,7 @@
import typing
from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
@@ -15,8 +15,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from src.core.utils.missing_files import MissingRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -77,9 +75,14 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.model.clear()
for i in self.tracker.missing_files:
self.model.appendRow(QStandardItem(str(i.path)))
item = QStandardItem(str(i.path))
item.setEditable(False)
self.model.appendRow(item)
def delete_entries(self):
def displayed_text(x):
return f"Deleting {x}/{self.tracker.missing_files_count} Unlinked Entries"
pw = ProgressWidget(
window_title="Deleting Entries",
label_text="",
@@ -87,23 +90,5 @@ class DeleteUnlinkedEntriesModal(QWidget):
minimum=0,
maximum=self.tracker.missing_files_count,
)
pw.show()
iterator = FunctionIterator(self.tracker.execute_deletion)
files_count = self.tracker.missing_files_count
iterator.value.connect(
lambda idx: (
pw.update_progress(idx),
pw.update_label(f"Deleting {idx}/{files_count} Unlinked Entries"),
)
)
r = CustomRunnable(iterator.run)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.done.emit(),
)
)
pw.from_iterable_function(self.tracker.execute_deletion, displayed_text, self.done.emit)
+229
View File
@@ -0,0 +1,229 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import enum
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
import structlog
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from src.qt.widgets.progress import ProgressWidget
if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class DuplicateChoice(enum.StrEnum):
SKIP = "Skipped"
OVERWRITE = "Overwritten"
RENAME = "Renamed"
CANCEL = "Cancelled"
class DropImportModal(QWidget):
DUPE_NAME_LIMT: int = 5
def __init__(self, driver: "QtDriver"):
super().__init__()
self.driver: QtDriver = driver
# Widget ======================
self.setWindowTitle("Conflicting File(s)")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
self.desc_widget.setText("The following files have filenames already exist in the library")
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Duplicate File List ========
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)
# Buttons ====================
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.skip_button = QPushButton()
self.skip_button.setText("&Skip")
self.skip_button.setDefault(True)
self.skip_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.SKIP))
self.button_layout.addWidget(self.skip_button)
self.overwrite_button = QPushButton()
self.overwrite_button.setText("&Overwrite")
self.overwrite_button.clicked.connect(
lambda: self.begin_transfer(DuplicateChoice.OVERWRITE)
)
self.button_layout.addWidget(self.overwrite_button)
self.rename_button = QPushButton()
self.rename_button.setText("&Rename")
self.rename_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.RENAME))
self.button_layout.addWidget(self.rename_button)
self.cancel_button = QPushButton()
self.cancel_button.setText("&Cancel")
self.cancel_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.CANCEL))
self.button_layout.addWidget(self.cancel_button)
# Layout =====================
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)
def import_urls(self, urls: list[QUrl]):
"""Add a colleciton of urls to the library."""
self.files: list[Path] = []
self.dirs_in_root: list[Path] = []
self.duplicate_files: list[Path] = []
self.collect_files_to_import(urls)
if len(self.duplicate_files) > 0:
self.ask_duplicates_choice()
else:
self.begin_transfer()
def collect_files_to_import(self, urls: list[QUrl]):
"""Collect one or more files from drop event urls."""
for url in urls:
if not url.isLocalFile():
continue
file = Path(url.toLocalFile())
if file.is_dir():
for f in file.glob("**/*"):
if f.is_dir():
continue
self.files.append(f)
if (self.driver.lib.library_dir / self._get_relative_path(file)).exists():
self.duplicate_files.append(f)
self.dirs_in_root.append(file.parent)
else:
self.files.append(file)
if file.parent not in self.dirs_in_root:
self.dirs_in_root.append(
file.parent
) # to create relative path of files not in folder
if (Path(self.driver.lib.library_dir) / file.name).exists():
self.duplicate_files.append(file)
def ask_duplicates_choice(self):
"""Display the message widgeth with a list of the duplicated files."""
self.desc_widget.setText(
f"The following {len(self.duplicate_files)} file(s) have filenames already exist in the library." # noqa: E501
)
self.model.clear()
for dupe in self.duplicate_files:
item = QStandardItem(str(self._get_relative_path(dupe)))
item.setEditable(False)
self.model.appendRow(item)
self.driver.main_window.raise_()
self.show()
def begin_transfer(self, choice: DuplicateChoice | None = None):
"""Display a progress bar and begin copying files into library."""
self.hide()
self.choice: DuplicateChoice | None = choice
logger.info("duplicated choice selected", choice=self.choice)
if self.choice == DuplicateChoice.CANCEL:
return
def displayed_text(x):
text = (
f"Importing New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Imported."
)
if self.choice:
text += f" {x[1]} {self.choice.value}"
return text
pw = ProgressWidget(
window_title="Import Files",
label_text="Importing New Files...",
cancel_button_text=None,
minimum=0,
maximum=len(self.files),
)
pw.from_iterable_function(
self.copy_files,
displayed_text,
self.driver.add_new_files_callback,
self.deleteLater,
)
def copy_files(self):
"""Copy files from original location to the library directory."""
file_count = 0
duplicated_files_progress = 0
for file in self.files:
if file.is_dir():
continue
dest_file = self._get_relative_path(file)
if file in self.duplicate_files:
duplicated_files_progress += 1
if self.choice == DuplicateChoice.SKIP:
file_count += 1
continue
elif self.choice == DuplicateChoice.RENAME:
new_name = self._get_renamed_duplicate_filename(dest_file)
dest_file = dest_file.with_name(new_name)
(self.driver.lib.library_dir / dest_file).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
file_count += 1
yield [file_count, duplicated_files_progress]
def _get_relative_path(self, path: Path) -> Path:
for dir in self.dirs_in_root:
if path.is_relative_to(dir):
return path.relative_to(dir)
return Path(path.name)
def _get_renamed_duplicate_filename(self, filepath: Path) -> str:
index = 2
o_filename = filepath.name
try:
dot_idx = o_filename.index(".")
except ValueError:
dot_idx = len(o_filename)
while (self.driver.lib.library_dir / filepath).exists():
filepath = filepath.with_name(
o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:]
)
index += 1
return filepath.name
+2 -2
View File
@@ -16,7 +16,7 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from src.core.constants import LibraryPrefs
from src.core.enums import LibraryPrefs
from src.core.library import Library
from src.qt.widgets.panel import PanelWidget
@@ -104,7 +104,7 @@ class FileExtensionModal(PanelWidget):
for i in range(self.table.rowCount()):
ext = self.table.item(i, 0)
if ext and ext.text().strip():
extensions.append(ext.text().strip().lower())
extensions.append(ext.text().strip().lstrip(".").lower())
# save preference
self.lib.set_prefs(LibraryPrefs.EXTENSION_LIST, extensions)
+13 -19
View File
@@ -5,12 +5,10 @@
import typing
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.library import Library
from src.core.utils.missing_files import MissingRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
@@ -85,7 +83,7 @@ class FixUnlinkedEntriesModal(QWidget):
self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker)
self.delete_modal.done.connect(
lambda: (
self.set_missing_count(self.tracker.missing_files_count),
self.set_missing_count(),
# refresh the grid
self.driver.filter_items(),
)
@@ -125,23 +123,19 @@ class FixUnlinkedEntriesModal(QWidget):
maximum=self.lib.entries_count,
)
pw.show()
iterator = FunctionIterator(self.tracker.refresh_missing_files)
iterator.value.connect(lambda v: pw.update_progress(v + 1))
r = CustomRunnable(iterator.run)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.set_missing_count(self.tracker.missing_files_count),
self.delete_modal.refresh_list(),
)
pw.from_iterable_function(
self.tracker.refresh_missing_files,
None,
self.set_missing_count,
self.delete_modal.refresh_list,
)
def set_missing_count(self, count: int):
self.missing_count = count
def set_missing_count(self, count: int | None = None):
if count is not None:
self.missing_count = count
else:
self.missing_count = self.tracker.missing_files_count
if self.missing_count < 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
+3 -13
View File
@@ -4,11 +4,9 @@
import typing
from PySide6.QtCore import QObject, QThreadPool, Signal
from PySide6.QtCore import QObject, Signal
from src.core.library import Library
from src.core.utils.dupe_files import DupeRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -26,20 +24,12 @@ class MergeDuplicateEntries(QObject):
self.tracker = DupeRegistry(library=self.lib)
def merge_entries(self):
iterator = FunctionIterator(self.tracker.merge_dupe_entries)
pw = ProgressWidget(
window_title="Merging Duplicate Entries",
label_text="",
label_text="Merging Duplicate Entries...",
cancel_button_text=None,
minimum=0,
maximum=self.tracker.groups_count,
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x))
iterator.value.connect(lambda: (pw.update_label("Merging Duplicate Entries...")))
r = CustomRunnable(iterator.run)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
QThreadPool.globalInstance().start(r)
pw.from_iterable_function(self.tracker.merge_dupe_entries, None, self.done.emit)
+11 -19
View File
@@ -6,7 +6,7 @@
import typing
from time import sleep
from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
@@ -17,8 +17,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from src.core.utils.dupe_files import DupeRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -83,28 +81,22 @@ class MirrorEntriesModal(QWidget):
self.model.appendRow(QStandardItem(str(i)))
def mirror_entries(self):
iterator = FunctionIterator(self.mirror_entries_runnable)
def displayed_text(x):
return f"Mirroring {x + 1}/{self.tracker.groups_count} Entries..."
pw = ProgressWidget(
window_title="Mirroring Entries",
label_text=f"Mirroring 1/{self.tracker.groups_count} Entries...",
label_text="",
cancel_button_text=None,
minimum=0,
maximum=self.tracker.groups_count,
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x + 1))
iterator.value.connect(
lambda x: pw.update_label(f"Mirroring {x + 1}/{self.tracker.groups_count} Entries...")
)
r = CustomRunnable(iterator.run)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.driver.preview_panel.update_widgets(),
self.done.emit(),
)
pw.from_iterable_function(
self.mirror_entries_runnable,
displayed_text,
self.driver.preview_panel.update_widgets,
self.done.emit,
)
def mirror_entries_runnable(self):
+6 -25
View File
@@ -3,10 +3,8 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import QObject, QThreadPool, Signal
from PySide6.QtCore import QObject, Signal
from src.core.utils.missing_files import MissingRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget
@@ -18,7 +16,10 @@ class RelinkUnlinkedEntries(QObject):
self.tracker = tracker
def repair_entries(self):
iterator = FunctionIterator(self.tracker.fix_missing_files)
def displayed_text(x):
text = f"Attempting to Relink {x}/{self.tracker.missing_files_count} Entries. \n"
text += f"{self.tracker.files_fixed_count} Successfully Relinked."
return text
pw = ProgressWidget(
window_title="Relinking Entries",
@@ -28,24 +29,4 @@ class RelinkUnlinkedEntries(QObject):
maximum=self.tracker.missing_files_count,
)
pw.show()
iterator.value.connect(
lambda idx: (
pw.update_progress(idx),
pw.update_label(
f"Attempting to Relink {idx}/{self.tracker.missing_files_count} Entries. "
f"{self.tracker.files_fixed_count} Successfully Relinked."
),
)
)
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.done.emit(),
)
)
QThreadPool.globalInstance().start(r)
pw.from_iterable_function(self.tracker.fix_missing_files, displayed_text, self.done.emit)
+78 -6
View File
@@ -2,21 +2,31 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLineEdit,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
from src.core.library import Library, Tag
from src.core.library.alchemy.enums import FilterState
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.widgets.panel import PanelModal, PanelWidget
from src.qt.widgets.tag import TagWidget
logger = structlog.get_logger(__name__)
# TODO: This class shares the majority of its code with tag_search.py.
# It should either be made DRY, or be replaced with the intended and more robust
# Tag Management tab/pane outlined on the Feature Roadmap.
class TagDatabasePanel(PanelWidget):
tag_chosen = Signal(int)
@@ -24,8 +34,8 @@ class TagDatabasePanel(PanelWidget):
def __init__(self, library: Library):
super().__init__()
self.lib: Library = library
self.is_initialized: bool = False
self.first_tag_id = -1
self.tag_limit = 30
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
@@ -52,10 +62,38 @@ class TagDatabasePanel(PanelWidget):
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
self.create_tag_button = QPushButton()
self.create_tag_button.setText("Create Tag")
self.create_tag_button.clicked.connect(self.build_tag)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.root_layout.addWidget(self.create_tag_button)
self.update_tags()
def build_tag(self):
self.modal = PanelModal(
BuildTagPanel(self.lib),
"New Tag",
"Add Tag",
has_save=True,
)
panel: BuildTagPanel = self.modal.widget
self.modal.saved.connect(
lambda: (
self.lib.add_tag(
tag=panel.build_tag(),
subtag_ids=panel.subtag_ids,
alias_names=panel.alias_names,
alias_ids=panel.alias_ids,
),
self.modal.hide(),
self.update_tags(),
)
)
self.modal.show()
def on_return(self, text: str):
if text and self.first_tag_id >= 0:
# callback(self.first_tag_id)
@@ -67,24 +105,52 @@ class TagDatabasePanel(PanelWidget):
def update_tags(self, query: str | None = None):
# TODO: Look at recycling rather than deleting and re-initializing
logger.info("[Tag Manager Modal] Updating Tags")
while self.scroll_layout.itemAt(0):
self.scroll_layout.takeAt(0).widget().deleteLater()
tags = self.lib.search_tags(FilterState(path=query, page_size=self.tag_limit))
tags_results = self.lib.search_tags(name=query)
for tag in tags:
for tag in tags_results:
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
tag_widget = TagWidget(tag, has_edit=True, has_remove=False)
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
tag_widget = TagWidget(tag, has_edit=False, has_remove=False)
else:
tag_widget = TagWidget(tag, has_edit=True, has_remove=True)
tag_widget.on_edit.connect(lambda checked=False, t=tag: self.edit_tag(t))
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
row.addWidget(tag_widget)
self.scroll_layout.addWidget(container)
self.search_field.setFocus()
def remove_tag(self, tag: Tag):
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
return
message_box = QMessageBox()
message_box.setWindowTitle("Remove Tag")
message_box.setText(f'Are you sure you want to delete the tag "{tag.name}"?')
message_box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) # type: ignore
message_box.setIcon(QMessageBox.Question) # type: ignore
result = message_box.exec()
if result != QMessageBox.Ok: # type: ignore
return
self.lib.remove_tag(tag)
self.update_tags()
def edit_tag(self, tag: Tag):
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
return
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
self.edit_modal = PanelModal(
@@ -99,5 +165,11 @@ class TagDatabasePanel(PanelWidget):
self.edit_modal.show()
def edit_tag_callback(self, btp: BuildTagPanel):
self.lib.add_tag(btp.build_tag())
self.lib.update_tag(btp.build_tag(), btp.subtag_ids, btp.alias_names, btp.alias_ids)
self.update_tags(self.search_field.text())
def showEvent(self, event: QShowEvent) -> None: # noqa N802
if not self.is_initialized:
self.update_tags()
self.is_initialized = True
return super().showEvent(event)
+23 -15
View File
@@ -7,6 +7,7 @@ import math
import structlog
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
@@ -17,7 +18,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from src.core.library import Library
from src.core.library.alchemy.enums import FilterState
from src.core.palette import ColorType, get_tag_color
from src.qt.widgets.panel import PanelWidget
from src.qt.widgets.tag import TagWidget
@@ -28,11 +28,12 @@ logger = structlog.get_logger(__name__)
class TagSearchPanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library: Library):
def __init__(self, library: Library, exclude: list[int] | None = None):
super().__init__()
self.lib = library
self.first_tag_id = None
self.tag_limit = 100
self.exclude = exclude
self.is_initialized: bool = False
self.first_tag_id: int = None
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
@@ -60,30 +61,31 @@ class TagSearchPanel(PanelWidget):
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.update_tags()
def on_return(self, text: str):
if text and self.first_tag_id is not None:
# callback(self.first_tag_id)
self.tag_chosen.emit(self.first_tag_id)
self.search_field.setText("")
self.update_tags()
else:
self.search_field.setFocus()
self.parentWidget().hide()
self.parentWidget().hide()
def update_tags(self, name: str | None = None):
def update_tags(self, query: str | None = None):
logger.info("[Tag Search Modal] Updating Tags")
while self.scroll_layout.count():
self.scroll_layout.takeAt(0).widget().deleteLater()
found_tags = self.lib.search_tags(
FilterState(
path=name,
page_size=self.tag_limit,
)
)
tag_results = self.lib.search_tags(name=query)
if len(tag_results) > 0:
self.first_tag_id = tag_results[0].id
else:
self.first_tag_id = None
for tag in tag_results:
if self.exclude is not None and tag.id in self.exclude:
continue
for tag in found_tags:
c = QWidget()
layout = QHBoxLayout(c)
layout.setContentsMargins(0, 0, 0, 0)
@@ -120,3 +122,9 @@ class TagSearchPanel(PanelWidget):
self.scroll_layout.addWidget(c)
self.search_field.setFocus()
def showEvent(self, event: QShowEvent) -> None: # noqa N802
if not self.is_initialized:
self.update_tags()
self.is_initialized = True
return super().showEvent(event)
+16
View File
@@ -0,0 +1,16 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
"""A collection of platform-dependant strings."""
import platform
class PlatformStrings:
open_file_str: str = "Open in file explorer"
if platform.system() == "Windows":
open_file_str = "Open in Explorer"
elif platform.system() == "Darwin":
open_file_str = "Reveal in Finder"
+39 -12
View File
@@ -7,6 +7,7 @@ from typing import Any
import structlog
import ujson
from PIL import Image
logger = structlog.get_logger(__name__)
@@ -17,6 +18,7 @@ class ResourceManager:
_map: dict = {}
_cache: dict[str, Any] = {}
_initialized: bool = False
_res_folder: Path = Path(__file__).parents[2]
def __init__(self) -> None:
# Load JSON resource map
@@ -26,6 +28,21 @@ class ResourceManager:
logger.info("resources registered", count=len(ResourceManager._map.items()))
ResourceManager._initialized = True
@staticmethod
def get_path(id: str) -> Path | None:
"""Get a resource's path from the ResourceManager.
Args:
id (str): The name of the resource.
Returns:
Path: The resource path if found, else None.
"""
res: dict = ResourceManager._map.get(id)
if res:
return ResourceManager._res_folder / "resources" / res.get("path")
return None
def get(self, id: str) -> Any:
"""Get a resource from the ResourceManager.
@@ -43,19 +60,29 @@ class ResourceManager:
return cached_res
else:
res: dict = ResourceManager._map.get(id)
if res.get("mode") in ["r", "rb"]:
with open(
(Path(__file__).parents[2] / "resources" / res.get("path")),
res.get("mode"),
) as f:
data = f.read()
if res.get("mode") == "rb":
data = bytes(data)
ResourceManager._cache[id] = data
if not res:
return None
try:
if res.get("mode") in ["r", "rb"]:
with open(
(ResourceManager._res_folder / "resources" / res.get("path")),
res.get("mode"),
) as f:
data = f.read()
if res.get("mode") == "rb":
data = bytes(data)
ResourceManager._cache[id] = data
return data
elif res and res.get("mode") == "pil":
data = Image.open(ResourceManager._res_folder / "resources" / res.get("path"))
return data
elif res.get("mode") in ["qt"]:
# TODO: Qt resource loading logic
pass
elif res.get("mode") in ["qt"]:
# TODO: Qt resource loading logic
pass
except FileNotFoundError:
path: Path = ResourceManager._res_folder / "resources" / res.get("path")
logger.error("[ResourceManager][ERROR]: Could not find resource: ", path)
return None
def __getattr__(self, __name: str) -> Any:
attr = self.get(__name)
+80
View File
@@ -14,5 +14,85 @@
"volume_mute_icon": {
"path": "qt/images/volume_mute.svg",
"mode": "rb"
},
"broken_link_icon": {
"path": "qt/images/broken_link_icon.png",
"mode": "pil"
},
"adobe_illustrator": {
"path": "qt/images/file_icons/adobe_illustrator.png",
"mode": "pil"
},
"adobe_photoshop": {
"path": "qt/images/file_icons/adobe_photoshop.png",
"mode": "pil"
},
"affinity_photo": {
"path": "qt/images/file_icons/affinity_photo.png",
"mode": "pil"
},
"audio": {
"path": "qt/images/file_icons/audio.png",
"mode": "pil"
},
"blender": {
"path": "qt/images/file_icons/blender.png",
"mode": "pil"
},
"document": {
"path": "qt/images/file_icons/document.png",
"mode": "pil"
},
"file_generic": {
"path": "qt/images/file_icons/file_generic.png",
"mode": "pil"
},
"font": {
"path": "qt/images/file_icons/font.png",
"mode": "pil"
},
"image": {
"path": "qt/images/file_icons/image.png",
"mode": "pil"
},
"image_vector": {
"path": "qt/images/file_icons/image_vector.png",
"mode": "pil"
},
"material": {
"path": "qt/images/file_icons/material.png",
"mode": "pil"
},
"model": {
"path": "qt/images/file_icons/model.png",
"mode": "pil"
},
"presentation": {
"path": "qt/images/file_icons/presentation.png",
"mode": "pil"
},
"program": {
"path": "qt/images/file_icons/program.png",
"mode": "pil"
},
"spreadsheet": {
"path": "qt/images/file_icons/spreadsheet.png",
"mode": "pil"
},
"text": {
"path": "qt/images/file_icons/text.png",
"mode": "pil"
},
"video": {
"path": "qt/images/file_icons/video.png",
"mode": "pil"
},
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"placeholder_mp4": {
"path": "qt/videos/placeholder.mp4",
"mode": "rb"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Resource object code (Python 3)
# Created by: object code
# Created by: The Resource Compiler for Qt version 6.6.3
# Created by: The Resource Compiler for Qt version 6.7.1
# WARNING! All changes made in this file will be lost!
from PySide6 import QtCore
@@ -16240,7 +16240,7 @@ qt_resource_struct = b"\
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x8a\xfb\xc6\x86\xda\
\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\
\x00\x00\x01\x8e\xfd%\xc3\xc7\
\x00\x00\x01\x92\xc5.Ns\
"
def qInitResources():
+331 -156
View File
@@ -11,6 +11,7 @@ import ctypes
import dataclasses
import math
import os
import re
import sys
import time
import webbrowser
@@ -24,18 +25,13 @@ import src.qt.resources_rc # noqa: F401
import structlog
from humanfriendly import format_timespan
from PySide6 import QtCore
from PySide6.QtCore import (
QObject,
QSettings,
Qt,
QThread,
QThreadPool,
QTimer,
Signal,
)
from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal
from PySide6.QtGui import (
QAction,
QColor,
QDragEnterEvent,
QDragMoveEvent,
QDropEvent,
QFontDatabase,
QGuiApplication,
QIcon,
@@ -50,6 +46,7 @@ from PySide6.QtWidgets import (
QLineEdit,
QMenu,
QMenuBar,
QMessageBox,
QPushButton,
QScrollArea,
QSplashScreen,
@@ -58,19 +55,20 @@ from PySide6.QtWidgets import (
from src.core.constants import (
TAG_ARCHIVED,
TAG_FAVORITE,
TS_FOLDER_NAME,
VERSION,
VERSION_BRANCH,
LibraryPrefs,
)
from src.core.enums import MacroID, SettingItems
from src.core.driver import DriverMixin
from src.core.enums import LibraryPrefs, MacroID, SettingItems
from src.core.library.alchemy import Library
from src.core.library.alchemy.enums import (
FieldTypeEnum,
FilterState,
ItemType,
SearchMode,
)
from src.core.library.alchemy.fields import _FieldID
from src.core.library.alchemy.library import Entry, LibraryStatus
from src.core.media_types import MediaCategories
from src.core.ts_core import TagStudioCore
from src.core.utils.refresh_dir import RefreshDirTracker
from src.core.utils.web import strip_web_protocol
@@ -79,6 +77,7 @@ from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.main_window import Ui_MainWindow
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.modals.drop_import import DropImportModal
from src.qt.modals.file_extension import FileExtensionModal
from src.qt.modals.fix_dupes import FixDupeFilesModal
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
@@ -86,6 +85,7 @@ from src.qt.modals.folders_to_tags import FoldersToTagsModal
from src.qt.modals.tag_database import TagDatabasePanel
from src.qt.resource_manager import ResourceManager
from src.qt.widgets.item_thumb import BadgeType, ItemThumb
from src.qt.widgets.migration_modal import JsonMigrationModal
from src.qt.widgets.panel import PanelModal
from src.qt.widgets.preview_panel import PreviewPanel
from src.qt.widgets.progress import ProgressWidget
@@ -120,12 +120,13 @@ class Consumer(QThread):
pass
class QtDriver(QObject):
class QtDriver(DriverMixin, QObject):
"""A Qt GUI frontend driver for TagStudio."""
SIGTERM = Signal()
preview_panel: PreviewPanel
lib: Library
def __init__(self, backend, args):
super().__init__()
@@ -135,7 +136,7 @@ class QtDriver(QObject):
self.rm: ResourceManager = ResourceManager()
self.args = args
self.frame_content = []
self.filter = FilterState()
self.filter = FilterState.show_all()
self.pages_count = 0
self.scrollbar_pos = 0
@@ -173,16 +174,15 @@ class QtDriver(QObject):
filename=self.settings.fileName(),
)
max_threads = os.cpu_count()
for i in range(max_threads):
# thread = threading.Thread(
# target=self.consumer, name=f"ThumbRenderer_{i}", args=(), daemon=True
# )
# thread.start()
thread = Consumer(self.thumb_job_queue)
thread.setObjectName(f"ThumbRenderer_{i}")
self.thumb_threads.append(thread)
thread.start()
def init_workers(self):
"""Init workers for rendering thumbnails."""
if not self.thumb_threads:
max_threads = os.cpu_count()
for i in range(max_threads):
thread = Consumer(self.thumb_job_queue)
thread.setObjectName(f"ThumbRenderer_{i}")
self.thumb_threads.append(thread)
thread.start()
def open_library_from_dialog(self):
dir = QFileDialog.getExistingDirectory(
@@ -230,19 +230,10 @@ class QtDriver(QObject):
# self.main_window = loader.load(home_path)
self.main_window = Ui_MainWindow(self)
self.main_window.setWindowTitle(self.base_title)
self.main_window.mousePressEvent = self.mouse_navigation # type: ignore
# self.main_window.setStyleSheet(
# f'QScrollBar::{{background:red;}}'
# )
# # self.main_window.windowFlags() &
# # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
# self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
# self.main_window.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False)
# self.main_window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# self.windowFX = WindowEffect()
# self.windowFX.setAcrylicEffect(self.main_window.winId())
self.main_window.mousePressEvent = self.mouse_navigation # type: ignore[method-assign]
self.main_window.dragEnterEvent = self.drag_enter_event # type: ignore[method-assign]
self.main_window.dragMoveEvent = self.drag_move_event # type: ignore[method-assign]
self.main_window.dropEvent = self.drop_event # type: ignore[method-assign]
splash_pixmap = QPixmap(":/images/splash.png")
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
@@ -252,7 +243,7 @@ class QtDriver(QObject):
if os.name == "nt":
appid = "cyanvoxel.tagstudio.9"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore[attr-defined,unused-ignore]
if sys.platform != "darwin":
icon = QIcon()
@@ -265,15 +256,12 @@ class QtDriver(QObject):
file_menu = QMenu("&File", menu_bar)
edit_menu = QMenu("&Edit", menu_bar)
view_menu = QMenu("&View", menu_bar)
tools_menu = QMenu("&Tools", menu_bar)
macros_menu = QMenu("&Macros", menu_bar)
window_menu = QMenu("&Window", menu_bar)
help_menu = QMenu("&Help", menu_bar)
# File Menu ============================================================
# file_menu.addAction(QAction('&New Library', menu_bar))
# file_menu.addAction(QAction('&Open Library', menu_bar))
open_library_action = QAction("&Open/Create Library", menu_bar)
open_library_action.triggered.connect(lambda: self.open_library_from_dialog())
open_library_action.setShortcut(
@@ -303,8 +291,6 @@ class QtDriver(QObject):
file_menu.addSeparator()
# refresh_lib_action = QAction('&Refresh Directories', self.main_window)
# refresh_lib_action.triggered.connect(lambda: self.lib.refresh_dir())
add_new_files_action = QAction("&Refresh Directories", menu_bar)
add_new_files_action.triggered.connect(
lambda: self.callback_library_needed_check(self.add_new_files_callback)
@@ -316,13 +302,23 @@ class QtDriver(QObject):
)
)
add_new_files_action.setStatusTip("Ctrl+R")
# file_menu.addAction(refresh_lib_action)
file_menu.addAction(add_new_files_action)
file_menu.addSeparator()
close_library_action = QAction("&Close Library", menu_bar)
close_library_action.triggered.connect(self.close_library)
file_menu.addAction(close_library_action)
file_menu.addSeparator()
open_on_start_action = QAction("Open Library on Start", self)
open_on_start_action.setCheckable(True)
open_on_start_action.setChecked(
bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
)
open_on_start_action.triggered.connect(
lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
)
file_menu.addAction(open_on_start_action)
# Edit Menu ============================================================
new_tag_action = QAction("New &Tag", menu_bar)
@@ -365,15 +361,32 @@ class QtDriver(QObject):
tag_database_action.triggered.connect(lambda: self.show_tag_database())
edit_menu.addAction(tag_database_action)
check_action = QAction("Open library on start", self)
check_action.setCheckable(True)
check_action.setChecked(
bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool))
# View Menu ============================================================
show_libs_list_action = QAction("Show Recent Libraries", menu_bar)
show_libs_list_action.setCheckable(True)
show_libs_list_action.setChecked(
bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool))
)
check_action.triggered.connect(
lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked)
show_libs_list_action.triggered.connect(
lambda checked: (
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked),
self.toggle_libs_list(checked),
)
)
window_menu.addAction(check_action)
view_menu.addAction(show_libs_list_action)
show_filenames_action = QAction("Show Filenames in Grid", menu_bar)
show_filenames_action.setCheckable(True)
show_filenames_action.setChecked(
bool(self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool))
)
show_filenames_action.triggered.connect(
lambda checked: (
self.settings.setValue(SettingItems.SHOW_FILENAMES, checked),
self.show_grid_filenames(checked),
)
)
view_menu.addAction(show_filenames_action)
# Tools Menu ===========================================================
def create_fix_unlinked_entries_modal():
@@ -408,19 +421,6 @@ class QtDriver(QObject):
)
macros_menu.addAction(self.autofill_action)
show_libs_list_action = QAction("Show Recent Libraries", menu_bar)
show_libs_list_action.setCheckable(True)
show_libs_list_action.setChecked(
bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool))
)
show_libs_list_action.triggered.connect(
lambda checked: (
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked),
self.toggle_libs_list(checked),
)
)
window_menu.addAction(show_libs_list_action)
def create_folders_tags_modal():
if not hasattr(self, "folders_modal"):
self.folders_modal = FoldersToTagsModal(self.lib, self)
@@ -430,7 +430,7 @@ class QtDriver(QObject):
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
macros_menu.addAction(folders_to_tags_action)
# Help Menu ==========================================================
# Help Menu ============================================================
self.repo_action = QAction("Visit GitHub Repository", menu_bar)
self.repo_action.triggered.connect(
lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio")
@@ -440,11 +440,13 @@ class QtDriver(QObject):
menu_bar.addMenu(file_menu)
menu_bar.addMenu(edit_menu)
menu_bar.addMenu(view_menu)
menu_bar.addMenu(tools_menu)
menu_bar.addMenu(macros_menu)
menu_bar.addMenu(window_menu)
menu_bar.addMenu(help_menu)
self.main_window.searchField.textChanged.connect(self.update_completions_list)
self.preview_panel = PreviewPanel(self.lib, self)
splitter = self.main_window.splitter
splitter.addWidget(self.preview_panel)
@@ -453,59 +455,78 @@ class QtDriver(QObject):
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
)
self.thumb_size = 128
self.thumb_sizes: list[tuple[str, int]] = [
("Extra Large Thumbnails", 256),
("Large Thumbnails", 192),
("Medium Thumbnails", 128),
("Small Thumbnails", 96),
("Mini Thumbnails", 76),
]
self.item_thumbs: list[ItemThumb] = []
self.thumb_renderers: list[ThumbRenderer] = []
self.filter = FilterState()
self.filter = FilterState.show_all()
self.init_library_window()
self.migration_modal: JsonMigrationModal = None
lib: str | None = None
if self.args.open:
lib = self.args.open
elif self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool):
lib = str(self.settings.value(SettingItems.LAST_LIBRARY))
# TODO: Remove this check if the library is no longer saved with files
if lib and not (Path(lib) / TS_FOLDER_NAME).exists():
logger.error(f"[QT DRIVER] {TS_FOLDER_NAME} folder in {lib} does not exist.")
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
lib = None
if lib:
path_result = self.evaluate_path(self.args.open)
# check status of library path evaluating
if path_result.success and path_result.library_path:
self.splash.showMessage(
f'Opening Library "{lib}"...',
f'Opening Library "{path_result.library_path}"...',
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
QColor("#9782ff"),
)
self.open_library(lib)
self.open_library(path_result.library_path)
app.exec()
self.shutdown()
def show_error_message(self, message: str):
self.main_window.statusbar.showMessage(message, Qt.AlignmentFlag.AlignLeft)
self.main_window.landing_widget.set_status_label(message)
self.main_window.setWindowTitle(message)
msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setText(message)
msg_box.setWindowTitle("Error")
msg_box.addButton("Close", QMessageBox.ButtonRole.AcceptRole)
# Show the message box
msg_box.exec()
def init_library_window(self):
# self._init_landing_page() # Taken care of inside the widget now
self._init_thumb_grid()
# TODO: Put this into its own method that copies the font file(s) into memory
# so the resource isn't being used, then store the specific size variations
# in a global dict for methods to access for different DPIs.
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
# Search Button
search_button: QPushButton = self.main_window.searchButton
search_button.clicked.connect(
lambda: self.filter_items(FilterState(query=self.main_window.searchField.text()))
lambda: self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
)
)
# Search Field
search_field: QLineEdit = self.main_window.searchField
search_field.returnPressed.connect(
# TODO - parse search field for filters
lambda: self.filter_items(FilterState(query=self.main_window.searchField.text()))
lambda: self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
)
)
search_type_selector: QComboBox = self.main_window.comboBox_2
search_type_selector.currentIndexChanged.connect(
lambda: self.set_search_type(SearchMode(search_type_selector.currentIndex()))
# Thumbnail Size ComboBox
thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox
for size in self.thumb_sizes:
thumb_size_combobox.addItem(size[0])
thumb_size_combobox.setCurrentIndex(2) # Default: Medium
thumb_size_combobox.currentIndexChanged.connect(
lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex())
)
self._init_thumb_grid()
back_button: QPushButton = self.main_window.backButton
back_button.clicked.connect(lambda: self.page_move(-1))
@@ -531,6 +552,10 @@ class QtDriver(QObject):
self.preview_panel.libs_flow_container.hide()
self.preview_panel.update()
def show_grid_filenames(self, value: bool):
for thumb in self.item_thumbs:
thumb.set_filename_visibility(value)
def callback_library_needed_check(self, func):
"""Check if loaded library has valid path before executing the button function."""
if self.lib.library_dir:
@@ -562,11 +587,12 @@ class QtDriver(QObject):
self.main_window.statusbar.showMessage("Closing Library...")
start_time = time.time()
self.settings.setValue(SettingItems.LAST_LIBRARY, self.lib.library_dir)
self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir))
self.settings.sync()
self.lib.close()
self.thumb_job_queue.queue.clear()
if is_shutdown:
# no need to do other things on shutdown
return
@@ -608,7 +634,9 @@ class QtDriver(QObject):
panel: BuildTagPanel = self.modal.widget
self.modal.saved.connect(
lambda: (
self.lib.add_tag(panel.build_tag(), panel.subtags),
self.lib.add_tag(
panel.build_tag(), panel.subtag_ids, panel.alias_names, panel.alias_ids
),
self.modal.hide(),
)
)
@@ -633,7 +661,11 @@ class QtDriver(QObject):
def show_tag_database(self):
self.modal = PanelModal(
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
widget=TagDatabasePanel(self.lib),
title="Library Tags",
window_title="Library Tags",
done_callback=self.preview_panel.update_widgets,
has_save=False,
)
self.modal.show()
@@ -668,7 +700,7 @@ class QtDriver(QObject):
pw.update_progress(x + 1),
pw.update_label(
f"Scanning Directories for New Files...\n{x + 1}"
f" File{"s" if x + 1 != 1 else ""} Searched,"
f' File{"s" if x + 1 != 1 else ""} Searched,'
f" {tracker.files_count} New Files Found"
),
)
@@ -688,26 +720,6 @@ class QtDriver(QObject):
Threaded method.
"""
# pb = QProgressDialog(
# f"Running Configured Macros on 1/{len(new_ids)} New Entries", None, 0, len(new_ids)
# )
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Running Macros')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.new_file_macros_runnable(pb, new_ids))
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.filter_items('')))
# r.run()
# # QThreadPool.globalInstance().start(r)
# # self.main_window.statusbar.showMessage(
# # f"Running configured Macros on {len(new_ids)} new Entries...", 3
# # )
# # pb.hide()
files_count = tracker.files_count
iterator = FunctionIterator(tracker.save_new_files)
@@ -719,6 +731,7 @@ class QtDriver(QObject):
maximum=files_count,
)
pw.show()
iterator.value.connect(
lambda x: (
pw.update_progress(x + 1),
@@ -760,9 +773,9 @@ class QtDriver(QObject):
def run_macro(self, name: MacroID, grid_idx: int):
"""Run a specific Macro on an Entry given a Macro name."""
entry = self.frame_content[grid_idx]
ful_path = self.lib.library_dir / entry.path
source = entry.path.parts[0]
entry: Entry = self.frame_content[grid_idx]
full_path = self.lib.library_dir / entry.path
source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower()
logger.info(
"running macro",
@@ -776,11 +789,13 @@ class QtDriver(QObject):
for macro_id in MacroID:
if macro_id == MacroID.AUTOFILL:
continue
self.run_macro(macro_id, entry.id)
self.run_macro(macro_id, grid_idx)
elif name == MacroID.SIDECAR:
parsed_items = TagStudioCore.get_gdl_sidecar(ful_path, source)
parsed_items = TagStudioCore.get_gdl_sidecar(full_path, source)
for field_id, value in parsed_items.items():
if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str):
value = self.lib.tag_from_strings(value)
self.lib.add_entry_field_type(
entry.id,
field_id=field_id,
@@ -788,8 +803,9 @@ class QtDriver(QObject):
)
elif name == MacroID.BUILD_URL:
url = TagStudioCore.build_url(entry.id, source)
self.lib.add_entry_field_type(entry.id, field_id=_FieldID.SOURCE, value=url)
url = TagStudioCore.build_url(entry, source)
if url is not None:
self.lib.add_entry_field_type(entry.id, field_id=_FieldID.SOURCE, value=url)
elif name == MacroID.MATCH:
TagStudioCore.match_conditions(self.lib, entry.id)
elif name == MacroID.CLEAN_URL:
@@ -797,9 +813,38 @@ class QtDriver(QObject):
if field.type.type == FieldTypeEnum.TEXT_LINE and field.value:
self.lib.update_entry_field(
entry_ids=entry.id,
field=field,
content=strip_web_protocol(field.value),
)
def thumb_size_callback(self, index: int):
"""Perform actions needed when the thumbnail size selection is changed.
Args:
index (int): The index of the item_thumbs/ComboBox list to use.
"""
spacing_divisor: int = 10
min_spacing: int = 12
# Index 2 is the default (Medium)
if index < len(self.thumb_sizes) and index >= 0:
self.thumb_size = self.thumb_sizes[index][1]
else:
logger.error(f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px.")
self.thumb_size = 128
self.update_thumbs()
blank_icon: QIcon = QIcon()
for it in self.item_thumbs:
it.thumb_button.setIcon(blank_icon)
it.resize(self.thumb_size, self.thumb_size)
it.thumb_size = (self.thumb_size, self.thumb_size)
it.setFixedSize(self.thumb_size, self.thumb_size)
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
it.set_filename_visibility(it.show_filename_label)
self.flow_container.layout().setSpacing(
min(self.thumb_size // spacing_divisor, min_spacing)
)
def mouse_navigation(self, event: QMouseEvent):
# print(event.button())
if event.button() == Qt.MouseButton.ForwardButton:
@@ -843,8 +888,16 @@ class QtDriver(QObject):
# TODO - init after library is loaded, it can have different page_size
for grid_idx in range(self.filter.page_size):
item_thumb = ItemThumb(
None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx
None,
self.lib,
self,
(self.thumb_size, self.thumb_size),
grid_idx,
bool(
self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool)
),
)
layout.addWidget(item_thumb)
self.item_thumbs.append(item_thumb)
@@ -901,6 +954,74 @@ class QtDriver(QObject):
def set_macro_menu_viability(self):
self.autofill_action.setDisabled(not self.selected)
def update_completions_list(self, text: str) -> None:
matches = re.search(
r"((?:.* )?)(mediatype|filetype|path|tag|tag_id):(\"?[A-Za-z0-9\ \t]+\"?)?", text
)
completion_list: list[str] = []
if len(text) < 3:
completion_list = [
"mediatype:",
"filetype:",
"path:",
"tag:",
"tag_id:",
"special:untagged",
]
self.main_window.searchFieldCompletionList.setStringList(completion_list)
if not matches:
return
query_type: str
query_value: str | None
prefix, query_type, query_value = matches.groups()
if not query_value:
return
if query_type == "tag":
completion_list = list(map(lambda x: prefix + "tag:" + x.name, self.lib.tags))
elif query_type == "tag_id":
completion_list = list(map(lambda x: prefix + "tag_id:" + str(x.id), self.lib.tags))
elif query_type == "path":
completion_list = list(map(lambda x: prefix + "path:" + x, self.lib.get_paths()))
elif query_type == "mediatype":
single_word_completions = map(
lambda x: prefix + "mediatype:" + x.name,
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
)
single_word_completions_quoted = map(
lambda x: prefix + 'mediatype:"' + x.name + '"',
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
)
multi_word_completions = map(
lambda x: prefix + 'mediatype:"' + x.name + '"',
filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES),
)
all_completions = [
single_word_completions,
single_word_completions_quoted,
multi_word_completions,
]
completion_list = [j for i in all_completions for j in i]
elif query_type == "filetype":
extensions_list: set[str] = set()
for media_cat in MediaCategories.ALL_CATEGORIES:
extensions_list = extensions_list | media_cat.extensions
completion_list = list(
map(lambda x: prefix + "filetype:" + x.replace(".", ""), extensions_list)
)
update_completion_list: bool = (
completion_list != self.main_window.searchFieldCompletionList.stringList()
or self.main_window.searchFieldCompletionList == []
)
if update_completion_list:
self.main_window.searchFieldCompletionList.setStringList(completion_list)
def update_thumbs(self):
"""Update search thumbnails."""
# start_time = time.time()
@@ -921,25 +1042,41 @@ class QtDriver(QObject):
self.flow_container.layout().update()
self.main_window.update()
for idx, (entry, item_thumb) in enumerate(
zip_longest(self.frame_content, self.item_thumbs)
):
is_grid_thumb = True
# Show loading placeholder icons
for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs):
if not entry:
item_thumb.hide()
continue
filepath = self.lib.library_dir / entry.path
item_thumb = self.item_thumbs[idx]
item_thumb.set_mode(ItemType.ENTRY)
item_thumb.set_item_id(entry)
# TODO - show after item is rendered
item_thumb.show()
is_loading = True
self.thumb_job_queue.put(
(
item_thumb.renderer.render,
(sys.float_info.max, "", base_size, ratio, True, True),
(sys.float_info.max, "", base_size, ratio, is_loading, is_grid_thumb),
)
)
# Show rendered thumbnails
for idx, (entry, item_thumb) in enumerate(
zip_longest(self.frame_content, self.item_thumbs)
):
if not entry:
continue
filepath = self.lib.library_dir / entry.path
is_loading = False
self.thumb_job_queue.put(
(
item_thumb.renderer.render,
(time.time(), filepath, base_size, ratio, is_loading, is_grid_thumb),
)
)
@@ -985,13 +1122,20 @@ class QtDriver(QObject):
self.item_thumbs[grid_idx].refresh_badge(entry)
def filter_items(self, filter: FilterState | None = None) -> None:
if not self.lib.library_dir:
logger.info("Library not loaded")
return
assert self.lib.engine
if filter:
self.filter = dataclasses.replace(self.filter, **dataclasses.asdict(filter))
self.main_window.statusbar.showMessage(f'Searching Library: "{self.filter.summary}"')
# inform user about running search
self.main_window.statusbar.showMessage("Searching Library...")
self.main_window.statusbar.repaint()
# search the library
start_time = time.time()
results = self.lib.search_library(self.filter)
@@ -999,17 +1143,11 @@ class QtDriver(QObject):
logger.info("items to render", count=len(results))
end_time = time.time()
if self.filter.summary:
# fmt: off
self.main_window.statusbar.showMessage(
f"{results.total_count} Results Found for \"{self.filter.summary}\""
f" ({format_timespan(end_time - start_time)})"
)
# fmt: on
else:
self.main_window.statusbar.showMessage(
f"{results.total_count} Results ({format_timespan(end_time - start_time)})"
)
# inform user about completed search
self.main_window.statusbar.showMessage(
f"{results.total_count} Results Found ({format_timespan(end_time - start_time)})"
)
# update page content
self.frame_content = results.items
@@ -1021,14 +1159,6 @@ class QtDriver(QObject):
self.pages_count, self.filter.page_index, emit=False
)
def set_search_type(self, mode: SearchMode = SearchMode.AND):
self.filter_items(
FilterState(
search_mode=mode,
path=self.main_window.searchField.text(),
)
)
def remove_recent_library(self, item_key: str):
self.settings.beginGroup(SettingItems.LIBS_LIST)
self.settings.remove(item_key)
@@ -1053,7 +1183,7 @@ class QtDriver(QObject):
all_libs_list = sorted(all_libs.items(), key=lambda item: item[0], reverse=True)
# remove previously saved items
self.settings.clear()
self.settings.remove("")
for item_key, item_value in all_libs_list[:item_limit]:
self.settings.setValue(item_key, item_value)
@@ -1061,23 +1191,43 @@ class QtDriver(QObject):
self.settings.endGroup()
self.settings.sync()
def open_library(self, path: Path | str):
"""Opens a TagStudio library."""
def open_library(self, path: Path) -> None:
"""Open a TagStudio library."""
open_message: str = f'Opening Library "{str(path)}"...'
self.main_window.landing_widget.set_status_label(open_message)
self.main_window.statusbar.showMessage(open_message, 3)
self.main_window.repaint()
self.lib.open_library(path)
open_status: LibraryStatus = self.lib.open_library(path)
# Migration is required
if open_status.json_migration_req:
self.migration_modal = JsonMigrationModal(path)
self.migration_modal.migration_finished.connect(
lambda: self.init_library(path, self.lib.open_library(path))
)
self.main_window.landing_widget.set_status_label("")
self.migration_modal.paged_panel.show()
else:
self.init_library(path, open_status)
def init_library(self, path: Path, open_status: LibraryStatus):
if not open_status.success:
self.show_error_message(open_status.message or "Error opening library.")
return open_status
self.init_workers()
self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE)
# TODO - make this call optional
self.add_new_files_callback()
if self.lib.entries_count < 10000:
self.add_new_files_callback()
self.update_libs_list(path)
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
self.main_window.setWindowTitle(title_text)
self.main_window.setAcceptDrops(True)
self.selected.clear()
self.preview_panel.update_widgets()
@@ -1086,3 +1236,28 @@ class QtDriver(QObject):
self.filter_items()
self.main_window.toggle_landing_page(enabled=False)
return open_status
def drop_event(self, event: QDropEvent):
if event.source() is self:
return
if not event.mimeData().hasUrls():
return
urls = event.mimeData().urls()
logger.info("New items dragged in", urls=urls)
drop_import = DropImportModal(self)
drop_import.import_urls(urls)
def drag_enter_event(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def drag_move_event(self, event: QDragMoveEvent):
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
+30 -17
View File
@@ -2,6 +2,7 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
from pathlib import Path
import cv2
@@ -12,9 +13,10 @@ from PySide6.QtCore import (
QObject,
Signal,
)
from src.core.constants import DOC_TYPES, IMAGE_TYPES, VIDEO_TYPES
from src.core.library import Library
from src.core.library.alchemy.fields import _FieldID
from src.core.media_types import MediaCategories
from src.qt.helpers.file_tester import is_readable_video
logger = structlog.get_logger(__name__)
@@ -74,7 +76,8 @@ class CollageIconRenderer(QObject):
color=self.get_file_color(filepath.suffix.lower()),
)
if filepath.suffix.lower() in IMAGE_TYPES:
ext: str = filepath.suffix.lower()
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
try:
with Image.open(str(self.lib.library_dir / entry.path)) as pic:
if keep_aspect:
@@ -84,23 +87,34 @@ class CollageIconRenderer(QObject):
if data_tint_mode and color:
pic = pic.convert(mode="RGB")
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except DecompressionBombError:
logger.exception("One of the images was too big", entry=entry.path)
elif filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(str(filepath))
except DecompressionBombError as e:
logger.info(f"[ERROR] One of the images was too big ({e})")
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.VIDEO_TYPES
) and is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
# NOTE: Depending on the video format, compression, and
# frame count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
max_frame_seek: int = 10
for i in range(
0,
min(
max_frame_seek,
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
with Image.fromarray(frame, mode="RGB") as pic:
if keep_aspect:
@@ -109,7 +123,6 @@ class CollageIconRenderer(QObject):
pic = pic.resize(size)
if data_tint_mode and color:
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except (UnidentifiedImageError, FileNotFoundError):
logger.error("Couldn't read entry", entry=entry.path)
@@ -130,13 +143,13 @@ class CollageIconRenderer(QObject):
self.done.emit()
def get_file_color(self, ext: str):
if ext.lower().replace(".", "", 1) == "gif":
if ext.lower() == "gif":
return "\033[93m"
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
return "\033[37m"
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
return "\033[96m"
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
return "\033[92m"
else:
return "\033[97m"
+7 -6
View File
@@ -113,8 +113,7 @@ class FieldContainer(QWidget):
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
if callback is not None:
self.copy_button.is_connected = True
self.copy_button.is_connected = callable(callback)
def set_edit_callback(self, callback: Callable):
if self.edit_button.is_connected:
@@ -122,8 +121,7 @@ class FieldContainer(QWidget):
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
if callback is not None:
self.edit_button.is_connected = True
self.edit_button.is_connected = callable(callback)
def set_remove_callback(self, callback: Callable):
if self.remove_button.is_connected:
@@ -131,11 +129,14 @@ class FieldContainer(QWidget):
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
self.remove_button.is_connected = True
self.remove_button.is_connected = callable(callback)
def set_inner_widget(self, widget: "FieldWidget"):
if self.field_layout.itemAt(0):
self.field_layout.itemAt(0).widget().deleteLater()
old: QWidget = self.field_layout.itemAt(0).widget()
self.field_layout.removeWidget(old)
old.deleteLater()
self.field_layout.addWidget(widget)
def get_inner_widget(self):
+126 -29
View File
@@ -10,8 +10,8 @@ from typing import TYPE_CHECKING
import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, QSize, Qt
from PySide6.QtGui import QAction, QEnterEvent, QPixmap
from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl
from PySide6.QtGui import QAction, QDrag, QEnterEvent, QPixmap
from PySide6.QtWidgets import (
QBoxLayout,
QCheckBox,
@@ -21,17 +21,15 @@ from PySide6.QtWidgets import (
QWidget,
)
from src.core.constants import (
AUDIO_TYPES,
IMAGE_TYPES,
TAG_ARCHIVED,
TAG_FAVORITE,
VIDEO_TYPES,
)
from src.core.library import Entry, ItemType, Library
from src.core.library.alchemy.enums import FilterState
from src.core.library.alchemy.fields import _FieldID
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.widgets.thumb_button import ThumbButton
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -88,6 +86,7 @@ class ItemThumb(FlowWidget):
small_text_style = (
"background-color:rgba(0, 0, 0, 192);"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
@@ -100,6 +99,7 @@ class ItemThumb(FlowWidget):
med_text_style = (
"background-color:rgba(0, 0, 0, 192);"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:18px;"
@@ -110,6 +110,8 @@ class ItemThumb(FlowWidget):
"padding-left: 1px;"
)
filename_style = "font-size:10px;"
def __init__(
self,
mode: ItemType,
@@ -117,6 +119,7 @@ class ItemThumb(FlowWidget):
driver: "QtDriver",
thumb_size: tuple[int, int],
grid_idx: int,
show_filename_label: bool = False,
):
super().__init__()
self.grid_idx = grid_idx
@@ -125,9 +128,24 @@ class ItemThumb(FlowWidget):
self.driver = driver
self.item_id: int | None = None
self.thumb_size: tuple[int, int] = thumb_size
self.show_filename_label: bool = show_filename_label
self.label_height = 12
self.label_spacing = 4
self.setMinimumSize(*thumb_size)
self.setMaximumSize(*thumb_size)
self.setMouseTracking(True)
check_size = 24
self.setFixedSize(
thumb_size[0],
thumb_size[1]
+ ((self.label_height + self.label_spacing) if show_filename_label else 0),
)
self.thumb_container = QWidget()
self.base_layout = QVBoxLayout(self)
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.base_layout.setSpacing(0)
self.setLayout(self.base_layout)
# +----------+
# | ARC FAV| Top Right: Favorite & Archived Badges
@@ -135,6 +153,8 @@ class ItemThumb(FlowWidget):
# | |
# |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon
# +----------+ Lower Right: Collation Count, Video Length, or Word Count
#
# Filename Underneath: (Optional) Filename
# Thumbnail ============================================================
@@ -144,9 +164,9 @@ class ItemThumb(FlowWidget):
# || ||
# |*--------*|
# +----------+
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName("baseLayout")
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.thumb_layout = QVBoxLayout(self.thumb_container)
self.thumb_layout.setObjectName("baseLayout")
self.thumb_layout.setContentsMargins(0, 0, 0, 0)
# +----------+
# |[~~~~~~~~]|
@@ -159,7 +179,7 @@ class ItemThumb(FlowWidget):
self.top_layout.setContentsMargins(6, 6, 6, 6)
self.top_container = QWidget()
self.top_container.setLayout(self.top_layout)
self.base_layout.addWidget(self.top_container)
self.thumb_layout.addWidget(self.top_container)
# +----------+
# |[~~~~~~~~]|
@@ -167,7 +187,7 @@ class ItemThumb(FlowWidget):
# | | |
# | v |
# +----------+
self.base_layout.addStretch(2)
self.thumb_layout.addStretch(2)
# +----------+
# |[~~~~~~~~]|
@@ -180,25 +200,26 @@ class ItemThumb(FlowWidget):
self.bottom_layout.setContentsMargins(6, 6, 6, 6)
self.bottom_container = QWidget()
self.bottom_container.setLayout(self.bottom_layout)
self.base_layout.addWidget(self.bottom_container)
self.thumb_layout.addWidget(self.bottom_container)
self.thumb_button = ThumbButton(self, thumb_size)
self.thumb_button = ThumbButton(self.thumb_container, thumb_size)
self.renderer = ThumbRenderer()
self.renderer.updated.connect(
lambda ts, i, s, ext: (
lambda ts, i, s, fn, ext: (
self.update_thumb(ts, image=i),
self.update_size(ts, size=s),
self.set_filename_text(fn),
self.set_extension(ext),
)
)
self.thumb_button.setFlat(True)
self.thumb_button.setLayout(self.base_layout)
self.thumb_button.setLayout(self.thumb_layout)
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper("")
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction("Open file in explorer", self)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
@@ -284,6 +305,16 @@ class ItemThumb(FlowWidget):
self.badges[badge_type] = badge
self.cb_layout.addWidget(badge)
# Filename Label =======================================================
self.file_label = QLabel(text="Filename")
self.file_label.setStyleSheet(ItemThumb.filename_style)
self.file_label.setMaximumHeight(self.label_height)
if not show_filename_label:
self.file_label.setHidden(True)
self.base_layout.addWidget(self.thumb_container)
self.base_layout.addWidget(self.file_label)
self.set_mode(mode)
@property
@@ -297,11 +328,11 @@ class ItemThumb(FlowWidget):
def set_mode(self, mode: ItemType | None) -> None:
if mode is None:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
self.unsetCursor()
self.thumb_button.unsetCursor()
self.thumb_button.setHidden(True)
elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.cb_container.setHidden(False)
# Count Badge depends on file extension (video length, word count)
@@ -311,7 +342,7 @@ class ItemThumb(FlowWidget):
self.ext_badge.setHidden(True)
elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.cb_container.setHidden(True)
self.ext_badge.setHidden(True)
@@ -320,7 +351,7 @@ class ItemThumb(FlowWidget):
self.item_type_badge.setHidden(False)
elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.ext_badge.setHidden(True)
self.count_badge.setHidden(False)
@@ -330,10 +361,26 @@ class ItemThumb(FlowWidget):
def set_extension(self, ext: str) -> None:
if ext and ext.startswith(".") is False:
ext = "." + ext
if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]:
media_types: set[MediaType] = MediaCategories.get_types(ext)
if (
ext
and not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES)
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES)
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES)
or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES)
or ext
in [
".apng",
".avif",
".exr",
".gif",
".jxl",
".webp",
]
):
self.ext_badge.setHidden(False)
self.ext_badge.setText(ext.upper()[1:])
if ext in VIDEO_TYPES + AUDIO_TYPES:
if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types:
self.count_badge.setHidden(False)
else:
if self.mode == ItemType.ENTRY:
@@ -349,14 +396,40 @@ class ItemThumb(FlowWidget):
self.ext_badge.setHidden(True)
self.count_badge.setHidden(True)
def set_filename_text(self, filename: Path | str | None):
self.file_label.setText(str(filename))
def set_filename_visibility(self, set_visible: bool):
"""Toggle the visibility of the filename label.
Args:
set_visible (bool): Show the filename, true or false.
"""
if set_visible:
if self.file_label.isHidden():
self.file_label.setHidden(False)
self.setFixedHeight(self.thumb_size[1] + self.label_height + self.label_spacing)
else:
self.file_label.setHidden(True)
self.setFixedHeight(self.thumb_size[1])
self.show_filename_label = set_visible
def update_thumb(self, timestamp: float, image: QPixmap | None = None):
"""Update attributes of a thumbnail element."""
if timestamp > ItemThumb.update_cutoff:
self.thumb_button.setIcon(image if image else QPixmap())
def update_size(self, timestamp: float, size: QSize):
"""Updates attributes of a thumbnail element."""
if timestamp > ItemThumb.update_cutoff and self.thumb_button.iconSize != size:
"""Updates attributes of a thumbnail element.
Args:
timestamp (float | None): The UTC timestamp for when this call was
originally dispatched. Used to skip outdated jobs.
size (QSize): The new thumbnail size to set.
"""
if timestamp > ItemThumb.update_cutoff:
self.thumb_size = size.toTuple() # type: ignore
self.thumb_button.setIconSize(size)
self.thumb_button.setMinimumSize(size)
self.thumb_button.setMaximumSize(size)
@@ -364,9 +437,9 @@ class ItemThumb(FlowWidget):
def update_clickable(self, clickable: typing.Callable):
"""Updates attributes of a thumbnail element."""
if self.thumb_button.is_connected:
self.thumb_button.clicked.disconnect()
self.thumb_button.pressed.disconnect()
if clickable:
self.thumb_button.clicked.connect(clickable)
self.thumb_button.pressed.connect(clickable)
self.thumb_button.is_connected = True
def refresh_badge(self, entry: Entry | None = None):
@@ -436,9 +509,7 @@ class ItemThumb(FlowWidget):
entry, toggle_value, tag_id, _FieldID.TAGS_META.name, create_field=True
)
# update the entry
self.driver.frame_content[idx] = self.lib.search_library(
FilterState(id=entry.id)
).items[0]
self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id)
self.driver.update_badges(update_items)
@@ -466,3 +537,29 @@ class ItemThumb(FlowWidget):
if self.driver.preview_panel.is_open:
self.driver.preview_panel.update_widgets()
def mouseMoveEvent(self, event): # noqa: N802
if event.buttons() is not Qt.MouseButton.LeftButton:
return
drag = QDrag(self.driver)
paths = []
mimedata = QMimeData()
selected_idxs = self.driver.selected
if self.grid_idx not in selected_idxs:
selected_idxs = [self.grid_idx]
for grid_idx in selected_idxs:
id = self.driver.item_thumbs[grid_idx].item_id
entry = self.lib.get_entry(id)
if not entry:
continue
url = QUrl.fromLocalFile(Path(self.lib.library_dir) / entry.path)
paths.append(url)
mimedata.setUrls(paths)
drag.setMimeData(mimedata)
drag.exec(Qt.DropAction.CopyAction)
logger.info("dragged files to external program", thumbnail_indexs=selected_idxs)
+209
View File
@@ -0,0 +1,209 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import typing
from pathlib import Path
from time import gmtime
from typing import Any
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer
from PySide6.QtWidgets import (
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSlider,
QWidget,
)
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class MediaPlayer(QWidget):
"""A basic media player widget.
Gives a basic control set to manage media playback.
"""
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
self.driver = driver
self.setFixedHeight(50)
self.filepath: Path | None = None
self.player = QMediaPlayer()
self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player))
# Used to keep track of play state.
# It would be nice if we could use QMediaPlayer.PlaybackState,
# but this will always show StoppedState when changing
# tracks. Therefore, we wouldn't know if the previous
# state was paused or playing
self.is_paused = False
# Subscribe to player events from MediaPlayer
self.player.positionChanged.connect(self.player_position_changed)
self.player.mediaStatusChanged.connect(self.media_status_changed)
self.player.playingChanged.connect(self.playing_changed)
self.player.audioOutput().mutedChanged.connect(self.muted_changed)
# Media controls
self.base_layout = QGridLayout(self)
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.base_layout.setSpacing(0)
self.pslider = QSlider(self)
self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.pslider.setTickPosition(QSlider.TickPosition.NoTicks)
self.pslider.setSingleStep(1)
self.pslider.setOrientation(Qt.Orientation.Horizontal)
self.pslider.sliderReleased.connect(self.slider_released)
self.pslider.valueChanged.connect(self.slider_value_changed)
self.media_btns_layout = QHBoxLayout()
policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.play_pause = QPushButton("", self)
self.play_pause.setFlat(True)
self.play_pause.setSizePolicy(policy)
self.play_pause.clicked.connect(self.toggle_pause)
self.load_play_pause_icon(playing=False)
self.media_btns_layout.addWidget(self.play_pause)
self.mute = QPushButton("", self)
self.mute.setFlat(True)
self.mute.setSizePolicy(policy)
self.mute.clicked.connect(self.toggle_mute)
self.load_mute_unmute_icon(muted=False)
self.media_btns_layout.addWidget(self.mute)
self.position_label = QLabel("0:00")
self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight)
self.base_layout.addWidget(self.pslider, 0, 0, 1, 2)
self.base_layout.addLayout(self.media_btns_layout, 1, 0)
self.base_layout.addWidget(self.position_label, 1, 1)
def format_time(self, ms: int) -> str:
"""Format the given time.
Formats the given time in ms to a nicer format.
Args:
ms: Time in ms
Returns:
A formatted time:
"1:43"
The formatted time will only include the hour if
the provided time is at least 60 minutes.
"""
time = gmtime(ms / 1000)
return (
f"{time.tm_hour}:{time.tm_min}:{time.tm_sec:02}"
if time.tm_hour > 0
else f"{time.tm_min}:{time.tm_sec:02}"
)
def toggle_pause(self) -> None:
"""Toggle the pause state of the media."""
if self.player.isPlaying():
self.player.pause()
self.is_paused = True
else:
self.player.play()
self.is_paused = False
def toggle_mute(self) -> None:
"""Toggle the mute state of the media."""
if self.player.audioOutput().isMuted():
self.player.audioOutput().setMuted(False)
else:
self.player.audioOutput().setMuted(True)
def playing_changed(self, playing: bool) -> None:
self.load_play_pause_icon(playing)
def muted_changed(self, muted: bool) -> None:
self.load_mute_unmute_icon(muted)
def stop(self) -> None:
"""Clear the filepath and stop the player."""
self.filepath = None
self.player.stop()
def play(self, filepath: Path) -> None:
"""Set the source of the QMediaPlayer and play."""
self.filepath = filepath
if not self.is_paused:
self.player.stop()
self.player.setSource(QUrl.fromLocalFile(self.filepath))
self.player.play()
else:
self.player.setSource(QUrl.fromLocalFile(self.filepath))
def load_play_pause_icon(self, playing: bool) -> None:
icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon
self.set_icon(self.play_pause, icon)
def load_mute_unmute_icon(self, muted: bool) -> None:
icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon
self.set_icon(self.mute, icon)
def set_icon(self, btn: QPushButton, icon: Any) -> None:
pix_map = QPixmap()
if pix_map.loadFromData(icon):
btn.setIcon(QIcon(pix_map))
else:
logging.error("failed to load svg file")
def slider_value_changed(self, value: int) -> None:
current = self.format_time(value)
duration = self.format_time(self.player.duration())
self.position_label.setText(f"{current} / {duration}")
def slider_released(self) -> None:
was_playing = self.player.isPlaying()
self.player.setPosition(self.pslider.value())
# Setting position causes the player to start playing again.
# We should reset back to initial state.
if not was_playing:
self.player.pause()
def player_position_changed(self, position: int) -> None:
if not self.pslider.isSliderDown():
# User isn't using the slider, so update position in widgets.
self.pslider.setValue(position)
current = self.format_time(self.player.position())
duration = self.format_time(self.player.duration())
self.position_label.setText(f"{current} / {duration}")
if self.player.duration() == position:
self.player.pause()
self.player.setPosition(0)
def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None:
# We can only set the slider duration once we know the size of the media
if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None:
self.pslider.setMinimum(0)
self.pslider.setMaximum(self.player.duration())
current = self.format_time(self.player.position())
duration = self.format_time(self.player.duration())
self.position_label.setText(f"{current} / {duration}")
+784
View File
@@ -0,0 +1,784 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from pathlib import Path
import structlog
from PySide6.QtCore import QObject, Qt, QThreadPool, Signal
from PySide6.QtWidgets import (
QApplication,
QGridLayout,
QHBoxLayout,
QLabel,
QMessageBox,
QProgressDialog,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from sqlalchemy import and_, select
from sqlalchemy.orm import Session
from src.core.constants import TS_FOLDER_NAME
from src.core.enums import LibraryPrefs
from src.core.library.alchemy.enums import FieldTypeEnum, TagColor
from src.core.library.alchemy.fields import TagBoxField, _FieldID
from src.core.library.alchemy.joins import TagField, TagSubtag
from src.core.library.alchemy.library import Library as SqliteLibrary
from src.core.library.alchemy.models import Entry, Tag, TagAlias
from src.core.library.json.library import Library as JsonLibrary # type: ignore
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.widgets.paged_panel.paged_body_wrapper import PagedBodyWrapper
from src.qt.widgets.paged_panel.paged_panel import PagedPanel
from src.qt.widgets.paged_panel.paged_panel_state import PagedPanelState
logger = structlog.get_logger(__name__)
class JsonMigrationModal(QObject):
"""A modal for data migration from v9.4 JSON to v9.5+ SQLite."""
migration_cancelled = Signal()
migration_finished = Signal()
def __init__(self, path: Path):
super().__init__()
self.done: bool = False
self.path: Path = path
self.stack: list[PagedPanelState] = []
self.json_lib: JsonLibrary = None
self.sql_lib: SqliteLibrary = None
self.is_migration_initialized: bool = False
self.discrepancies: list[str] = []
self.title: str = f'Save Format Migration: "{self.path}"'
self.warning: str = "<b><a style='color: #e22c3c'>(!)</a></b>"
self.old_entry_count: int = 0
self.old_tag_count: int = 0
self.old_ext_count: int = 0
self.old_ext_type: bool = None
self.field_parity: bool = False
self.path_parity: bool = False
self.shorthand_parity: bool = False
self.subtag_parity: bool = False
self.alias_parity: bool = False
self.color_parity: bool = False
self.init_page_info()
self.init_page_convert()
self.paged_panel: PagedPanel = PagedPanel((700, 640), self.stack)
def init_page_info(self) -> None:
"""Initialize the migration info page."""
body_wrapper: PagedBodyWrapper = PagedBodyWrapper()
body_label: QLabel = QLabel(
"Library save files created with TagStudio versions <b>9.4 and below</b> will "
"need to be migrated to the new <b>v9.5+</b> format."
"<br>"
"<h2>What you need to know:</h2>"
"<ul>"
"<li>Your existing library save file will <b><i>NOT</i></b> be deleted</li>"
"<li>Your personal files will <b><i>NOT</i></b> be deleted, moved, or modified</li>"
"<li>The new v9.5+ save format can not be opened in earlier versions of TagStudio</li>"
"</ul>"
)
body_label.setWordWrap(True)
body_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
body_wrapper.layout().addWidget(body_label)
body_wrapper.layout().setContentsMargins(0, 36, 0, 0)
cancel_button: QPushButtonWrapper = QPushButtonWrapper("Cancel")
next_button: QPushButtonWrapper = QPushButtonWrapper("Continue")
cancel_button.clicked.connect(self.migration_cancelled.emit)
self.stack.append(
PagedPanelState(
title=self.title,
body_wrapper=body_wrapper,
buttons=[cancel_button, 1, next_button],
connect_to_back=[cancel_button],
connect_to_next=[next_button],
)
)
def init_page_convert(self) -> None:
"""Initialize the migration conversion page."""
self.body_wrapper_01: PagedBodyWrapper = PagedBodyWrapper()
body_container: QWidget = QWidget()
body_container_layout: QHBoxLayout = QHBoxLayout(body_container)
body_container_layout.setContentsMargins(0, 0, 0, 0)
tab: str = " "
self.match_text: str = "Matched"
self.differ_text: str = "Discrepancy"
entries_text: str = "Entries:"
tags_text: str = "Tags:"
shorthand_text: str = tab + "Shorthands:"
subtags_text: str = tab + "Parent Tags:"
aliases_text: str = tab + "Aliases:"
colors_text: str = tab + "Colors:"
ext_text: str = "File Extension List:"
ext_type_text: str = "Extension List Type:"
desc_text: str = (
"<br>Start and preview the results of the library migration process. "
'The converted library will <i>not</i> be used unless you click "Finish Migration". '
"<br><br>"
'Library data should either have matching values or a feature a "Matched" label. '
'Values that do not match will be displayed in red and feature a "<b>(!)</b>" '
"symbol next to them."
"<br><center><i>"
"This process may take up to several minutes for larger libraries."
"</i></center>"
)
path_parity_text: str = tab + "Paths:"
field_parity_text: str = tab + "Fields:"
self.entries_row: int = 0
self.path_row: int = 1
self.fields_row: int = 2
self.tags_row: int = 3
self.shorthands_row: int = 4
self.subtags_row: int = 5
self.aliases_row: int = 6
self.colors_row: int = 7
self.ext_row: int = 8
self.ext_type_row: int = 9
old_lib_container: QWidget = QWidget()
old_lib_layout: QVBoxLayout = QVBoxLayout(old_lib_container)
old_lib_title: QLabel = QLabel("<h2>v9.4 Library</h2>")
old_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
old_lib_layout.addWidget(old_lib_title)
old_content_container: QWidget = QWidget()
self.old_content_layout: QGridLayout = QGridLayout(old_content_container)
self.old_content_layout.setContentsMargins(0, 0, 0, 0)
self.old_content_layout.setSpacing(3)
self.old_content_layout.addWidget(QLabel(entries_text), self.entries_row, 0)
self.old_content_layout.addWidget(QLabel(path_parity_text), self.path_row, 0)
self.old_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0)
self.old_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0)
self.old_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0)
self.old_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0)
self.old_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0)
self.old_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0)
self.old_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0)
self.old_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0)
old_entry_count: QLabel = QLabel()
old_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight)
old_path_value: QLabel = QLabel()
old_path_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_field_value: QLabel = QLabel()
old_field_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_tag_count: QLabel = QLabel()
old_tag_count.setAlignment(Qt.AlignmentFlag.AlignRight)
old_shorthand_count: QLabel = QLabel()
old_shorthand_count.setAlignment(Qt.AlignmentFlag.AlignRight)
old_subtag_value: QLabel = QLabel()
old_subtag_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_alias_value: QLabel = QLabel()
old_alias_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_color_value: QLabel = QLabel()
old_color_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_ext_count: QLabel = QLabel()
old_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight)
old_ext_type: QLabel = QLabel()
old_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight)
self.old_content_layout.addWidget(old_entry_count, self.entries_row, 1)
self.old_content_layout.addWidget(old_path_value, self.path_row, 1)
self.old_content_layout.addWidget(old_field_value, self.fields_row, 1)
self.old_content_layout.addWidget(old_tag_count, self.tags_row, 1)
self.old_content_layout.addWidget(old_shorthand_count, self.shorthands_row, 1)
self.old_content_layout.addWidget(old_subtag_value, self.subtags_row, 1)
self.old_content_layout.addWidget(old_alias_value, self.aliases_row, 1)
self.old_content_layout.addWidget(old_color_value, self.colors_row, 1)
self.old_content_layout.addWidget(old_ext_count, self.ext_row, 1)
self.old_content_layout.addWidget(old_ext_type, self.ext_type_row, 1)
self.old_content_layout.addWidget(QLabel(), self.path_row, 2)
self.old_content_layout.addWidget(QLabel(), self.fields_row, 2)
self.old_content_layout.addWidget(QLabel(), self.shorthands_row, 2)
self.old_content_layout.addWidget(QLabel(), self.subtags_row, 2)
self.old_content_layout.addWidget(QLabel(), self.aliases_row, 2)
self.old_content_layout.addWidget(QLabel(), self.colors_row, 2)
old_lib_layout.addWidget(old_content_container)
new_lib_container: QWidget = QWidget()
new_lib_layout: QVBoxLayout = QVBoxLayout(new_lib_container)
new_lib_title: QLabel = QLabel("<h2>v9.5+ Library</h2>")
new_lib_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
new_lib_layout.addWidget(new_lib_title)
new_content_container: QWidget = QWidget()
self.new_content_layout: QGridLayout = QGridLayout(new_content_container)
self.new_content_layout.setContentsMargins(0, 0, 0, 0)
self.new_content_layout.setSpacing(3)
self.new_content_layout.addWidget(QLabel(entries_text), self.entries_row, 0)
self.new_content_layout.addWidget(QLabel(path_parity_text), self.path_row, 0)
self.new_content_layout.addWidget(QLabel(field_parity_text), self.fields_row, 0)
self.new_content_layout.addWidget(QLabel(tags_text), self.tags_row, 0)
self.new_content_layout.addWidget(QLabel(shorthand_text), self.shorthands_row, 0)
self.new_content_layout.addWidget(QLabel(subtags_text), self.subtags_row, 0)
self.new_content_layout.addWidget(QLabel(aliases_text), self.aliases_row, 0)
self.new_content_layout.addWidget(QLabel(colors_text), self.colors_row, 0)
self.new_content_layout.addWidget(QLabel(ext_text), self.ext_row, 0)
self.new_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0)
new_entry_count: QLabel = QLabel()
new_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight)
path_parity_value: QLabel = QLabel()
path_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight)
field_parity_value: QLabel = QLabel()
field_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight)
new_tag_count: QLabel = QLabel()
new_tag_count.setAlignment(Qt.AlignmentFlag.AlignRight)
new_shorthand_count: QLabel = QLabel()
new_shorthand_count.setAlignment(Qt.AlignmentFlag.AlignRight)
subtag_parity_value: QLabel = QLabel()
subtag_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight)
alias_parity_value: QLabel = QLabel()
alias_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight)
new_color_value: QLabel = QLabel()
new_color_value.setAlignment(Qt.AlignmentFlag.AlignRight)
new_ext_count: QLabel = QLabel()
new_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight)
new_ext_type: QLabel = QLabel()
new_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight)
self.new_content_layout.addWidget(new_entry_count, self.entries_row, 1)
self.new_content_layout.addWidget(path_parity_value, self.path_row, 1)
self.new_content_layout.addWidget(field_parity_value, self.fields_row, 1)
self.new_content_layout.addWidget(new_tag_count, self.tags_row, 1)
self.new_content_layout.addWidget(new_shorthand_count, self.shorthands_row, 1)
self.new_content_layout.addWidget(subtag_parity_value, self.subtags_row, 1)
self.new_content_layout.addWidget(alias_parity_value, self.aliases_row, 1)
self.new_content_layout.addWidget(new_color_value, self.colors_row, 1)
self.new_content_layout.addWidget(new_ext_count, self.ext_row, 1)
self.new_content_layout.addWidget(new_ext_type, self.ext_type_row, 1)
self.new_content_layout.addWidget(QLabel(), self.entries_row, 2)
self.new_content_layout.addWidget(QLabel(), self.path_row, 2)
self.new_content_layout.addWidget(QLabel(), self.fields_row, 2)
self.new_content_layout.addWidget(QLabel(), self.shorthands_row, 2)
self.new_content_layout.addWidget(QLabel(), self.tags_row, 2)
self.new_content_layout.addWidget(QLabel(), self.subtags_row, 2)
self.new_content_layout.addWidget(QLabel(), self.aliases_row, 2)
self.new_content_layout.addWidget(QLabel(), self.colors_row, 2)
self.new_content_layout.addWidget(QLabel(), self.ext_row, 2)
self.new_content_layout.addWidget(QLabel(), self.ext_type_row, 2)
new_lib_layout.addWidget(new_content_container)
desc_label = QLabel(desc_text)
desc_label.setWordWrap(True)
body_container_layout.addStretch(2)
body_container_layout.addWidget(old_lib_container)
body_container_layout.addStretch(1)
body_container_layout.addWidget(new_lib_container)
body_container_layout.addStretch(2)
self.body_wrapper_01.layout().addWidget(body_container)
self.body_wrapper_01.layout().addWidget(desc_label)
self.body_wrapper_01.layout().setSpacing(12)
back_button: QPushButtonWrapper = QPushButtonWrapper("Back")
start_button: QPushButtonWrapper = QPushButtonWrapper("Start and Preview")
start_button.setMinimumWidth(120)
start_button.clicked.connect(self.migrate)
start_button.clicked.connect(lambda: finish_button.setDisabled(False))
start_button.clicked.connect(lambda: start_button.setDisabled(True))
finish_button: QPushButtonWrapper = QPushButtonWrapper("Finish Migration")
finish_button.setMinimumWidth(120)
finish_button.setDisabled(True)
finish_button.clicked.connect(self.finish_migration)
finish_button.clicked.connect(self.migration_finished.emit)
self.stack.append(
PagedPanelState(
title=self.title,
body_wrapper=self.body_wrapper_01,
buttons=[back_button, 1, start_button, 1, finish_button],
connect_to_back=[back_button],
connect_to_next=[finish_button],
)
)
def migrate(self, skip_ui: bool = False):
"""Open and migrate the JSON library to SQLite."""
if not self.is_migration_initialized:
self.paged_panel.update_frame()
self.paged_panel.update()
# Open the JSON Library
self.json_lib = JsonLibrary()
self.json_lib.open_library(self.path)
# Update JSON UI
self.update_json_entry_count(len(self.json_lib.entries))
self.update_json_tag_count(len(self.json_lib.tags))
self.update_json_ext_count(len(self.json_lib.ext_list))
self.update_json_ext_type(self.json_lib.is_exclude_list)
self.migration_progress(skip_ui=skip_ui)
self.is_migration_initialized = True
def migration_progress(self, skip_ui: bool = False):
"""Initialize the progress bar and iterator for the library migration."""
pb = QProgressDialog(
labelText="",
cancelButtonText="",
minimum=0,
maximum=0,
)
pb.setCancelButton(None)
self.body_wrapper_01.layout().addWidget(pb)
iterator = FunctionIterator(self.migration_iterator)
iterator.value.connect(
lambda x: (
pb.setLabelText(f"<h4>{x}</h4>"),
self.update_sql_value_ui(show_msg_box=False)
if x == "Checking for Parity..."
else (),
self.update_parity_ui() if x == "Checking for Parity..." else (),
)
)
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
self.update_sql_value_ui(show_msg_box=not skip_ui),
pb.setMinimum(1),
pb.setValue(1),
)
)
QThreadPool.globalInstance().start(r)
def migration_iterator(self):
"""Iterate over the library migration process."""
try:
# Convert JSON Library to SQLite
yield "Creating SQL Database Tables..."
self.sql_lib = SqliteLibrary()
self.temp_path: Path = (
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
)
self.sql_lib.storage_path = self.temp_path
if self.temp_path.exists():
logger.info('Temporary migration file "temp_path" already exists. Removing...')
self.temp_path.unlink()
self.sql_lib.open_sqlite_library(
self.json_lib.library_dir, is_new=True, add_default_data=False
)
yield f"Migrating {len(self.json_lib.entries):,d} File Entries..."
self.sql_lib.migrate_json_to_sqlite(self.json_lib)
yield "Checking for Parity..."
check_set = set()
check_set.add(self.check_field_parity())
check_set.add(self.check_path_parity())
check_set.add(self.check_shorthand_parity())
check_set.add(self.check_subtag_parity())
check_set.add(self.check_alias_parity())
check_set.add(self.check_color_parity())
self.update_parity_ui()
if False not in check_set:
yield "Migration Complete!"
else:
yield "Migration Complete, Discrepancies Found"
self.done = True
except Exception as e:
yield f"Error: {type(e).__name__}"
self.done = True
def update_parity_ui(self):
"""Update all parity values UI."""
self.update_parity_value(self.fields_row, self.field_parity)
self.update_parity_value(self.path_row, self.path_parity)
self.update_parity_value(self.shorthands_row, self.shorthand_parity)
self.update_parity_value(self.subtags_row, self.subtag_parity)
self.update_parity_value(self.aliases_row, self.alias_parity)
self.update_parity_value(self.colors_row, self.color_parity)
self.sql_lib.close()
def update_sql_value_ui(self, show_msg_box: bool = True):
"""Update the SQL value count UI."""
self.update_sql_value(
self.entries_row,
self.sql_lib.entries_count,
self.old_entry_count,
)
self.update_sql_value(
self.tags_row,
len(self.sql_lib.tags),
self.old_tag_count,
)
self.update_sql_value(
self.ext_row,
len(self.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)),
self.old_ext_count,
)
self.update_sql_value(
self.ext_type_row,
self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST),
self.old_ext_type,
)
logger.info("Parity check complete!")
if self.discrepancies:
logger.warning("Discrepancies found:")
logger.warning("\n".join(self.discrepancies))
QApplication.beep()
if not show_msg_box:
return
msg_box = QMessageBox()
msg_box.setWindowTitle("Library Discrepancies Found")
msg_box.setText(
"Discrepancies were found between the original and converted library formats. "
"Please review and choose to whether continue with the migration or to cancel."
)
msg_box.setDetailedText("\n".join(self.discrepancies))
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.exec()
def finish_migration(self):
"""Finish the migration upon user approval."""
final_name = self.json_lib.library_dir / TS_FOLDER_NAME / SqliteLibrary.SQL_FILENAME
if self.temp_path.exists():
self.temp_path.rename(final_name)
def update_json_entry_count(self, value: int):
self.old_entry_count = value
label: QLabel = self.old_content_layout.itemAtPosition(self.entries_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_json_tag_count(self, value: int):
self.old_tag_count = value
label: QLabel = self.old_content_layout.itemAtPosition(self.tags_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_json_ext_count(self, value: int):
self.old_ext_count = value
label: QLabel = self.old_content_layout.itemAtPosition(self.ext_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_json_ext_type(self, value: bool):
self.old_ext_type = value
label: QLabel = self.old_content_layout.itemAtPosition(self.ext_type_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_sql_value(self, row: int, value: int | bool, old_value: int | bool):
label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore
warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore
label.setText(self.color_value_conditional(old_value, value))
warning_icon.setText("" if old_value == value else self.warning)
def update_parity_value(self, row: int, value: bool):
result: str = self.match_text if value else self.differ_text
old_label: QLabel = self.old_content_layout.itemAtPosition(row, 1).widget() # type:ignore
new_label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore
old_warning_icon: QLabel = self.old_content_layout.itemAtPosition(row, 2).widget() # type:ignore
new_warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore
old_label.setText(self.color_value_conditional(self.match_text, result))
new_label.setText(self.color_value_conditional(self.match_text, result))
old_warning_icon.setText("" if value else self.warning)
new_warning_icon.setText("" if value else self.warning)
def color_value_default(self, value: int) -> str:
"""Apply the default color to a value."""
return str(f"<b><a style='color: #3b87f0'>{value}</a></b>")
def color_value_conditional(self, old_value: int | str, new_value: int | str) -> str:
"""Apply a conditional color to a value."""
red: str = "#e22c3c"
green: str = "#28bb48"
color = green if old_value == new_value else red
return str(f"<b><a style='color: {color}'>{new_value}</a></b>")
def check_field_parity(self) -> bool:
"""Check if all JSON field data matches the new SQL field data."""
def sanitize_field(session, entry: Entry, value, type, type_key):
if type is FieldTypeEnum.TAGS:
tags = list(
session.scalars(
select(Tag.id)
.join(TagField)
.join(TagBoxField)
.where(
and_(
TagBoxField.entry_id == entry.id,
TagBoxField.id == TagField.field_id,
TagBoxField.type_key == type_key,
)
)
)
)
return set(tags) if tags else None
else:
return value if value else None
def sanitize_json_field(value):
if isinstance(value, list):
return set(value) if value else None
else:
return value if value else None
with Session(self.sql_lib.engine) as session:
for json_entry in self.json_lib.entries:
sql_fields: list[tuple] = []
json_fields: list[tuple] = []
sql_entry: Entry = session.scalar(
select(Entry).where(Entry.id == json_entry.id + 1)
)
if not sql_entry:
logger.info(
"[Field Comparison]",
message=f"NEW (SQL): SQL Entry ID mismatch: {json_entry.id+1}",
)
self.discrepancies.append(
f"[Field Comparison]:\nNEW (SQL): SQL Entry ID not found: {json_entry.id+1}"
)
self.field_parity = False
return self.field_parity
for sf in sql_entry.fields:
sql_fields.append(
(
sql_entry.id,
sf.type.key,
sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key),
)
)
sql_fields.sort()
# NOTE: The JSON database allowed for separate tag fields of the same type with
# different values. The SQL database does not, and instead merges these values
# across all instances of that field on an entry.
# TODO: ROADMAP: "Tag Categories" will merge all field tags onto the entry.
# All visual separation from there will be data-driven from the tag itself.
meta_tags_count: int = 0
content_tags_count: int = 0
tags_count: int = 0
merged_meta_tags: set[int] = set()
merged_content_tags: set[int] = set()
merged_tags: set[int] = set()
for jf in json_entry.fields:
key: str = self.sql_lib.get_field_name_from_id(list(jf.keys())[0]).name
value = sanitize_json_field(list(jf.values())[0])
if key == _FieldID.TAGS_META.name:
meta_tags_count += 1
merged_meta_tags = merged_meta_tags.union(value or [])
elif key == _FieldID.TAGS_CONTENT.name:
content_tags_count += 1
merged_content_tags = merged_content_tags.union(value or [])
elif key == _FieldID.TAGS.name:
tags_count += 1
merged_tags = merged_tags.union(value or [])
else:
# JSON IDs start at 0 instead of 1
json_fields.append((json_entry.id + 1, key, value))
if meta_tags_count:
for _ in range(0, meta_tags_count):
json_fields.append(
(
json_entry.id + 1,
_FieldID.TAGS_META.name,
merged_meta_tags if merged_meta_tags else None,
)
)
if content_tags_count:
for _ in range(0, content_tags_count):
json_fields.append(
(
json_entry.id + 1,
_FieldID.TAGS_CONTENT.name,
merged_content_tags if merged_content_tags else None,
)
)
if tags_count:
for _ in range(0, tags_count):
json_fields.append(
(
json_entry.id + 1,
_FieldID.TAGS.name,
merged_tags if merged_tags else None,
)
)
json_fields.sort()
if not (
json_fields is not None
and sql_fields is not None
and (json_fields == sql_fields)
):
self.discrepancies.append(
f"[Field Comparison]:\nOLD (JSON):{json_fields}\nNEW (SQL):{sql_fields}"
)
self.field_parity = False
return self.field_parity
logger.info(
"[Field Comparison]",
fields="\n".join([str(x) for x in zip(json_fields, sql_fields)]),
)
self.field_parity = True
return self.field_parity
def check_path_parity(self) -> bool:
"""Check if all JSON file paths match the new SQL paths."""
with Session(self.sql_lib.engine) as session:
json_paths: list = sorted([x.path / x.filename for x in self.json_lib.entries])
sql_paths: list = sorted(list(session.scalars(select(Entry.path))))
self.path_parity = (
json_paths is not None and sql_paths is not None and (json_paths == sql_paths)
)
return self.path_parity
def check_subtag_parity(self) -> bool:
"""Check if all JSON subtags match the new SQL subtags."""
sql_subtags: set[int] = None
json_subtags: set[int] = None
with Session(self.sql_lib.engine) as session:
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_subtags = set(
session.scalars(select(TagSubtag.child_id).where(TagSubtag.parent_id == tag.id))
)
# JSON tags allowed self-parenting; SQL tags no longer allow this.
json_subtags = set(self.json_lib.get_tag(tag_id).subtag_ids).difference(
set([self.json_lib.get_tag(tag_id).id])
)
logger.info(
"[Subtag Parity]",
tag_id=tag_id,
json_subtags=json_subtags,
sql_subtags=sql_subtags,
)
if not (
sql_subtags is not None
and json_subtags is not None
and (sql_subtags == json_subtags)
):
self.discrepancies.append(
f"[Subtag Parity]:\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}"
)
self.subtag_parity = False
return self.subtag_parity
self.subtag_parity = True
return self.subtag_parity
def check_ext_type(self) -> bool:
return self.json_lib.is_exclude_list == self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST)
def check_alias_parity(self) -> bool:
"""Check if all JSON aliases match the new SQL aliases."""
sql_aliases: set[str] = None
json_aliases: set[str] = None
with Session(self.sql_lib.engine) as session:
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_aliases = set(
session.scalars(select(TagAlias.name).where(TagAlias.tag_id == tag.id))
)
json_aliases = set([x for x in self.json_lib.get_tag(tag_id).aliases if x])
logger.info(
"[Alias Parity]",
tag_id=tag_id,
json_aliases=json_aliases,
sql_aliases=sql_aliases,
)
if not (
sql_aliases is not None
and json_aliases is not None
and (sql_aliases == json_aliases)
):
self.discrepancies.append(
f"[Alias Parity]:\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}"
)
self.alias_parity = False
return self.alias_parity
self.alias_parity = True
return self.alias_parity
def check_shorthand_parity(self) -> bool:
"""Check if all JSON shorthands match the new SQL shorthands."""
sql_shorthand: str = None
json_shorthand: str = None
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_shorthand = tag.shorthand
json_shorthand = self.json_lib.get_tag(tag_id).shorthand
logger.info(
"[Shorthand Parity]",
tag_id=tag_id,
json_shorthand=json_shorthand,
sql_shorthand=sql_shorthand,
)
if not (
sql_shorthand is not None
and json_shorthand is not None
and (sql_shorthand == json_shorthand)
):
self.discrepancies.append(
f"[Shorthand Parity]:\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}"
)
self.shorthand_parity = False
return self.shorthand_parity
self.shorthand_parity = True
return self.shorthand_parity
def check_color_parity(self) -> bool:
"""Check if all JSON tag colors match the new SQL tag colors."""
sql_color: str = None
json_color: str = None
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_color = tag.color.name
json_color = (
TagColor.get_color_from_str(self.json_lib.get_tag(tag_id).color).name
if self.json_lib.get_tag(tag_id).color != ""
else TagColor.DEFAULT.name
)
logger.info(
"[Color Parity]",
tag_id=tag_id,
json_color=json_color,
sql_color=sql_color,
)
if not (sql_color is not None and json_color is not None and (sql_color == json_color)):
self.discrepancies.append(
f"[Color Parity]:\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}"
)
self.color_parity = False
return self.color_parity
self.color_parity = True
return self.color_parity
@@ -0,0 +1,20 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
class PagedBodyWrapper(QWidget):
"""A state object for paged panels."""
def __init__(self):
super().__init__()
layout: QVBoxLayout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -0,0 +1,112 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget,
)
from src.qt.widgets.paged_panel.paged_panel_state import PagedPanelState
logger = structlog.get_logger(__name__)
class PagedPanel(QWidget):
"""A paginated modal panel."""
def __init__(self, size: tuple[int, int], stack: list[PagedPanelState]):
super().__init__()
self._stack: list[PagedPanelState] = stack
self._index: int = 0
self.setMinimumSize(*size)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.content_container = QWidget()
self.content_layout = QVBoxLayout(self.content_container)
self.content_layout.setContentsMargins(12, 12, 12, 12)
self.title_label = QLabel()
self.title_label.setObjectName("fieldTitle")
self.title_label.setWordWrap(True)
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.body_container = QWidget()
self.body_container.setObjectName("bodyContainer")
self.body_layout = QVBoxLayout(self.body_container)
self.body_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.body_layout.setContentsMargins(0, 0, 0, 0)
self.body_layout.setSpacing(0)
self.button_nav_container = QWidget()
self.button_nav_layout = QHBoxLayout(self.button_nav_container)
self.root_layout.addWidget(self.content_container)
self.content_layout.addWidget(self.title_label)
self.content_layout.addWidget(self.body_container)
self.content_layout.addStretch(1)
self.root_layout.addWidget(self.button_nav_container)
self.init_connections()
self.update_frame()
def init_connections(self):
"""Initialize button navigation connections."""
for frame in self._stack:
for button in frame.connect_to_back:
button.clicked.connect(self.back)
for button in frame.connect_to_next:
button.clicked.connect(self.next)
def back(self):
"""Navigate backward in the state stack. Close if out of bounds."""
if self._index > 0:
self._index = self._index - 1
self.update_frame()
else:
self.close()
def next(self):
"""Navigate forward in the state stack. Close if out of bounds."""
if self._index < len(self._stack) - 1:
self._index = self._index + 1
self.update_frame()
else:
self.close()
def update_frame(self):
"""Update the widgets with the current frame's content."""
frame: PagedPanelState = self._stack[self._index]
# Update Title
self.setWindowTitle(frame.title)
self.title_label.setText(f"<h1>{frame.title}</h1>")
# Update Body Widget
if self.body_layout.itemAt(0):
self.body_layout.itemAt(0).widget().setHidden(True)
self.body_layout.removeWidget(self.body_layout.itemAt(0).widget())
self.body_layout.addWidget(frame.body_wrapper)
self.body_layout.itemAt(0).widget().setHidden(False)
# Update Button Widgets
while self.button_nav_layout.count():
if _ := self.button_nav_layout.takeAt(0).widget():
_.setHidden(True)
for item in frame.buttons:
if isinstance(item, QWidget):
self.button_nav_layout.addWidget(item)
item.setHidden(False)
elif isinstance(item, int):
self.button_nav_layout.addStretch(item)

Some files were not shown because too many files have changed in this diff Show More