Merge branch 'main' into keyboard_nav_improvements

This commit is contained in:
Jann Stute
2025-01-11 20:45:11 +01:00
58 changed files with 28436 additions and 17226 deletions

View File

@@ -62,7 +62,7 @@ TagStudio is a photo & file organization application with an underlying tag-base
If you're interested in contributing to TagStudio, please take a look at the [contribution guidelines](/CONTRIBUTING.md) for how to get started!
Translation hosting generously provided by [Weblate](https://weblate.org/en/). Check out our [project page](<(https://hosted.weblate.org/projects/tagstudio/)>) to help translate TagStudio!
Translation hosting generously provided by [Weblate](https://weblate.org/en/). Check out our [project page](https://hosted.weblate.org/projects/tagstudio/) to help translate TagStudio!
## Current Features
@@ -71,12 +71,12 @@ Translation hosting generously provided by [Weblate](https://weblate.org/en/). C
- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your librarys directory, and is linked to its location.
- Address moved, deleted, or otherwise "unlinked" files by using the "Fix Unlinked Entries" option in the Tools menu.
### Metadata + Tagging
### Tagging + Custom Metadata
- Add custom powerful tags to your library entries
- Add metadata to your library entries, including:
- Name, Author, Artist (Single-Line Text Fields)
- Description, Notes (Multiline Text Fields)
- Tags, Meta Tags, Content Tags (Tag Boxes)
- Create rich tags composed of a name, a list of aliases, and a list of “parent tags” - being tags in which these tags inherit values from.
- Copy and paste tags and fields across file entries
- Generate tags from your existing folder structure with the "Folders to Tags" macro (NOTE: these tags do NOT sync with folders after they are created)
@@ -109,6 +109,9 @@ To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagS
> [!IMPORTANT]
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
> [!IMPORTANT]
> On Linux with non-Qt based Desktop Environments you may be unable to open TagStudio. You need to make sure that "xcb-cursor0" or "libxcb-cursor0" packages are installed. For more info check [Missing linux dependencies](https://github.com/TagStudioDev/TagStudio/discussions/182#discussioncomment-9452896)
### Third-Party Dependencies
- For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
@@ -136,9 +139,15 @@ In order to scan for new files or file changes, youll need to manually go to
> [!NOTE]
> In the future, library refreshing will also be automatically done in the background, or additionally on app startup.
### Adding Metadata to Entries
### Adding Tags to File Entries
To add a metadata field to a file entry, start by clicking the “Add Field” button under the file preview in the right-hand preview panel. From the dropdown menu, select the type of metadata field youd like to add to the entry.
Click the "Add Tag" button at the bottom of the preview panel with one or more tags selected. Search for existing inside the new dialog popup or create a new one from here. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the first item in the list. Press Enter/Return once more to close the dialog box.
To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears.
### Adding Metadata to File Entries
To add a metadata field to a file entry, start by clicking the “Add Field” button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field youd like to add to the entry
### Editing Metadata Fields
@@ -146,10 +155,6 @@ To add a metadata field to a file entry, start by clicking the “Add Field” b
Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup.
#### Tag Box
Click the “+” button at the end of the Tags list, and search for tags to add inside the new dialog popup. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the add the first item in the list. Press Enter/Return once more to close the dialog box
> [!WARNING]
> Keyboard control and navigation is currently _very_ buggy, but will be improved in future versions.

View File

@@ -7,6 +7,9 @@ For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/
!!! info "For macOS Users"
On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
!!! info "For Linux Users"
On Linux with non-Qt based Desktop Environments you may be unable to open TagStudio. You need to make sure that "xcb-cursor0" or "libxcb-cursor0" packages are installed. For more info check [Missing linux dependencies](https://github.com/TagStudioDev/TagStudio/discussions/182#discussioncomment-9452896)
## Optional Arguments
Optional arguments to pass to the program:
@@ -15,4 +18,4 @@ Optional arguments to pass to the program:
: Path to a TagStudio Library folder to open on start.
`--config-file <path>` / `-c <path>`
: Path to the TagStudio config file to load.
: Path to the TagStudio config file to load.

View File

@@ -1,8 +1,15 @@
---
tags:
- Upcoming Feature
---
# Tag Categories
# Tag Categories (v9.5)
Replaces [Tag Fields](field.md#tag_box). Tags are able to be marked as a “category” which then displays as tag fields currently do, with any tags inheriting from that category being displayed underneath.
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. Tags marked as categories will show themselves and all tags inheriting from it (including recursively) underneath a field-like section with the tag's name. This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section.
### Built-In Tags and Categories
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
### Migrating from v9.4 Libraries
Due to the nature of how tags and Tag Felids operated prior to v9.5, the organization style of Tag Categories vs Tag Fields is not 1:1. Instead of tags being organized into fields on a per-entry basis, tags themselves determine their organizational layout via the "Is Property" flag. Any tags _(not currently inheriting from either the "Favorite" or "Archived" tags)_ will be shown under the default "Tags" header upon migrating to the v9.5+ library format. Similar organization to Tag Fields can be achieved by using the built-in "Meta Tags" tag or any other marked with "Is Category" and then setting those tags as parents for other tags to inherit from.

View File

@@ -34,8 +34,8 @@ Features are broken up into the following priority levels, with nested prioritie
- [ ] Existing colors are now a set of base colors [HIGH]
- [ ] Editable [MEDIUM]
- [ ] Non-removable [HIGH]
- [ ] [Tag Categories](../library/tag_categories.md) [HIGH]
- [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
- [ ] Title is tag name [HIGH]
- [ ] Title has tag color [MEDIUM]
- [ ] Tag marked as category does not display as a tag itself [HIGH]
@@ -99,6 +99,7 @@ Features are broken up into the following priority levels, with nested prioritie
- [ ] Fuzzy Search [LOW] [#400](https://github.com/TagStudioDev/TagStudio/issues/400)
- [ ] Sortable results [HIGH] [#68](https://github.com/TagStudioDev/TagStudio/issues/68)
- [ ] Sort by relevance [HIGH]
- [x] Sort by date added [HIGH]
- [ ] Sort by date created [HIGH]
- [ ] Sort by date modified [HIGH]
- [ ] Sort by date taken (photos) [MEDIUM]
@@ -169,8 +170,8 @@ These version milestones are rough estimations for when the previous core featur
- [ ] Existing colors are now a set of base colors [HIGH]
- [ ] Editable [MEDIUM]
- [ ] Non-removable [HIGH]
- [ ] [Tag Categories](../library/tag_categories.md) [HIGH]
- [ ] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
- [x] [Tag Categories](../library/tag_categories.md) [HIGH]
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title [HIGH]
- [ ] Search engine [HIGH]
- [x] Boolean operators [HIGH]
- [ ] Tag objects + autocomplete [HIGH]
@@ -182,6 +183,7 @@ These version milestones are rough estimations for when the previous core featur
- [ ] Field content search [HIGH]
- [ ] Sortable results [HIGH]
- [ ] Sort by relevance [HIGH]
- [x] Sort by date added [HIGH]
- [ ] Sort by date created [HIGH]
- [ ] Sort by date modified [HIGH]
- [ ] Sort by date taken (photos) [MEDIUM]

View File

@@ -11,9 +11,14 @@ In order to scan for new files or file changes, youll need to manually go to
!!! note
In the future, library refreshing will also be automatically done in the background, or additionally on app startup.
## Adding Metadata to Entries
## Adding Tags to File Entries
Click the "Add Tag" button at the bottom of the preview panel with one or more tags selected. Search for existing inside the new dialog popup or create a new one from. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the first item in the list. Press Enter/Return once more to close the dialog box.
To add a metadata field to a file entry, start by clicking the “Add Field” button under the file preview in the right-hand preview panel. From the dropdown menu, select the type of metadata field youd like to add to the entry.
To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears.
## Adding Metadata Fields to File Entries
To add a metadata field to a file entry, start by clicking the “Add Field” button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field youd like to add to the entry.
## Editing Metadata Fields
@@ -21,10 +26,6 @@ To add a metadata field to a file entry, start by clicking the “Add Field” b
Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup.
### Tag Box
Click the “+” button at the end of the Tags list, and search for tags to add inside the new dialog popup. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the add the first item in the list. Press Enter/Return once more to close the dialog box
!!! warning
Keyboard control and navigation is currently _very_ buggy, but will be improved in future versions.
@@ -51,6 +52,10 @@ Inevitably, some of the files inside your library will be renamed, moved, or del
!!! warning
If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are top priorities for future versions.
## Deleting Tags
To delete a tag from your library, go to File -> Tag Manager, hover over the tag you wish to delete, and click the "-" icon that appears. You will be prompted to make sure you wish to delete this tag from your library and across all file entries.
## Saving the Library
Libraries are saved upon exiting the program. To manually save, select File -> Save Library from the menu bar. To save a backup of your library, select File -> Save Library Backup from the menu bar.

View File

@@ -77,8 +77,8 @@ app = BUNDLE(
exe if coll is None else coll,
name='TagStudio.app',
icon=icon,
bundle_identifier='com.github.tagstudiodev',
version='0.0.0',
bundle_identifier='com.cyanvoxel.tagstudio',
version='9.5.0',
info_plist={
'NSAppleScriptEnabled': False,
'NSPrincipalClass': 'NSApplication',

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 397 KiB

View File

@@ -1,24 +1,42 @@
{
"entries.duplicate.merge.label": "Zusammenführen von doppelten Einträgen",
"app.git": "Git Commit",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Bibliothek '{library_dir}'",
"drop_import.description": "Die folgenden Dateinamen existieren bereits in der Bibliothek",
"drop_import.duplicates_choice.plural": "Die folgenden {count} Dateinamen existieren bereits in der Bibliothek.",
"drop_import.duplicates_choice.singular": "Der folgende Dateiname existiert bereits in der Bibliothek.",
"drop_import.progress.label.initial": "Neue Dateien werden importiert...",
"drop_import.progress.label.plural": "Neue Dateien werden importiert...\n{count} Dateien importiert.{suffix}",
"drop_import.progress.label.singular": "Neue Dateien werden importiert...\n1 Datei importiert.{suffix}",
"drop_import.progress.window_title": "Dateien Importieren",
"edit.tag_manager": "Tags Verwalten",
"entries.duplicate.merge.label": "Führe doppelte Einträge zusammen…",
"entries.duplicate.refresh": "Doppelte Einträge aktualisieren",
"entries.duplicates.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.",
"entries.mirror": "Spiegel",
"entries.mirror.confirmation": "Sind Sie sich sicher, dass Sie die folgenden %{len(self.lib.dupe_files)} Einträge kopieren wollen?",
"entries.mirror.label": "Kopiere 1/%{count} Einträge...",
"entries.mirror": "Kopieren",
"entries.mirror.confirmation": "Sind Sie sich sicher, dass Sie die folgenden {count} Einträge kopieren wollen?",
"entries.mirror.label": "Kopiere {idx}/{total} Einträge...",
"entries.mirror.title": "Einträge werden kopiert",
"entries.tags": "Tags",
"entries.unlinked.delete": "Unverknüpfte Einträge löschen",
"entries.unlinked.delete.confirm": "Sind Sie sicher, dass Sie die folgenden %{len(self.lib.missing_files)}-Einträge löschen wollen?",
"entries.unlinked.delete.deleting": "Einträge am Löschen",
"entries.unlinked.delete.deleting_count": "Löschen von %{x[0]+1}/{len(self.lib.missing_files)} Unverknüpften Einträgen",
"entries.unlinked.delete.confirm": "Sind Sie sicher, dass Sie die folgenden {count} Einträge löschen wollen?",
"entries.unlinked.delete.deleting": "Einträge werden gelöscht",
"entries.unlinked.delete.deleting_count": "Lösche {idx}/{count} unverknüpfte Einträge",
"entries.unlinked.delete_alt": "Unverknüpfte Einträge löschen",
"entries.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.",
"entries.unlinked.missing_count.none": "Unverknüpfte Einträge: -",
"entries.unlinked.missing_count.some": "Unverknüpfte Einträge: {count}",
"entries.unlinked.refresh_all": "Alle aktualisieren",
"entries.unlinked.relink.attempting": "Versuche %{x[0]+1}/%{len(self.lib.missing_files)} Einträge wieder zu verknüpfen - %{self.fixed} bereits erfolgreich wieder verknüpft",
"entries.unlinked.relink.manual": "Manuelle Neuverknüpfung",
"entries.unlinked.relink.attempting": "Versuche {idx}/{missing_count} Einträge wieder zu verknüpfen, {fixed_count} bereits erfolgreich wieder verknüpft",
"entries.unlinked.relink.manual": "Manuell Neuverknüpfen",
"entries.unlinked.relink.title": "Einträge werden neuverknüpft",
"entries.unlinked.scanning": "Bibliothek nach nicht verknüpften Einträgen durchsuchen...",
"entries.unlinked.search_and_relink": "Suche && Neuverbindung",
"entries.unlinked.scanning": "Bibliothek wird nach nicht verknüpften Einträgen durchsucht...",
"entries.unlinked.search_and_relink": "Suchen && Neuverbinden",
"entries.unlinked.title": "Unverknüpfte Einträge reparieren",
"field.copy": "Feld kopieren",
"field.edit": "Feld bearbeiten",
"field.paste": "Feld einfügen",
"file.date_added": "Hinzufügungsdatum",
"file.date_created": "Erstellungsdatum",
"file.date_modified": "Datum geändert",
"file.dimensions": "Abmessungen",
@@ -28,64 +46,153 @@
"file.duplicates.dupeguru.no_file": "Keine DupeGuru-Datei ausgewählt",
"file.duplicates.dupeguru.open_file": "DupeGuru Ergebnisdatei öffnen",
"file.duplicates.fix": "Duplizierte Dateien korrigieren",
"file.duplicates.matches": "Übereinstimmungen mit doppelten Dateien: %{count}",
"file.duplicates.matches": "Übereinstimmungen mit duplizierten Dateien: {count}",
"file.duplicates.matches_uninitialized": "Übereinstimmungen mit doppelten Dateien: N/A",
"file.duplicates.mirror.description": "Kopiert die Eintragsdaten in jeder Duplikatsmenge, wobei alle Daten kombiniert werden ohne Felder zu entfernen oder zu duplizieren. Diese Operation wird keine Dateien oder Daten löschen.",
"file.duplicates.mirror_entries": "Einträge kopieren",
"file.not_found": "Datei nicht gefunden:",
"file.duration": "Länge",
"file.not_found": "Datei nicht gefunden",
"file.open_file": "Datei öffnen",
"file.open_file_with": "Datei öffnen mit",
"file.open_location.generic": "Datei im Explorer öffnen",
"file.open_location.windows": "Im Explorer anzeigen",
"folders_to_tags.close_all": "Alle schließen",
"folders_to_tags.converting": "Wandele Ordner zu Tags um",
"folders_to_tags.description": "Erstellt Tags basierend auf der Verzeichnisstruktur und wendet sie auf die Einträge an.\nDer folgende Verzeichnisbaum zeigt welche Tags erstellt werden würden und auf welche Einträge sie angewendet werden würden.",
"folders_to_tags.open_all": "Alle öffnen",
"folders_to_tags.title": "Aus Verzeichnissen Tags erstellen",
"generic.add": "hinzufügen",
"generic.apply": "anwenden",
"generic.add": "Hinzufügen",
"generic.apply": "Anwenden",
"generic.apply_alt": "Anwenden",
"generic.cancel": "Abbrechen",
"generic.cancel_alt": "Abbrechen",
"generic.close": "Schließen",
"generic.continue": "Fortfahren",
"generic.copy": "Kopieren",
"generic.cut": "Ausschneiden",
"generic.delete": "Löschen",
"generic.delete_alt": "Löschen",
"generic.done": "Fertig",
"generic.done_alt": "Fertig",
"generic.edit": "Bearbeiten",
"generic.edit_alt": "Bearbeiten",
"generic.filename": "Dateiname",
"generic.navigation.back": "Zurück",
"generic.navigation.next": "Weiter",
"generic.overwrite": "Überschreibem",
"generic.overwrite_alt": "Überschreiben",
"generic.paste": "Einfügen",
"generic.recent_libraries": "Aktuelle Bibliotheken",
"generic.rename": "Umbenennen",
"generic.rename_alt": "Umbenennen",
"generic.save": "Speichern",
"generic.skip": "Überspringen",
"generic.skip_alt": "Überspringen",
"help.visit_github": "GitHub Repository besuchen",
"home.search": "Suchen",
"home.search_entries": "Nach Einträgen suchen",
"home.search_library": "Bibliothek durchsuchen",
"home.search_tags": "Tags suchen",
"home.thumbnail_size": "Grösse des Vorschaubildes",
"home.thumbnail_size": "Größe des Vorschaubildes",
"home.thumbnail_size.extra_large": "Extra Große Vorschau",
"home.thumbnail_size.large": "Große Vorschau",
"home.thumbnail_size.medium": "Mittelgroße Vorschau",
"home.thumbnail_size.mini": "Mini Vorschau",
"home.thumbnail_size.small": "Kleine Vorschau",
"ignore_list.add_extension": "Erweiterung hinzufügen",
"ignore_list.mode.exclude": "ausschliessen",
"ignore_list.mode.include": "Inklusive",
"ignore_list.mode.label": "Listen Modus:",
"ignore_list.title": "Dateierweiterungen",
"json_migration.checking_for_parity": "Parität wird überprüft...",
"json_migration.creating_database_tables": "SQL Datenbank Tabellen werden erstellt...",
"json_migration.discrepancies_found": "Bibliotheksdiskrepanzen wurden gefunden",
"json_migration.finish_migration": "Migration abschließen",
"json_migration.heading.aliases": "Aliase:",
"json_migration.heading.colors": "Farben:",
"json_migration.heading.differ": "Diskrepanz",
"json_migration.heading.entires": "Einträge:",
"json_migration.heading.fields": "Felder:",
"json_migration.heading.parent_tags": "Übergeordnete Tags:",
"json_migration.heading.paths": "Pfade:",
"json_migration.heading.shorthands": "Kurzformen:",
"json_migration.heading.tags": "Tags:",
"json_migration.migrating_files_entries": "Migriere {entries:,d} Dateieinträge...",
"json_migration.migration_complete": "Migration abgeschlossen!",
"json_migration.migration_complete_with_discrepancies": "Migration abgeschlossen mit Diskrepanzen",
"json_migration.title": "Speicherformatmigration: \"{path}\"",
"json_migration.title.new_lib": "<h2>v9.5+ Bibliothek</h2>",
"json_migration.title.old_lib": "<h2>v9.4 Bibliothek</h2>",
"landing.open_create_library": "Bibliothek öffnen/erstellen {shortcut}",
"library.field.add": "Feld hinzufügen",
"library.field.confirm_remove": "Wollen Sie dieses \"%{self.lib.get_field_attr(field, \"name\")}\" Feld wirklich entfernen?",
"library.field.confirm_remove": "Wollen Sie dieses \"{name}\" Feld wirklich entfernen?",
"library.field.mixed_data": "Gemischte Daten",
"library.field.remove": "Feld entfernen",
"library.missing": "Dateiort fehlt",
"library.name": "Bibliothek",
"library.refresh.scanning.plural": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Dateien durchsucht, {found_count} neue Dateien gefunden",
"library.refresh.scanning.singular": "Durchsuche Verzeichnisse nach neuen Dateien...\n{searched_count} Datei durchsucht, {found_count} neue Datei gefunden",
"library.refresh.scanning_preparing": "Überprüfe Verzeichnisse auf neue Dateien...\nBereite vor...",
"library.refresh.title": "Verzeichnisse werden aktualisiert",
"library.scan_library.title": "Bibliothek wird scannen",
"macros.running.dialog.new_entries": "Führe konfigurierte Makros für %{x + 1}/%{len(new_ids)} neue Einträge aus",
"macros.running.dialog.new_entries": "Führe konfigurierte Makros für {count}/{total} neue Einträge aus",
"macros.running.dialog.title": "Ausführen von Makros bei neuen Einträgen",
"media_player.autoplay": "Autoplay",
"menu.edit": "Bearbeiten",
"menu.edit.ignore_list": "Dateien und Verzeichnisse ignorieren",
"menu.edit.manage_tags": "Tags Verwalten",
"menu.edit.new_tag": "Neuer Tag",
"menu.file": "Datei",
"menu.file.close_library": "Bibliothek schließen",
"menu.file.new_library": "Neue Bibliothek",
"menu.file.open_create_library": "Bibliothek öffnen/erstellen",
"menu.file.open_library": "Bibliothek öffnen",
"menu.file.refresh_directories": "Verzeichnisse aktualisieren",
"menu.file.save_backup": "Bibliotheksbackup speichern",
"menu.file.save_library": "Bibliothek speichern",
"menu.help": "Hilfe",
"menu.macros": "Makros",
"menu.macros.folders_to_tags": "Verzeichnisse zu Tags",
"menu.select": "Auswählen",
"menu.tools": "Werkzeuge",
"menu.view": "Ansicht",
"menu.window": "Fenster",
"preview.no_selection": "Keine Elemente ausgewählt",
"status.library_backup_success": "Bibliotheks-Backup gespeichert unter:",
"select.all": "Alle auswählen",
"select.clear": "Auswahl leeren",
"settings.open_library_on_start": "Bibliothek zum Start öffnen",
"splash.opening_library": "Öffne Bibliothek \"{library_path}\"...",
"status.library_backup_in_progress": "Bibliotheksbackup wird gespeichert...",
"status.library_backup_success": "Bibliotheks-Backup gespeichert unter: \"{path}\" ({time_span})",
"status.library_closed": "Bibliothek geschlossen ({time_span})",
"status.library_closing": "Bibliothek wird geschlossen...",
"status.library_save_success": "Bibliothek gespeichert und geschlossen!",
"status.library_search_query": "Suche in der Bibliothek nach",
"status.library_search_query": "Durchsuche die Bibliothek...",
"status.results": "Ergebnisse",
"tag.add": "Hinzufüge Tag",
"status.results_found": "{count} Ergebnisse gefunden ({time_span})",
"tag.add": "Tag hinzufügen",
"tag.add.plural": "Tags hinzufügen",
"tag.add_to_search": "Zur Suche hinzufügen",
"tag.aliases": "Aliase",
"tag.color": "Farbe",
"tag.confirm_delete": "Sind Sie sich sicher, dass Sie den Tag \"{tag_name}\" löschen wollen?",
"tag.create": "Tag erstellen",
"tag.edit": "Tag bearbeiten",
"tag.name": "Name",
"tag.new": "Neuer Tag",
"tag.parent_tags": "Übergeordnete Tags",
"tag.parent_tags.add": "Übergeordnete Tags hinzufügen",
"tag.remove": "Tag entfernen",
"tag.search_for_tag": "Nach Tag suchen",
"tag.shorthand": "Kürzel",
"tag_manager.title": "Bibliothek Tags"
"tag.tag_name_required": "Tag Name (Pflichtfeld)",
"tag_manager.title": "Bibliothek Tags",
"view.size.0": "Mini",
"view.size.1": "Klein",
"view.size.2": "Mittel",
"view.size.3": "Groß",
"view.size.4": "Extra Groß",
"window.message.error_opening_library": "Fehler beim Öffnen der Bibliothek.",
"window.title.error": "Fehler",
"window.title.open_create_library": "Bibliothek öffnen/erstellen"
}

View File

@@ -158,6 +158,8 @@
"menu.file.close_library": "&Close Library",
"menu.file.new_library": "New Library",
"menu.file.open_create_library": "&Open/Create Library",
"menu.file.open_recent_library": "Open Recent",
"menu.file.clear_recent_libraries": "Clear Recent",
"menu.file.open_library": "Open Library",
"menu.file.refresh_directories": "&Refresh Directories",
"menu.file.save_backup": "&Save Library Backup",
@@ -212,5 +214,7 @@
"view.size.4": "Extra Large",
"window.message.error_opening_library": "Error opening library.",
"window.title.error": "Error",
"window.title.open_create_library": "Open/Create Library"
"window.title.open_create_library": "Open/Create Library",
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending"
}

View File

@@ -1,60 +1,116 @@
{
"entries.duplicate.merge.label": "Fusionando entradas duplicadas",
"app.git": "Git Commit",
"app.pre_release": "Previas al lanzamiento",
"app.title": "{base_title} - Biblioteca '{library_dir}'",
"drop_import.description": "Los siguientes archivos tienen nombres de archivo que ya existen en la biblioteca",
"drop_import.duplicates_choice.plural": "Los siguientes {count} archivos tienen nombres de archivo que ya existen en la biblioteca.",
"drop_import.duplicates_choice.singular": "El siguiente archivo tiene un nombre de archivo que ya existe en la biblioteca.",
"drop_import.progress.label.initial": "Importando archivos nuevos...",
"drop_import.progress.label.plural": "Importando archivos nuevos...\n{count} Archivos importado.{suffix}",
"drop_import.progress.label.singular": "Importando archivos nuevos...\n1 Archivo importado.{suffix}",
"drop_import.progress.window_title": "Importar archivos",
"drop_import.title": "Conflictos de archivos",
"edit.tag_manager": "Administrar etiquetas",
"entries.duplicate.merge": "Fusionar entradas duplicadas",
"entries.duplicate.merge.label": "Fusionando entradas duplicadas...",
"entries.duplicate.refresh": "Recargar entradas duplicadas",
"entries.duplicates.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.",
"entries.mirror": "Espejo",
"entries.mirror.confirmation": "¿Estás seguro de que quieres reflejar las siguientes %{len(self.lib.dupe_files)} entradas?",
"entries.mirror.label": "Reflejando 1/%{count} Entradas...",
"entries.mirror.title": "Entradas reflejadas",
"entries.mirror": "&Reflejar",
"entries.mirror.confirmation": "¿Estás seguro de que quieres reflejar las siguientes {count} entradas?",
"entries.mirror.label": "Reflejando {idx}/{total} Entradas...",
"entries.mirror.title": "Reflejando entradas",
"entries.mirror.window_title": "Reflejar entradas",
"entries.tags": "Etiquetas",
"entries.unlinked.delete": "Eliminar entradas no vinculadas",
"entries.unlinked.delete.confirm": "¿Está seguro de que desea eliminar las siguientes %{len(self.lib.missing_files)} entradas?",
"entries.unlinked.delete.confirm": "¿Está seguro de que desea eliminar las siguientes {count} entradas?",
"entries.unlinked.delete.deleting": "Eliminando entradas",
"entries.unlinked.delete.deleting_count": "Eliminando %{x[0]+1}/{len(self.lib.missing_files)} entradas no vinculadas",
"entries.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.",
"entries.unlinked.refresh_all": "Recargar todo",
"entries.unlinked.relink.attempting": "Intentando volver a vincular %{x[0]+1}/%{len(self.lib.missing_files)} Entradas, %{self.fixed} Reenlazado correctamente",
"entries.unlinked.relink.manual": "Reenlace manual",
"entries.unlinked.delete.deleting_count": "Eliminando {idx}/{count} entradas no vinculadas",
"entries.unlinked.delete_alt": "Eliminar entradas no vinculadas",
"entries.unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de tus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado. <br><br>Las entradas no vinculadas se pueden volver a vincular automáticamente mediante una búsqueda en tus directorios, el usuario puede eliminarlas si así lo desea.",
"entries.unlinked.missing_count.none": "Entradas no vinculadas: N/A",
"entries.unlinked.missing_count.some": "Entradas no vinculadas: {count}",
"entries.unlinked.refresh_all": "&Recargar todo",
"entries.unlinked.relink.attempting": "Intentando volver a vincular {idx}/{missing_count} Entradas, {fixed_count} Reenlazado correctamente",
"entries.unlinked.relink.manual": "&Reenlace manual",
"entries.unlinked.relink.title": "Volver a vincular las entradas",
"entries.unlinked.scanning": "Buscando entradas no enlazadas en la biblioteca...",
"entries.unlinked.search_and_relink": "Buscar y volver a vincular",
"entries.unlinked.search_and_relink": "&Buscar && volver a vincular",
"entries.unlinked.title": "Corregir entradas no vinculadas",
"field.copy": "Copiar campo",
"field.edit": "Editar campo",
"field.paste": "Pegar campo",
"file.date_added": "Fecha de adición",
"file.date_created": "Fecha de creación",
"file.date_modified": "Fecha de modificación",
"file.dimensions": "Dimensiones",
"file.duplicates.description": "TagStudio es compatible con Importación de resultados de DupeGuru para administrar archivos duplicados.",
"file.duplicates.dupeguru.advice": "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.",
"file.duplicates.dupeguru.file_extension": "Archivos DupeGuru (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "Cargar archivo DupeGuru",
"file.duplicates.dupeguru.load_file": "&Cargar archivo DupeGuru",
"file.duplicates.dupeguru.no_file": "No se ha seleccionado ningún archivo DupeGuru",
"file.duplicates.dupeguru.open_file": "Abrir el archivo de resultados de DupeGuru",
"file.duplicates.fix": "Reparar archivos duplicados",
"file.duplicates.matches": "Coincidencias de archivos duplicados: %{count}",
"file.duplicates.matches": "Coincidencias de archivos duplicados: {count}",
"file.duplicates.matches_uninitialized": "Coincidencias de archivos duplicados: N/D",
"file.duplicates.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.",
"file.duplicates.mirror_entries": "Reflejar entradas",
"file.not_found": "Archivo no encontrado:",
"file.duplicates.mirror_entries": "&Reflejar entradas",
"file.duration": "Longitud",
"file.not_found": "Archivo no encontrado",
"file.open_file": "Abrir archivo",
"file.open_location.generic": "Abrir archivo en el explorador",
"file.open_file_with": "Abrir el archivo con",
"file.open_location.generic": "Abrir archivo en el Explorador",
"file.open_location.mac": "Abrir en el Finder",
"file.open_location.windows": "Abrir en el Explorador",
"folders_to_tags.close_all": "Cerrar todo",
"folders_to_tags.converting": "Convertir carpetas en etiquetas",
"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.description": "Crea etiquetas basadas en su estructura de carpetas y las aplica a sus entradas.\nLa siguiente estructura muestra todas las etiquetas que se crearán y a qué entradas se aplicarán.",
"folders_to_tags.open_all": "Abrir todo",
"folders_to_tags.title": "Crear etiquetas a partir de carpetas",
"generic.add": "Añadir",
"generic.apply": "Aplicar",
"generic.apply_alt": "&Aplicar",
"generic.cancel": "Cancelar",
"generic.delete": "Borrar",
"generic.done": "Donar",
"generic.cancel_alt": "&Cancelar",
"generic.close": "Cerrar",
"generic.continue": "Continuar",
"generic.copy": "Copiar",
"generic.cut": "Cortar",
"generic.delete": "Eliminar",
"generic.delete_alt": "&Eliminar",
"generic.done": "Terminado",
"generic.done_alt": "&Terminado",
"generic.edit": "Editar",
"generic.edit_alt": "&Editar",
"generic.filename": "Nombre de archivo",
"generic.navigation.back": "Volver",
"generic.navigation.next": "Continuar",
"generic.overwrite": "Sobrescribir",
"generic.overwrite_alt": "&Sobrescribir",
"generic.paste": "Pegar",
"generic.recent_libraries": "Bibliotecas recientes",
"generic.rename": "Renombrar",
"generic.rename_alt": "&Renombrar",
"generic.save": "Guardar",
"generic.skip": "Saltear",
"generic.skip_alt": "&Saltear",
"help.visit_github": "Visitar el repositorio en GitHub",
"home.search": "Buscar",
"home.search_entries": "Buscar entradas",
"home.search_library": "Buscar el biblioteca",
"home.search_tags": "Buscar etiquetas",
"home.thumbnail_size": "Tamaño de la miniatura",
"ignore_list.add_extension": "Añadir extensión",
"home.thumbnail_size": "Tamaño de las imágenes",
"home.thumbnail_size.extra_large": "Imágenes extra grandes",
"home.thumbnail_size.large": "Imágenes grandes",
"home.thumbnail_size.medium": "Imágenes medianas",
"home.thumbnail_size.mini": "Imágenes en miniatura",
"home.thumbnail_size.small": "Imágenes pequeñas",
"ignore_list.add_extension": "&Añadir extensión",
"ignore_list.mode.exclude": "Excluir",
"ignore_list.mode.include": "Incluir",
"ignore_list.mode.label": "Modo de lista:",
"ignore_list.title": "Extensiones del archivo",
"json_migration.checking_for_parity": "Revisando paridad...",
"json_migration.creating_database_tables": "Creando tablas en la base de datos de SQL...",
"library.field.add": "Añadir campo",
"library.field.confirm_remove": "¿Está seguro de que desea eliminar este campo \"%{self.lib.get_field_attr(field, \"name\")}\"?",
"library.field.mixed_data": "Datos variados",

View File

@@ -1,45 +1,59 @@
{
"app.git": "Git-véglegesítés",
"app.pre_release": "Kísérleti verzió",
"app.title": "{base_title} Könyvtár: „{library_dir}”",
"drop_import.description": "Az alábbi fájlnevek már foglaltak a könyvtárban",
"drop_import.duplicates_choice.plural": "Az alábbi {count} fájl olyan névvel rendelkezik, amely már szerepel a könyvtárban.",
"drop_import.duplicates_choice.singular": "Az alábbi fájl olyan névvel rendelkezik, amely már szerepel a könyvtárban.",
"drop_import.progress.label.initial": "Új fájlok importálása folyamatban…",
"drop_import.progress.label.plural": "Új fájlok importálása folyamatban…\n{count} fájl importálva.{suffix}",
"drop_import.progress.label.singular": "Új fájlok importálása folyamatban…\n1 fájl importálva.{suffix}",
"drop_import.progress.window_title": "Fájlok importálása",
"drop_import.title": "Fájlütközés",
"edit.tag_manager": "Címkék kezelése",
"entries.duplicate.merge": "Egyező elemek egyesítése",
"entries.duplicate.merge.label": "Egyező elemek egyesítése",
"entries.duplicate.refresh": "Egyező elemek frissítése",
"entries.duplicate.merge": "Egyező elemek &egyesítése",
"entries.duplicate.merge.label": "Egyező elemek egyesítése folyamatban…",
"entries.duplicate.refresh": "Egyező elemek &frissítése",
"entries.duplicates.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.",
"entries.mirror": "Tükrözés",
"entries.mirror.confirmation": "Biztosan tükrözni akarja az alábbi adatokat %{len(self.lib.dupe_files)} különböző elemre?",
"entries.mirror.label": "%{count}/1 elem tükrözése folyamatban…",
"entries.mirror": "&Tükrözés",
"entries.mirror.confirmation": "Biztosan tükrözni akarja az alábbi adatokat {count} különböző elemre?",
"entries.mirror.label": "{total}/{idx} elem tükrözése folyamatban…",
"entries.mirror.title": "Elemek tükrözése",
"entries.mirror.window_title": "Elemek tükrözése",
"entries.tags": "Címkék",
"entries.unlinked.delete": "Kapcsolat nélküli elemek törlése",
"entries.unlinked.delete.confirm": "Biztosan törölni akarja az alábbi %{len(self.lib.missing_files)} elemet?",
"entries.unlinked.delete.confirm": "Biztosan törölni akarja az alábbi {count} elemet?",
"entries.unlinked.delete.deleting": "Elemek törlése",
"entries.unlinked.delete.deleting_count": "{len(self.lib.missing_files)}/%{x[0]+1}. kapcsolat nélküli elem törlése",
"entries.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.",
"entries.unlinked.refresh_all": "Összes frissítése",
"entries.unlinked.relink.attempting": "%{len(self.lib.missing_files)}/%{x[0]+1} elem újra összekapcsolásának megkísérlése; %{self.fixed} elem sikeresen újra összekapcsolva",
"entries.unlinked.relink.manual": "Újra összekapcsolás kézileg",
"entries.unlinked.delete.deleting_count": "{count}/{idx}. kapcsolat nélküli elem törlése folyamatban…",
"entries.unlinked.delete_alt": "Kapcsolat &nélküli elemek törlése",
"entries.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.<br><br>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.",
"entries.unlinked.missing_count.none": "Kapcsolat nélküli elemek: 0",
"entries.unlinked.missing_count.some": "Kapcsolat nélküli elemek: {count}",
"entries.unlinked.refresh_all": "&Az összes frissítése",
"entries.unlinked.relink.attempting": "{missing_count}/{idx} elem újra összekapcsolásának megkísérlése; {fixed_count} elem sikeresen újra összekapcsolva",
"entries.unlinked.relink.manual": "Új&ra összekapcsolás kézileg",
"entries.unlinked.relink.title": "Elemek újra összekapcsolása",
"entries.unlinked.scanning": "Kapcsolat nélküli elemek keresése a könyvtárban…",
"entries.unlinked.search_and_relink": "Keresés és újra összekapcsolás",
"entries.unlinked.search_and_relink": "&Keresés és újra összekapcsolás",
"entries.unlinked.title": "Kapcsolat nélküli elemek javítása",
"field.copy": "Mező másolása",
"field.copy": "Mező &másolása",
"field.edit": "Mező szerkesztése",
"field.paste": "Mező beillesztése",
"field.paste": "Mező &beillesztése",
"file.date_added": "Adatbázisba felvétel dátuma",
"file.date_created": "Létrehozás dátuma",
"file.date_modified": "Módosítás dátuma",
"file.dimensions": "Méret",
"file.duplicates.description": "A TagStudio támogatja az egyező fájlok azonosítását importált a DupeGuru segítségével.",
"file.duplicates.dupeguru.advice": "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.",
"file.duplicates.dupeguru.file_extension": "DupeGuru-fájlok (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "DupeGuru fájl betöltése",
"file.duplicates.dupeguru.no_file": "Nincs kiválasztott DupeGuru-fájl.",
"file.duplicates.dupeguru.open_file": "DupeGuru-fájl megnyitása",
"file.duplicates.dupeguru.file_extension": "DupeGuru fájlok (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "&DupeGuru fájl betöltése",
"file.duplicates.dupeguru.no_file": "Nincs kiválasztott DupeGuru fájl.",
"file.duplicates.dupeguru.open_file": "DupeGuru fájl megnyitása",
"file.duplicates.fix": "Egyező fájlok egyesítése",
"file.duplicates.matches": "%{count} egyező fájl",
"file.duplicates.matches": "{count} egyező fájl",
"file.duplicates.matches_uninitialized": "Nincsenek egyező fájlok",
"file.duplicates.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.",
"file.duplicates.mirror_entries": "Elemek tükrözése",
"file.duplicates.mirror_entries": "&Elemek tükrözése",
"file.duration": "Hossz",
"file.not_found": "Az alábbi fájl nem található:",
"file.open_file": "Fájl megnyitása",
@@ -47,85 +61,156 @@
"file.open_location.generic": "Fájl megnyitása Intézőben",
"file.open_location.mac": "Megnyitás Finderben",
"file.open_location.windows": "Megnyitás Intézőben",
"folders_to_tags.close_all": "Összes bezárása",
"folders_to_tags.close_all": "Az összes &összecsukása",
"folders_to_tags.converting": "Mappák címkékké alakítása",
"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.open_all": "Összes megnyitása",
"folders_to_tags.open_all": "Az összes &kibontása",
"folders_to_tags.title": "Címkék létrehozása mappák alapján",
"generic.add": "Hozzáadás",
"generic.apply": "Alkalmaz",
"generic.apply_alt": "&Alkalmaz",
"generic.cancel": "Mégse",
"generic.cancel_alt": "Mégse",
"generic.close": "Bezárás",
"generic.continue": "Folytatás",
"generic.copy": "Másolás",
"generic.cut": "Kivágás",
"generic.delete": "Törlés",
"generic.delete_alt": "&Törlés",
"generic.done": "Kész",
"generic.done_alt": "Kész",
"generic.edit": "Szerkesztés",
"generic.edit_alt": "S&zerkesztés",
"generic.filename": "Fájlnév",
"generic.navigation.back": "Vissza",
"generic.navigation.next": "Tovább",
"generic.overwrite": "Felülírás",
"generic.overwrite_alt": "&Felülírás",
"generic.paste": "Beillesztés",
"generic.recent_libraries": "Legutóbbi könytárak",
"help.visit_github": "GitHub-adattár megnyitása",
"generic.recent_libraries": "Legutóbbi könyvtárak",
"generic.rename": "Átnevezés",
"generic.rename_alt": "&Átnevezés",
"generic.save": "Mentés",
"generic.skip": "Kihagyás",
"generic.skip_alt": "&Kihagyás",
"help.visit_github": "&GitHub-adattár megnyitása",
"home.search": "Keresés",
"home.search_entries": "Tételek keresése",
"home.search_library": "Keresés a könyvtárban",
"home.search_tags": "Címkék keresése",
"home.thumbnail_size": "Indexkép mérete",
"ignore_list.add_extension": "Kiterjesztés hozzáadása",
"home.thumbnail_size.extra_large": "Extra nagy miniatűrök",
"home.thumbnail_size.large": "Nagy miniatűrök",
"home.thumbnail_size.medium": "Közepes miniatűrök",
"home.thumbnail_size.mini": "Pici miniatűrök",
"home.thumbnail_size.small": "Kicsi miniatűrök",
"ignore_list.add_extension": "&Kiterjesztés hozzáadása",
"ignore_list.mode.exclude": "Elrejtés",
"ignore_list.mode.include": "Mutatás",
"ignore_list.mode.label": "Listázott elemek módja:",
"ignore_list.title": "Kiterjesztések",
"json_migration.checking_for_parity": "Paritás ellenőrzése folyamatban…",
"json_migration.creating_database_tables": "SQL-adatbázis táblázatainak létrehozása folyamatban…",
"json_migration.description": "<br>A könyvtárátalakítási folyamat megkezdése és az eredmény előnézete. Az új könyvtár az „Átalakítás befejezése” gomb megnyomásáig <i>nem</i> lesz használatba véve.<br><br>A könyvtár adatai változatlanok maradnak vagy egy „Egységesítve” címkével lesznek felruházva. A nem egyező adatok vörösen lesznek megjelenítve és egy „<b>(!)</b>” szimbólummal lesznek ellátva.<br><center></i>Ez a folyamat nagyobb könyvtárak esetén akár több percig is eltarthat.</i><center>",
"json_migration.discrepancies_found": "Eltérő könyvtáradatok",
"json_migration.discrepancies_found.description": "Eltéréseket észleltünk az eredeti és az átalakított könyvtár adataiban. Döntse el, hogy szeretné-e folytatni a folyamatot!",
"json_migration.finish_migration": "Átalakítás befejezése",
"json_migration.heading.aliases": "Áljelek:",
"json_migration.heading.colors": "Színek:",
"json_migration.heading.differ": "Eltérés",
"json_migration.heading.entires": "Elemek:",
"json_migration.heading.extension_list_type": "Kiterjesztési lista típusa:",
"json_migration.heading.fields": "Mezők:",
"json_migration.heading.file_extension_list": "Fájlkiterjesztési lista:",
"json_migration.heading.match": "Egységesítve",
"json_migration.heading.parent_tags": "Szülőcímkék:",
"json_migration.heading.paths": "Elérési utak:",
"json_migration.heading.shorthands": "Rövidítések:",
"json_migration.heading.tags": "Címkék:",
"json_migration.info.description": "A TagStudio <b>9.4 és korábbi</b> verzióival készült könyvtárakat át kell alakítani a program <b>9.5</b> és afölötti verzióval kompatibilis formátummá.<br><h2>Tudnivalók:</h2><ul><li>Az Ön létező könyvtára <b><i>NEM</i></b> lesz törölve.</li><li>Az Ön személyes fájljait <b><i>NEM</i></b> fogjuk törölni, áthelyezni vagy módosítani.</li><li>Az új formátumot a TagStudio korábbi verzióiban nem lehet megnyitni.</li></ul>",
"json_migration.migrating_files_entries": "{entries:,d} elem átalakítása folyamatban…",
"json_migration.migration_complete": "Az átalakítási folyamat sikeresen befejeződött!",
"json_migration.migration_complete_with_discrepancies": "Az átalakítási folyamat befejeződött; eltéréseket találtunk",
"json_migration.start_and_preview": "Folyamat indítása és előnézet",
"json_migration.title": "Formátumátalakítás: „{path}”",
"json_migration.title.new_lib": "<h2>9.5 és afölötti könyvtár</h2>",
"json_migration.title.old_lib": "<h2>9.4-es könyvtár</h2>",
"landing.open_create_library": "Könyvtár megnyitása/létrehozása {shortcut}",
"library.field.add": "Új mező",
"library.field.confirm_remove": "Biztosan el akarja távolítani a(z) „%{self.lib.get_field_attr(field, \"name\")}”-mezőt?",
"library.field.confirm_remove": "Biztosan el akarja távolítani a(z) „{name}”-mezőt?",
"library.field.mixed_data": "Kevert adatok",
"library.field.remove": "Mező eltávolítása",
"library.missing": "Hiányzó hely",
"library.name": "Könyvtár",
"library.refresh.scanning.plural": "Új fájlok keresése a mappákban…\n{searched_count} fájl megvizsgálva; ebből {found_count} új fájl",
"library.refresh.scanning.singular": "Új fájlok keresése a mappákban…\n{searched_count} fájl megvizsgálva; ebből {found_count} új fájl",
"library.refresh.scanning_preparing": "Új fájlok keresése a mappákban…\nElőkészítés…",
"library.refresh.title": "Mappák frissítése",
"library.scan_library.title": "Könyvtár vizsgálata",
"macros.running.dialog.new_entries": "Korábban beállított makrók futtatása %{len(new_ids)}/%{x + 1} új elemen",
"macros.running.dialog.new_entries": "Korábban beállított makrók futtatása {total}/{count} új elemen",
"macros.running.dialog.title": "Makrók futtatása az új elemeken",
"menu.edit": "Szerkesztés",
"media_player.autoplay": "Automatikus lejátszás",
"menu.edit": "S&zerkesztés",
"menu.edit.ignore_list": "Fájlok és mappák figyelmen kívül hagyása",
"menu.file": "Fájl",
"menu.edit.manage_file_extensions": "&Fájlkiterjesztések kezelése",
"menu.edit.manage_tags": "Címkék ke&zelése",
"menu.edit.new_tag": "Új &címke",
"menu.file": "&Fájl",
"menu.file.close_library": "Könyvtár &bezárása",
"menu.file.new_library": "Új könyvtár",
"menu.file.open_create_library": "Könyvtár megnyitása/létrehozása",
"menu.file.open_create_library": "Könyvtár meg&nyitása/létrehozása",
"menu.file.open_library": "Könyvtár megnyitása",
"menu.file.save_library": "Könyvtár mentése",
"menu.help": "Súgó",
"menu.macros": "Makrók",
"menu.file.refresh_directories": "Mappák &frissítése",
"menu.file.save_backup": "Biztonsági m&entés létrehozása",
"menu.file.save_library": "Könyvtár &mentése",
"menu.help": "&Súgó",
"menu.macros": "&Makrók",
"menu.macros.folders_to_tags": "Mappák &címkékké alakítása",
"menu.select": "Kijelölés",
"menu.tools": "Eszközök",
"menu.view": "Nézet",
"menu.window": "Ablak",
"menu.tools": "&Eszközök",
"menu.tools.fix_duplicate_files": "&Egyező fájlok egyesítése",
"menu.tools.fix_unlinked_entries": "Kapcsolat &nélküli elemek javítása",
"menu.view": "&Nézet",
"menu.window": "&Ablak",
"preview.no_selection": "Nincs kijelölt elem",
"select.all": "Összes kijelölése",
"select.clear": "Kijelölés megszüntetése",
"select.all": "&Az összes kijelölése",
"select.clear": "&Kijelölés megszüntetése",
"settings.open_library_on_start": "Könyvtár megnyitása a program indulásakor",
"settings.show_filenames_in_grid": "Fájlnevek megjelenítése rácsnézetben",
"settings.show_recent_libraries": "Legutóbbi könyvtárak megjelenítése",
"splash.opening_library": "Könyvtár megnyitása folyamatban…",
"status.library_backup_success": "A biztonsági mentés létrehozása megtörtént az alábbi elérési úton:",
"settings.show_recent_libraries": "&Legutóbbi könyvtárak megjelenítése",
"splash.opening_library": "Könyvtár megnyitása folyamatban: „{library_path}”…",
"status.library_backup_in_progress": "Könyvtár biztonsági mentése folyamatban…",
"status.library_backup_success": "A biztonsági mentés létrehozása megtörtént az alábbi elérési úton: „{path}” ({time_span})",
"status.library_closed": "Könyvtár bezárása ({time_span}) sikeresen megtörtént.",
"status.library_closing": "Könyvtár bezárása folyamatban…",
"status.library_save_success": "A könyvtár mentése és bezárása sikeresen megtörtént.",
"status.library_search_query": "Az alábbi kifejezés keresése a könyvtárban:",
"status.library_search_query": "Keresés folyamatban",
"status.results": "találat",
"status.results_found": "{results.total_count} találat",
"status.results_found": "{count} találat ({time_span})",
"tag.add": "Címke hozzáadása",
"tag.add.plural": "Címkék hozzáadása",
"tag.add_to_search": "Keresési kifejezés kiegészítése",
"tag.aliases": "Áljelek",
"tag.color": "Szín",
"tag.confirm_delete": "Biztosan törölni akarja a(z) „{tag_name}” címkét?",
"tag.create": "Címke létrehozása",
"tag.edit": "Címke szerkesztése",
"tag.name": "Név",
"tag.new": "Új címke",
"tag.parent_tags": "Szülőcímkék",
"tag.parent_tags.add": "Új szülőcímke",
"tag.parent_tags.description": "Ez a címke képes helyettesíteni bármely alábbi szülőcímkét kereséskor.",
"tag.remove": "Címke eltávolítása",
"tag.search_for_tag": "Címke keresése",
"tag.shorthand": "Rövidítés",
"tag.tag_name_required": "Címkenév (Kötelező)",
"tag_manager.title": "Könyvtárcímkék",
"view.size.0": "Apró",
"view.size.1": "Kicsi",
"view.size.2": "Közepes",
"view.size.3": "Nagy",
"view.size.4": "Extra nagy"
"view.size.4": "Extra nagy",
"window.message.error_opening_library": "Hiba történt a könyvtár megnyitása közben.",
"window.title.error": "Hiba",
"window.title.open_create_library": "Könyvtár megnyitása/létrehozása"
}

View File

@@ -1,26 +1,39 @@
{
"app.git": "Git提交更新",
"app.pre_release": "預先發行",
"app.title": "{base_title} - 文庫 '{library_dir}'",
"drop_import.description": "以下檔案的檔名已經存在於文庫中",
"drop_import.duplicates_choice.plural": "以下 {count} 個檔案的檔名已經存在於文庫中。",
"drop_import.duplicates_choice.singular": "此檔案的檔名已經存在於文庫中。",
"drop_import.progress.label.initial": "新的檔案匯入中...",
"drop_import.progress.label.plural": "匯入新檔案...\n已匯入 {count} 個檔案.{suffix}",
"drop_import.progress.label.singular": "匯入新檔案...\n已匯入一個檔案.{suffix}",
"drop_import.progress.window_title": "匯入檔案",
"drop_import.title": "檔案衝突",
"edit.tag_manager": "標籤管理",
"entries.duplicate.merge": "合併重複項目",
"entries.duplicate.merge.label": "正在合併重複項目",
"entries.duplicate.merge.label": "正在合併重複項目...",
"entries.duplicate.refresh": "重新整理重複項目",
"entries.duplicates.description": "重複的項目指的是多個項目皆指向同一個檔案。合併這些重複項目會合併所有項目的標籤和後設資料。重複檔案不一樣,重複檔案在Tag Studio外重複檔案。",
"entries.mirror": "鏡像",
"entries.mirror.confirmation": "您確定要鏡像以下 %{len(self.lib.dupe_files)} 項目?",
"entries.mirror.label": "正在鏡像 1/%{count} 項目...",
"entries.mirror.title": "正在鏡像項目",
"entries.duplicates.description": "重複的項目指的是多個項目皆指向同一個檔案。合併這些重複項目會合併所有項目的標籤和後設資料。重複檔案和重複項目不同,重複檔案在Tag Studio外重複檔案。",
"entries.mirror": "&鏡像",
"entries.mirror.confirmation": "您確定要鏡像以下 {count} 項目?",
"entries.mirror.label": "正在鏡像 {idx}/{total} 項目...",
"entries.mirror.title": "進行項目鏡像",
"entries.mirror.window_title": "項目鏡像",
"entries.tags": "標籤",
"entries.unlinked.delete": "刪除未連結的項目",
"entries.unlinked.delete.confirm": "您確定要刪除這些 %{len(self.lib.missing_files)} 項目?",
"entries.unlinked.delete.confirm": "您確定要刪除 {count} 項目?",
"entries.unlinked.delete.deleting": "正在刪除項目",
"entries.unlinked.delete.deleting_count": "正在刪除 %{x[0]+1}/{len(self.lib.missing_files)} 未連結的項目",
"entries.unlinked.refresh_all": "重新整理",
"entries.unlinked.relink.attempting": "正在重新連結 %{x[0]+1}/%{len(self.lib.missing_files)} 項目, %{self.fixed} 重新連結成功",
"entries.unlinked.relink.manual": "手動重新連結",
"entries.unlinked.delete.deleting_count": "正在刪除 {idx}/{count} 未連結的項目",
"entries.unlinked.delete_alt": "刪除並解除項目連結",
"entries.unlinked.missing_count.none": "未連結項目:無",
"entries.unlinked.missing_count.some": "未連結項目:{count}",
"entries.unlinked.refresh_all": "&重新整理",
"entries.unlinked.relink.attempting": "正在重新連結{idx}/{missing_files)count}個項目, 成功重新連結{fixed_count}",
"entries.unlinked.relink.manual": "&手動重新連結",
"entries.unlinked.relink.title": "正在重新連結項目",
"entries.unlinked.scanning": "正在掃描資料庫以尋找未連結的項目...",
"entries.unlinked.search_and_relink": "搜尋並重新連結",
"entries.unlinked.search_and_relink": "&搜尋並重新連結",
"entries.unlinked.title": "修復未連結項目",
"field.copy": "複製欄位",
"field.edit": "編輯欄位",
@@ -29,73 +42,134 @@
"file.date_created": "建立日期",
"file.date_modified": "更改日期",
"file.dimensions": "尺寸",
"file.duplicates.description": "TagStudio支援匯入DupeGuru結果以管理重複檔案。",
"file.duplicates.dupeguru.advice": "在鏡像後之後你可以使用DupeGuru刪除不要的檔案。檔案刪除後使用TagStudio內的 “修復未連結項目” 以移除未鏈結的項目。",
"file.duplicates.dupeguru.file_extension": "DupeGuru 檔案 (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "匯入 DupeGuru 檔案",
"file.duplicates.dupeguru.load_file": "&匯入 DupeGuru 檔案",
"file.duplicates.dupeguru.no_file": "尚未選擇 DupeGuru 檔案",
"file.duplicates.dupeguru.open_file": "開啟 DupeGuru 結果檔案",
"file.duplicates.fix": "修復重複檔案",
"file.duplicates.matches": "重複檔案: %{count}",
"file.duplicates.matches": "重複檔案: {count}",
"file.duplicates.matches_uninitialized": "重複檔案: 無",
"file.duplicates.mirror.description": "將項目資料鏡像至各 duplicate match set,在不刪除或是重複欄位的狀態下合併所有資料。此動作不會刪除任何欄位或資料。",
"file.duplicates.mirror_entries": "項目鏡像",
"file.duplicates.mirror.description": "將項目資料鏡像至各複寫組,在不刪除或是重複欄位的狀態下合併所有資料。此動作不會刪除任何欄位或資料。",
"file.duplicates.mirror_entries": "&項目鏡像",
"file.duration": "長度",
"file.not_found": "找不到檔案",
"file.open_file": "開啟檔案",
"file.open_file_with": "開啟檔案",
"file.open_location.generic": "檔案總管內顯示",
"file.open_location.generic": "檔案總管內顯示",
"file.open_location.mac": "在Finder顯示",
"file.open_location.windows": "在檔案總管內顯示",
"folders_to_tags.close_all": "關閉全部",
"folders_to_tags.converting": "正在將資料夾轉換成標籤",
"folders_to_tags.description": "依照的資料夾結構建立標籤並套用到項目上。\n. 以下的結構提供所有會建立的標籤和會用到哪些項目上。",
"folders_to_tags.description": "依照的資料夾結構建立標籤並套用到項目上。\n. 以下的結構提供所有會建立的標籤和會用到項目上。",
"folders_to_tags.open_all": "開啟全部",
"folders_to_tags.title": "從資料夾建立標籤",
"generic.add": "新增",
"generic.apply": "套用",
"generic.apply_alt": "&套用",
"generic.cancel": "取消",
"generic.cancel_alt": "&取消",
"generic.close": "關閉",
"generic.continue": "繼續",
"generic.copy": "複製",
"generic.cut": "剪下",
"generic.delete": "刪除",
"generic.delete_alt": "&刪除",
"generic.done": "完成",
"generic.done_alt": "&完成",
"generic.edit": "編輯",
"generic.edit_alt": "&編輯",
"generic.filename": "檔案名稱",
"generic.navigation.back": "回到",
"generic.navigation.next": "下一個",
"generic.overwrite": "覆蓋",
"generic.overwrite_alt": "&覆蓋",
"generic.paste": "貼上",
"generic.recent_libraries": "最近使用資料庫",
"generic.rename": "重新命名",
"generic.rename_alt": "&重新命名",
"generic.save": "儲存",
"generic.skip": "略過",
"generic.skip_alt": "&略過",
"help.visit_github": "訪問 GitHub 儲存庫",
"home.search": "搜尋",
"home.search_entries": "搜尋項目",
"home.search_library": "搜尋資料庫",
"home.search_tags": "搜尋標籤",
"home.thumbnail_size": "縮圖大小",
"ignore_list.add_extension": "新增擴充功能",
"home.thumbnail_size.extra_large": "特大縮圖",
"home.thumbnail_size.large": "大縮圖",
"home.thumbnail_size.medium": "中縮圖",
"home.thumbnail_size.mini": "迷你縮圖",
"home.thumbnail_size.small": "小縮圖",
"ignore_list.add_extension": "&新增擴充功能",
"ignore_list.mode.exclude": "排除",
"ignore_list.mode.include": "包含",
"ignore_list.mode.label": "條列模式",
"ignore_list.mode.label": "條列模式",
"ignore_list.title": "檔案格式",
"json_migration.checking_for_parity": "檢查奇偶性中...",
"json_migration.creating_database_tables": "建立SQL資料庫表格中...",
"json_migration.description": "<br>開始並預覽文庫移轉作業。 在你點擊 “完成移轉” 前 <i>不會</i> 啟用被移轉的文庫。 <br><br>Library data should either have matching values or 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>大文庫可能需要幾分鐘移轉。</i></center>",
"json_migration.discrepancies_found": "發現文庫差異",
"json_migration.discrepancies_found.description": "在原始檔案和轉換後的資料庫格式中發現差異。請檢查並確認是否繼續執行移轉。",
"json_migration.finish_migration": "完成移轉",
"json_migration.heading.aliases": "別名:",
"json_migration.heading.colors": "顏色:",
"json_migration.heading.differ": "差異",
"json_migration.heading.entires": "項目:",
"json_migration.heading.extension_list_type": "擴展列表類型:",
"json_migration.heading.fields": "欄位:",
"json_migration.heading.file_extension_list": "檔案Extension列表",
"json_migration.heading.match": "配對",
"json_migration.heading.parent_tags": "上層標籤:",
"json_migration.heading.paths": "路徑:",
"json_migration.heading.shorthands": "簡寫:",
"json_migration.heading.tags": "標籤:",
"json_migration.info.description": "透過TagStudio <b>9.4或更舊版本</b> 建立的文庫需要移轉到新的 <b>v9.5+</b> 版本。<br><h2>須知項目:</h2><ul><li>現有的文庫資料 <b><i>不會</i></b> 被刪除</li><li>你的個人資料 <b><i>不會</i></b> 被刪除,移動,或是更改</li><li>TagStudio v9.5+以上的檔案格式並不向下相容</li></ul>",
"json_migration.migrating_files_entries": "移轉 {entries:,d} 個項目中...",
"json_migration.migration_complete": "移轉完成!",
"json_migration.migration_complete_with_discrepancies": "移轉完畢,發現差異",
"json_migration.start_and_preview": "開始並預覽",
"json_migration.title": "儲存格式移轉:\"{path}\"",
"json_migration.title.new_lib": "<h2>v9.5+ 文庫 </h2>",
"json_migration.title.old_lib": "<h2>v9.4 文庫</h2>",
"landing.open_create_library": "開啟/ 建立文庫 {shortcut}",
"library.field.add": "新增欄位",
"library.field.confirm_remove": "您確定要移除此 \"%{self.lib.get_field_attr(field, \"name\")}\" 欄位?",
"library.field.confirm_remove": "您確定要移除此 \"{name}\" 欄位?",
"library.field.mixed_data": "混合資料",
"library.field.remove": "移除欄位",
"library.missing": "找不到資料庫路徑",
"library.name": "資料庫",
"library.refresh.scanning_preparing": "正在掃描資料夾中的新檔案...\n準備中...",
"library.refresh.title": "重新整理路徑中",
"library.refresh.scanning.plural": "掃描並搜尋新項目...\n已搜尋{searched_count} 個檔案,找到 {found_count} 個新檔案",
"library.refresh.scanning.singular": "掃描並搜尋新檔案...\n已搜尋{searched_count} 個檔案,找到 {found_count} 個新檔案",
"library.refresh.scanning_preparing": "正在掃描目錄中的新檔案...\n準備中...",
"library.refresh.title": "重新整理目錄中",
"library.scan_library.title": "掃描資料庫中",
"macros.running.dialog.new_entries": "執行巨集中找到 %{x + 1}/%{len(new_ids)} 個新項目",
"macros.running.dialog.new_entries": "對{count}/{total} 個新項目執行現有巨集",
"macros.running.dialog.title": "針對新項目執行巨集",
"media_player.autoplay": "自動播放",
"menu.edit": "編輯",
"menu.edit.ignore_list": "略過檔案和資料夾",
"menu.file": "檔案",
"menu.edit.manage_file_extensions": "管理副檔名",
"menu.edit.manage_tags": "標籤管理",
"menu.edit.new_tag": "新 &標籤",
"menu.file": "&檔案",
"menu.file.close_library": "&關閉文庫",
"menu.file.new_library": "新資料庫",
"menu.file.open_create_library": "開啟/建立 Library",
"menu.file.open_create_library": "&開啟/建立文庫",
"menu.file.open_library": "開啟資料庫",
"menu.file.refresh_directories": "&重新整理目錄",
"menu.file.save_backup": "&儲存文庫備份",
"menu.file.save_library": "儲存儲存庫",
"menu.help": "提示",
"menu.macros": "巨集",
"menu.help": "提示",
"menu.macros": "巨集",
"menu.macros.folders_to_tags": "檔案夾到標籤",
"menu.select": "選擇",
"menu.tools": "工具",
"menu.view": "檢視",
"menu.tools": "工具",
"menu.tools.fix_duplicate_files": "修復重複 &檔案",
"menu.tools.fix_unlinked_entries": "修復並解除項目連結",
"menu.view": "&檢視",
"menu.window": "選單",
"preview.no_selection": "尚未選擇物件",
"select.all": "全選",
@@ -103,27 +177,39 @@
"settings.open_library_on_start": "在啟動時開啟儲存庫",
"settings.show_filenames_in_grid": "在Grid顯示檔案名稱",
"settings.show_recent_libraries": "顯示最近使用過儲存庫",
"splash.opening_library": "開啟儲存庫",
"status.library_backup_success": "儲存庫備份路徑:",
"splash.opening_library": "開啟文庫 \"{library_path}\"...",
"status.library_backup_in_progress": "儲存庫備份中...",
"status.library_backup_success": "文庫備份路徑:\"{path}\" ({time_span})",
"status.library_closed": "已關閉文庫({time_span})",
"status.library_closing": "關閉文庫中...",
"status.library_save_success": "資料庫保存成功!",
"status.library_search_query": "正在搜尋儲存庫",
"status.library_search_query": "正在搜尋文庫...",
"status.results": "結果",
"status.results_found": "找到 {results.total_count} 個結果",
"status.results_found": "用({time_span})找到 {count} 個結果",
"tag.add": "新增標籤",
"tag.add.plural": "新增標籤",
"tag.add_to_search": "新增到搜尋",
"tag.aliases": "別名",
"tag.color": "顏色",
"tag.confirm_delete": "你確定要刪除此標籤 \"{tag_name}\"",
"tag.create": "建立標籤",
"tag.edit": "編輯標籤",
"tag.name": "名稱",
"tag.new": "新標籤",
"tag.parent_tags": "上層標籤",
"tag.parent_tags.add": "新增上層標籤",
"tag.parent_tags.description": "此標籤可以替代以下的上層標籤。",
"tag.parent_tags.description": "此標籤可以在搜尋時替代以下的上層標籤。",
"tag.remove": "移除標籤",
"tag.search_for_tag": "尋找標籤",
"tag.shorthand": "速記",
"tag.tag_name_required": "標籤名稱(必要)",
"tag_manager.title": "儲存庫標籤",
"view.size.0": "迷你",
"view.size.1": "小",
"view.size.2": "中",
"view.size.3": "大",
"view.size.4": "特大"
"view.size.4": "特大",
"window.message.error_opening_library": "開啟文庫時錯誤了。",
"window.title.error": "錯誤",
"window.title.open_create_library": "開啟/建立文庫"
}

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
VERSION: str = "9.5.0" # Major.Minor.Patch
VERSION_BRANCH: str = "EXPERIMENTAL" # Usually "" or "Pre-Release"
@@ -11,7 +15,12 @@ FONT_SAMPLE_TEXT: str = (
)
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
TAG_FAVORITE = 1
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
# the legacy JSON database. These are used to help migrate libraries from JSON to SQLite.
LEGACY_TAG_FIELD_IDS: set[int] = {6, 7, 8}
TAG_ARCHIVED = 0
TAG_FAVORITE = 1
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import enum
from typing import Any
from uuid import uuid4
@@ -20,10 +24,11 @@ class Theme(str, enum.Enum):
COLOR_DARK_LABEL = "#DD000000"
COLOR_BG = "#65000000"
COLOR_HOVER = "#65AAAAAA"
COLOR_PRESSED = "#65EEEEEE"
COLOR_DISABLED = "#65F39CAA"
COLOR_DISABLED_BG = "#65440D12"
COLOR_HOVER = "#65444444"
COLOR_PRESSED = "#65777777"
COLOR_DISABLED_BG = "#30000000"
COLOR_FORBIDDEN = "#65F39CAA"
COLOR_FORBIDDEN_BG = "#65440D12"
class OpenStatus(enum.IntEnum):
@@ -65,4 +70,4 @@ class LibraryPrefs(DefaultEnum):
IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
DB_VERSION: int = 2
DB_VERSION: int = 3

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from pathlib import Path
import structlog
@@ -44,7 +48,10 @@ def make_tables(engine: Engine) -> None:
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)")
text(
"INSERT INTO tags (id, name, color, is_category) VALUES "
f"({RESERVED_TAG_END}, 'temp', 1, false)"
)
)
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
conn.commit()

View File

@@ -59,6 +59,10 @@ class ItemType(enum.Enum):
TAG_GROUP = 2
class SortingModeEnum(enum.Enum):
DATE_ADDED = "file.date_added"
@dataclass
class FilterState:
"""Represent a state of the Library grid view."""
@@ -66,6 +70,8 @@ class FilterState:
# these should remain
page_index: int | None = 0
page_size: int | None = 500
sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED
ascending: bool = True
# these should be erased on update
# Abstract Syntax Tree Of the current Search Query
@@ -110,6 +116,12 @@ class FilterState:
def with_page_size(self, page_size: int) -> "FilterState":
return replace(self, page_size=page_size)
def with_sorting_mode(self, mode: SortingModeEnum) -> "FilterState":
return replace(self, sorting_mode=mode)
def with_sorting_direction(self, ascending: bool) -> "FilterState":
return replace(self, ascending=ascending)
class FieldTypeEnum(enum.Enum):
TEXT_LINE = "Text Line"

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from __future__ import annotations
from dataclasses import dataclass, field
@@ -11,7 +15,7 @@ from .db import Base
from .enums import FieldTypeEnum
if TYPE_CHECKING:
from .models import Entry, Tag, ValueType
from .models import Entry, ValueType
class BaseField(Base):
@@ -75,33 +79,11 @@ class TextField(BaseField):
def __eq__(self, value) -> bool:
if isinstance(value, TextField):
return self.__key() == value.__key()
elif isinstance(value, (TagBoxField, DatetimeField)):
elif isinstance(value, DatetimeField):
return False
raise NotImplementedError
class TagBoxField(BaseField):
__tablename__ = "tag_box_fields"
tags: Mapped[set[Tag]] = relationship(secondary="tag_fields")
def __key(self):
return (
self.entry_id,
self.type_key,
)
@property
def value(self) -> None:
"""For interface compatibility with other field types."""
return None
def __eq__(self, value) -> bool:
if isinstance(value, TagBoxField):
return self.__key() == value.__key()
raise NotImplementedError
class DatetimeField(BaseField):
__tablename__ = "datetime_fields"
@@ -133,9 +115,6 @@ class _FieldID(Enum):
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_LINE)
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
TAGS = DefaultField(id=6, name="Tags", type=FieldTypeEnum.TAGS)
TAGS_CONTENT = DefaultField(id=7, name="Content Tags", type=FieldTypeEnum.TAGS, is_default=True)
TAGS_META = DefaultField(id=8, name="Meta Tags", type=FieldTypeEnum.TAGS, is_default=True)
COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE)
DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME)
DATE_CREATED = DefaultField(id=11, name="Date Created", type=FieldTypeEnum.DATETIME)

View File

@@ -1,18 +1,22 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
class TagSubtag(Base):
__tablename__ = "tag_subtags"
class TagParent(Base):
__tablename__ = "tag_parents"
parent_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
child_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
class TagField(Base):
__tablename__ = "tag_fields"
class TagEntry(Base):
__tablename__ = "tag_entries"
field_id: Mapped[int] = mapped_column(ForeignKey("tag_box_fields.id"), primary_key=True)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import re
import shutil
import time
@@ -7,18 +11,21 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import Any
from uuid import uuid4
from warnings import catch_warnings
import structlog
from humanfriendly import format_timespan
from sqlalchemy import (
URL,
ColumnExpressionArgument,
Engine,
NullPool,
and_,
asc,
create_engine,
delete,
desc,
exists,
func,
or_,
@@ -29,6 +36,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import (
Session,
contains_eager,
joinedload,
make_transient,
selectinload,
)
@@ -36,21 +44,24 @@ from src.core.library.json.library import Library as JsonLibrary # type: ignore
from ...constants import (
BACKUP_FOLDER_NAME,
LEGACY_TAG_FIELD_IDS,
RESERVED_TAG_END,
RESERVED_TAG_START,
TAG_ARCHIVED,
TAG_FAVORITE,
TAG_META,
TS_FOLDER_NAME,
)
from ...enums import LibraryPrefs
from .db import make_tables
from .enums import FieldTypeEnum, FilterState, TagColor
from .enums import FieldTypeEnum, FilterState, SortingModeEnum, TagColor
from .fields import (
BaseField,
DatetimeField,
TagBoxField,
TextField,
_FieldID,
)
from .joins import TagField, TagSubtag
from .joins import TagEntry, TagParent
from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType
from .visitors import SQLBoolExpressionBuilder
@@ -71,13 +82,19 @@ def slugify(input_string: str) -> str:
def get_default_tags() -> tuple[Tag, ...]:
meta_tag = Tag(
id=TAG_META,
name="Meta Tags",
aliases={TagAlias(name="Meta"), TagAlias(name="Meta Tag")},
is_category=True,
)
archive_tag = Tag(
id=TAG_ARCHIVED,
name="Archived",
aliases={TagAlias(name="Archive")},
parent_tags={meta_tag},
color=TagColor.RED,
)
favorite_tag = Tag(
id=TAG_FAVORITE,
name="Favorite",
@@ -85,10 +102,15 @@ def get_default_tags() -> tuple[Tag, ...]:
TagAlias(name="Favorited"),
TagAlias(name="Favorites"),
},
parent_tags={meta_tag},
color=TagColor.YELLOW,
)
return archive_tag, favorite_tag
return archive_tag, favorite_tag, meta_tag
# The difference in the number of default JSON tags vs default tags in the current version.
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
@dataclass(frozen=True)
@@ -165,18 +187,30 @@ class Library:
color=TagColor.get_color_from_str(tag.color),
)
)
# Apply user edits to built-in JSON tags.
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1):
updated_tag = self.get_tag(tag.id)
updated_tag.color = TagColor.get_color_from_str(tag.color)
self.update_tag(updated_tag) # NOTE: This just calls add_tag?
# 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)
# Only add new (user-created) aliases to the default tags.
# This prevents pre-existing built-in aliases from being added as duplicates.
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END + 1):
for dt in get_default_tags():
if dt.id == tag.id and alias not in dt.alias_strings:
self.add_alias(name=alias, tag_id=tag.id)
else:
self.add_alias(name=alias, tag_id=tag.id)
# Tag Subtags
# Parent Tags (Previously known as "Subtags" in JSON)
for tag in json_lib.tags:
for subtag_id in tag.subtag_ids:
self.add_subtag(parent_id=tag.id, child_id=subtag_id)
for child_id in tag.subtag_ids:
self.add_parent_tag(parent_id=tag.id, child_id=child_id)
# Entries
self.add_entries(
@@ -193,11 +227,15 @@ class Library:
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,
)
# Old tag fields get added as tags
if k in LEGACY_TAG_FIELD_IDS:
self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v)
else:
self.add_field_to_entry(
entry_id=(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])
@@ -233,9 +271,7 @@ class Library:
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:
def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
connection_string = URL.create(
drivername="sqlite",
database=str(self.storage_path),
@@ -256,13 +292,13 @@ class Library:
with Session(self.engine) as session:
make_tables(self.engine)
if add_default_data:
# Add default tags to new libraries only.
if is_new:
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
@@ -281,12 +317,13 @@ class Library:
)
for pref in LibraryPrefs:
try:
session.add(Preferences(key=pref.name, value=pref.default))
session.commit()
except IntegrityError:
logger.debug("preference already exists", pref=pref)
session.rollback()
with catch_warnings(record=True):
try:
session.add(Preferences(key=pref.name, value=pref.default))
session.commit()
except IntegrityError:
logger.debug("preference already exists", pref=pref)
session.rollback()
for field in _FieldID:
try:
@@ -356,43 +393,6 @@ class Library:
session.delete(item)
session.commit()
def remove_field_tag(self, entry: Entry, tag_id: int, field_key: str) -> bool:
assert isinstance(field_key, str), f"field_key is {type(field_key)}"
with Session(self.engine) as session:
# find field matching entry and field_type
field = session.scalars(
select(TagBoxField).where(
and_(
TagBoxField.entry_id == entry.id,
TagBoxField.type_key == field_key,
)
)
).first()
if not field:
logger.error("no field found", entry=entry, field=field)
return False
try:
# find the record in `TagField` table and delete it
tag_field = session.scalars(
select(TagField).where(
and_(
TagField.tag_id == tag_id,
TagField.field_id == field.id,
)
)
).first()
if tag_field:
session.delete(tag_field)
session.commit()
return True
except IntegrityError as e:
logger.exception(e)
session.rollback()
return False
def get_entry(self, entry_id: int) -> Entry | None:
"""Load entry without joins."""
with Session(self.engine) as session:
@@ -403,22 +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."""
def get_entry_full(
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
) -> Entry | None:
"""Load entry and 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)),
)
if with_fields:
statement = (
statement.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields))
)
if with_tags:
statement = (
statement.outerjoin(Entry.tags)
.outerjoin(TagAlias)
.options(
selectinload(Entry.tags).options(
joinedload(Tag.aliases),
joinedload(Tag.parent_tags),
)
)
)
entry = session.scalar(statement)
if not entry:
return None
@@ -426,6 +433,32 @@ class Library:
make_transient(entry)
return entry
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
"""Load entry and join with all joins and all tags."""
with Session(self.engine) as session:
statement = select(Entry).where(Entry.id.in_(set(entry_ids)))
statement = (
statement.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tags)
)
statement = statement.options(
selectinload(Entry.text_fields),
selectinload(Entry.datetime_fields),
selectinload(Entry.tags).options(
selectinload(Tag.aliases),
selectinload(Tag.parent_tags),
),
)
statement = statement.distinct()
entries = session.execute(statement).scalars()
entries = entries.unique()
for entry in entries:
yield entry
session.expunge(entry)
@property
def entries_count(self) -> int:
with Session(self.engine) as session:
@@ -440,12 +473,12 @@ class Library:
stmt = (
stmt.outerjoin(Entry.text_fields)
.outerjoin(Entry.datetime_fields)
.outerjoin(Entry.tag_box_fields)
.outerjoin(Entry.tags)
)
stmt = stmt.options(
contains_eager(Entry.text_fields),
contains_eager(Entry.datetime_fields),
contains_eager(Entry.tag_box_fields).selectinload(TagBoxField.tags),
contains_eager(Entry.tags),
)
stmt = stmt.distinct()
@@ -461,8 +494,8 @@ class Library:
@property
def tags(self) -> list[Tag]:
with Session(self.engine) as session:
# load all tags and join subtags
tags_query = select(Tag).options(selectinload(Tag.subtags))
# load all tags and join parent tags
tags_query = select(Tag).options(selectinload(Tag.parent_tags))
tags = session.scalars(tags_query).unique()
tags_list = list(tags)
@@ -544,13 +577,8 @@ class Library:
if search.ast:
start_time = time.time()
statement = statement.outerjoin(Entry.tag_box_fields).where(
SQLBoolExpressionBuilder(self).visit(search.ast)
)
statement = statement.where(SQLBoolExpressionBuilder(self).visit(search.ast))
end_time = time.time()
logger.info(
f"SQL Expression Builder finished ({format_timespan(end_time - start_time)})"
)
@@ -563,19 +591,16 @@ class Library:
elif extensions:
statement = statement.where(Entry.suffix.in_(extensions))
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)),
)
statement = statement.distinct(Entry.id)
query_count = select(func.count()).select_from(statement.alias("entries"))
count_all: int = session.execute(query_count).scalar()
sort_on: ColumnExpressionArgument = Entry.id
match search.sorting_mode:
case SortingModeEnum.DATE_ADDED:
sort_on = Entry.id
statement = statement.order_by(asc(sort_on) if search.ascending else desc(sort_on))
statement = statement.limit(search.limit).offset(search.offset)
logger.info(
@@ -603,7 +628,7 @@ class Library:
with Session(self.engine) as session:
query = select(Tag)
query = query.options(
selectinload(Tag.subtags),
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
).limit(tag_limit)
@@ -630,24 +655,6 @@ class Library:
return res
def get_all_child_tag_ids(self, tag_id: int) -> list[int]:
"""Recursively traverse a Tag's subtags and return a list of all children tags."""
all_subtags: set[int] = {tag_id}
with Session(self.engine) as session:
tag = session.scalar(select(Tag).where(Tag.id == tag_id))
if tag is None:
raise ValueError(f"No tag found with id {tag_id}.")
subtag_ids = tag.subtag_ids
all_subtags.update(subtag_ids)
for sub_id in subtag_ids:
all_subtags.update(self.get_all_child_tag_ids(sub_id))
return list(all_subtags)
def update_entry_path(self, entry_id: int | Entry, path: Path) -> None:
if isinstance(entry_id, Entry):
entry_id = entry_id.id
@@ -669,11 +676,11 @@ class Library:
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)
child_tags = session.scalars(
select(TagParent).where(TagParent.child_id == tag.id)
).all()
tags_query = select(Tag).options(
selectinload(Tag.subtags), selectinload(Tag.aliases)
selectinload(Tag.parent_tags), selectinload(Tag.aliases)
)
tag = session.scalar(tags_query.where(Tag.id == tag.id))
aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id))
@@ -681,9 +688,9 @@ class Library:
for alias in aliases or []:
session.delete(alias)
for subtag in subtags or []:
session.delete(subtag)
session.expunge(subtag)
for child_tag in child_tags or []:
session.delete(child_tag)
session.expunge(child_tag)
session.delete(tag)
session.commit()
@@ -697,16 +704,6 @@ class Library:
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()
tag = session.scalars(select(Tag).where(Tag.id == tag.id)).one()
field_.tags.remove(tag)
session.add(field_)
session.commit()
def update_field_position(
self,
field_class: type[BaseField],
@@ -776,7 +773,7 @@ class Library:
self,
entry_ids: list[int] | int,
field: BaseField,
content: str | datetime | set[Tag],
content: str | datetime,
):
if isinstance(entry_ids, int):
entry_ids = [entry_ids]
@@ -810,17 +807,17 @@ class Library:
session.expunge(field)
return field
def add_entry_field_type(
def add_field_to_entry(
self,
entry_ids: list[int] | int,
entry_id: int,
*,
field: ValueType | None = None,
field_id: _FieldID | str | None = None,
value: str | datetime | list[int] | None = None,
value: str | datetime | None = None,
) -> bool:
logger.info(
"add_field_to_entry",
entry_ids=entry_ids,
entry_id=entry_id,
field_type=field,
field_id=field_id,
value=value,
@@ -828,32 +825,17 @@ class Library:
# supply only instance or ID, not both
assert bool(field) != (field_id is not None)
if isinstance(entry_ids, int):
entry_ids = [entry_ids]
if not field:
if isinstance(field_id, _FieldID):
field_id = field_id.name
field = self.get_value_type(field_id)
field_model: TextField | DatetimeField | TagBoxField
field_model: TextField | DatetimeField
if field.type in (FieldTypeEnum.TEXT_LINE, FieldTypeEnum.TEXT_BOX):
field_model = TextField(
type_key=field.key,
value=value or "",
)
elif field.type == FieldTypeEnum.TAGS:
field_model = TagBoxField(
type_key=field.key,
)
if value:
assert isinstance(value, list)
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(
@@ -865,11 +847,9 @@ class Library:
with Session(self.engine) as session:
try:
for entry_id in entry_ids:
field_model.entry_id = entry_id
session.add(field_model)
session.flush()
field_model.entry_id = entry_id
session.add(field_model)
session.flush()
session.commit()
except IntegrityError as e:
logger.exception(e)
@@ -881,7 +861,7 @@ class Library:
self.update_field_position(
field_class=type(field_model),
field_type=field.key,
entry_ids=entry_ids,
entry_ids=entry_id,
)
return True
@@ -910,7 +890,7 @@ class Library:
def add_tag(
self,
tag: Tag,
subtag_ids: list[int] | set[int] | None = None,
parent_ids: list[int] | set[int] | None = None,
alias_names: list[str] | set[str] | None = None,
alias_ids: list[int] | set[int] | None = None,
) -> Tag | None:
@@ -919,8 +899,8 @@ class Library:
session.add(tag)
session.flush()
if subtag_ids is not None:
self.update_subtags(tag, subtag_ids, session)
if parent_ids is not None:
self.update_parent_tags(tag, parent_ids, session)
if alias_ids is not None and alias_names is not None:
self.update_aliases(tag, alias_ids, alias_names, session)
@@ -934,59 +914,44 @@ class Library:
session.rollback()
return None
def add_field_tag(
self,
entry: Entry,
tag: Tag,
field_key: str = _FieldID.TAGS.name,
create_field: bool = False,
) -> bool:
assert isinstance(field_key, str), f"field_key is {type(field_key)}"
with Session(self.engine) as session:
# find field matching entry and field_type
field = session.scalars(
select(TagBoxField).where(
and_(
TagBoxField.entry_id == entry.id,
TagBoxField.type_key == field_key,
)
)
).first()
if not field and not create_field:
logger.error("no field found", entry=entry, field_key=field_key)
def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Add one or more tags to an entry."""
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
try:
# TODO: Optimize this by using a single query to update.
for tag_id in tag_ids_:
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
session.flush()
session.commit()
return True
except IntegrityError as e:
logger.warning("[add_tags_to_entry]", warning=e)
session.rollback()
return False
def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
"""Remove one or more tags from an entry."""
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
with Session(self.engine, expire_on_commit=False) as session:
try:
if not field:
field = TagBoxField(
type_key=field_key,
entry_id=entry.id,
position=0,
)
session.add(field)
session.flush()
# create record for `TagField` table
if not tag.id:
session.add(tag)
session.flush()
tag_field = TagField(
tag_id=tag.id,
field_id=field.id,
)
session.add(tag_field)
for tag_id in tag_ids_:
tag_entry = session.scalars(
select(TagEntry).where(
and_(
TagEntry.tag_id == tag_id,
TagEntry.entry_id == entry_id,
)
)
).first()
if tag_entry:
session.delete(tag_entry)
session.commit()
session.commit()
logger.info("tag added to field", tag=tag, field=field, entry_id=entry.id)
return True
except IntegrityError as e:
logger.exception(e)
session.rollback()
return False
def save_library_backup_to_disk(self) -> Path:
@@ -1006,12 +971,14 @@ class Library:
def get_tag(self, tag_id: int) -> Tag:
with Session(self.engine) as session:
tags_query = select(Tag).options(selectinload(Tag.subtags), selectinload(Tag.aliases))
tags_query = select(Tag).options(
selectinload(Tag.parent_tags), 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 parent in tag.parent_tags:
session.expunge(parent)
for alias in tag.aliases:
session.expunge(alias)
@@ -1034,19 +1001,19 @@ class Library:
return alias
def add_subtag(self, parent_id: int, child_id: int) -> bool:
def add_parent_tag(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:
subtag = TagSubtag(
parent_tag = TagParent(
parent_id=parent_id,
child_id=child_id,
)
try:
session.add(subtag)
session.add(parent_tag)
session.commit()
return True
except IntegrityError:
@@ -1073,11 +1040,11 @@ class Library:
logger.exception("IntegrityError")
return False
def remove_subtag(self, base_id: int, remove_tag_id: int) -> bool:
def remove_parent_tag(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()
remove = session.query(TagParent).filter_by(parent_id=p_id, child_id=r_id).one()
session.delete(remove)
session.commit()
@@ -1086,12 +1053,12 @@ class Library:
def update_tag(
self,
tag: Tag,
subtag_ids: list[int] | set[int] | None = None,
parent_ids: list[int] | set[int] | None = None,
alias_names: list[str] | set[str] | None = None,
alias_ids: list[int] | set[int] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, subtag_ids, alias_names, alias_ids)
self.add_tag(tag, parent_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()
@@ -1107,35 +1074,37 @@ class Library:
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)
def update_parent_tags(self, tag, parent_ids, session):
if tag.id in parent_ids:
parent_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()
# load all tag's parent tags to know which to remove
prev_parent_tags = session.scalars(
select(TagParent).where(TagParent.parent_id == tag.id)
).all()
for subtag in prev_subtags:
if subtag.child_id not in subtag_ids:
session.delete(subtag)
for parent_tag in prev_parent_tags:
if parent_tag.child_id not in parent_ids:
session.delete(parent_tag)
else:
# no change, remove from list
subtag_ids.remove(subtag.child_id)
parent_ids.remove(parent_tag.child_id)
# create remaining items
for subtag_id in subtag_ids:
# add new subtag
subtag = TagSubtag(
for parent_id in parent_ids:
# add new parent tag
parent_tag = TagParent(
parent_id=tag.id,
child_id=subtag_id,
child_id=parent_id,
)
session.add(subtag)
session.add(parent_tag)
def prefs(self, key: LibraryPrefs) -> Any:
def prefs(self, key: LibraryPrefs):
# load given item from Preferences table
with Session(self.engine) as session:
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value
def set_prefs(self, key: LibraryPrefs, value: Any) -> None:
def set_prefs(self, key: LibraryPrefs, value) -> None:
# set given item in Preferences table
with Session(self.engine) as session:
# load existing preference and update value
@@ -1158,8 +1127,8 @@ class Library:
for entry in entries:
for field_key, field in fields.items():
if field_key not in existing_fields:
self.add_entry_field_type(
entry_ids=entry.id,
self.add_field_to_entry(
entry_id=entry.id,
field_id=field.type_key,
value=field.value,
)

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from pathlib import Path
from sqlalchemy import JSON, ForeignKey, Integer, event
@@ -11,20 +15,16 @@ from .fields import (
BooleanField,
DatetimeField,
FieldTypeEnum,
TagBoxField,
TextField,
_FieldID,
)
from .joins import TagSubtag
from .joins import TagParent
class TagAlias(Base):
__tablename__ = "tag_aliases"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
tag: Mapped["Tag"] = relationship(back_populates="aliases")
@@ -46,27 +46,21 @@ class Tag(Base):
name: Mapped[str]
shorthand: Mapped[str | None]
color: Mapped[TagColor]
is_category: Mapped[bool]
icon: Mapped[str | None]
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
parent_tags: Mapped[set["Tag"]] = relationship(
secondary=TagSubtag.__tablename__,
primaryjoin="Tag.id == TagSubtag.child_id",
secondaryjoin="Tag.id == TagSubtag.parent_id",
back_populates="subtags",
)
subtags: Mapped[set["Tag"]] = relationship(
secondary=TagSubtag.__tablename__,
primaryjoin="Tag.id == TagSubtag.parent_id",
secondaryjoin="Tag.id == TagSubtag.child_id",
secondary=TagParent.__tablename__,
primaryjoin="Tag.id == TagParent.parent_id",
secondaryjoin="Tag.id == TagParent.child_id",
back_populates="parent_tags",
)
@property
def subtag_ids(self) -> list[int]:
return [tag.id for tag in self.subtags]
def parent_ids(self) -> list[int]:
return [tag.id for tag in self.parent_tags]
@property
def alias_strings(self) -> list[str]:
@@ -83,17 +77,17 @@ class Tag(Base):
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,
is_category: bool = False,
):
self.name = name
self.aliases = aliases or set()
self.parent_tags = parent_tags or set()
self.subtags = subtags or set()
self.color = color
self.icon = icon
self.shorthand = shorthand
self.is_category = is_category
assert not self.id
self.id = id
super().__init__()
@@ -104,6 +98,18 @@ class Tag(Base):
def __repr__(self) -> str:
return self.__str__()
def __lt__(self, other) -> bool:
return self.name < other.name
def __le__(self, other) -> bool:
return self.name <= other.name
def __gt__(self, other) -> bool:
return self.name > other.name
def __ge__(self, other) -> bool:
return self.name >= other.name
class Folder(Base):
__tablename__ = "folders"
@@ -125,6 +131,8 @@ class Entry(Base):
path: Mapped[Path] = mapped_column(PathType, unique=True)
suffix: Mapped[str] = mapped_column()
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
text_fields: Mapped[list[TextField]] = relationship(
back_populates="entry",
cascade="all, delete",
@@ -133,44 +141,22 @@ class Entry(Base):
back_populates="entry",
cascade="all, delete",
)
tag_box_fields: Mapped[list[TagBoxField]] = relationship(
back_populates="entry",
cascade="all, delete",
)
@property
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
fields.extend(self.tag_box_fields)
fields.extend(self.text_fields)
fields.extend(self.datetime_fields)
fields = sorted(fields, key=lambda field: field.type.position)
return fields
@property
def tags(self) -> set[Tag]:
tag_set: set[Tag] = set()
for tag_box_field in self.tag_box_fields:
tag_set.update(tag_box_field.tags)
return tag_set
@property
def is_favorited(self) -> bool:
for tag_box_field in self.tag_box_fields:
if tag_box_field.type_key == _FieldID.TAGS_META.name:
for tag in tag_box_field.tags:
if tag.id == TAG_FAVORITE:
return True
return False
def is_favorite(self) -> bool:
return any(tag.id == TAG_FAVORITE for tag in self.tags)
@property
def is_archived(self) -> bool:
for tag_box_field in self.tag_box_fields:
if tag_box_field.type_key == _FieldID.TAGS_META.name:
for tag in tag_box_field.tags:
if tag.id == TAG_ARCHIVED:
return True
return False
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
def __init__(
self,
@@ -189,27 +175,15 @@ class Entry(Base):
self.text_fields.append(field)
elif isinstance(field, DatetimeField):
self.datetime_fields.append(field)
elif isinstance(field, TagBoxField):
self.tag_box_fields.append(field)
else:
raise ValueError(f"Invalid field type: {field}")
def has_tag(self, tag: Tag) -> bool:
return tag in self.tags
def remove_tag(self, tag: Tag, field: TagBoxField | None = None) -> None:
"""Removes a Tag from the Entry.
If given a field index, the given Tag will
only be removed from that index. If left blank, all instances of that
Tag will be removed from the Entry.
"""
if field:
field.tags.remove(tag)
return
for tag_box_field in self.tag_box_fields:
tag_box_field.tags.remove(tag)
def remove_tag(self, tag: Tag) -> None:
"""Removes a Tag from the Entry."""
self.tags.remove(tag)
class ValueType(Base):
@@ -237,7 +211,6 @@ class ValueType(Base):
datetime_fields: Mapped[list[DatetimeField]] = relationship(
"DatetimeField", back_populates="type"
)
tag_box_fields: Mapped[list[TagBoxField]] = relationship("TagBoxField", back_populates="type")
boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type")
@property
@@ -245,7 +218,6 @@ class ValueType(Base):
FieldClass = { # noqa: N806
FieldTypeEnum.TEXT_LINE: TextField,
FieldTypeEnum.TEXT_BOX: TextField,
FieldTypeEnum.TAGS: TagBoxField,
FieldTypeEnum.DATETIME: DatetimeField,
FieldTypeEnum.BOOLEAN: BooleanField,
}

View File

@@ -1,3 +1,7 @@
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import TYPE_CHECKING
import structlog
@@ -8,8 +12,8 @@ from src.core.media_types import FILETYPE_EQUIVALENTS, 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
from .joins import TagEntry
from .models import Entry, Tag, TagAlias
# workaround to have autocompletion in the Editor
if TYPE_CHECKING:
@@ -19,16 +23,17 @@ else:
logger = structlog.get_logger(__name__)
# TODO: Reevaluate after subtags -> parent tags name change
CHILDREN_QUERY = text("""
-- Note for this entire query that tag_subtags.child_id is the parent id and tag_subtags.parent_id is the child id due to bad naming
WITH RECURSIVE Subtags AS (
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS child_id
UNION ALL
SELECT ts.parent_id AS child_id
FROM tag_subtags ts
INNER JOIN Subtags s ON ts.child_id = s.child_id
SELECT tp.parent_id AS child_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.child_id = c.child_id
)
SELECT * FROM Subtags;
SELECT * FROM ChildTags;
""") # noqa: E501
@@ -51,12 +56,18 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
tag_ids: list[int] = []
bool_expressions: list[ColumnExpressionArgument] = []
# Search for TagID / unambigous Tag Constraints and store the respective tag ids seperately
# Search for TagID / unambiguous Tag Constraints and store the respective tag ids separately
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))
try:
tag_ids.append(int(term.value))
except ValueError:
logger.error(
"[SQLBoolExpressionBuilder] Could not cast value to an int Tag ID",
value=term.value,
)
continue
case ConstraintType.Tag:
if len(ids := self.__get_tag_ids(term.value)) == 1:
@@ -72,19 +83,20 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
# 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])
self.__entry_satisfies_expression(TagEntry.tag_id == tag_ids[0])
)
return and_(*bool_expressions)
def visit_constraint(self, node: Constraint) -> ColumnExpressionArgument:
"""Returns a Boolean Expression that is true, if the Entry satisfies the constraint."""
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)))
return Entry.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))
return Entry.tags.any(Tag.id == int(node.value))
elif node.type == ConstraintType.Path:
return Entry.path.op("GLOB")(node.value)
elif node.type == ConstraintType.MediaType:
@@ -100,9 +112,7 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
)
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)
)
return ~Entry.id.in_(select(Entry.id).join(TagEntry))
# raise exception if Constraint stays unhandled
raise NotImplementedError("This type of constraint is not implemented yet")
@@ -141,11 +151,10 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
# Relational Division Query
return Entry.id.in_(
select(Entry.id)
.outerjoin(TagBoxField)
.outerjoin(TagField)
.where(TagField.tag_id.in_(tag_ids))
.outerjoin(TagEntry)
.where(TagEntry.tag_id.in_(tag_ids))
.group_by(Entry.id)
.having(func.count(distinct(TagField.tag_id)) == len(tag_ids))
.having(func.count(distinct(TagEntry.tag_id)) == len(tag_ids))
)
def __entry_satisfies_ast(self, partial_query: AST) -> BinaryExpression[bool]:
@@ -155,7 +164,8 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnExpressionArgument]):
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)
)
"""Returns Binary Expression that is true if the Entry satisfies the column expression.
Executed on: Entry ⟕ TagEntry (Entry LEFT OUTER JOIN TagEntry).
"""
return Entry.id.in_(select(Entry.id).outerjoin(TagEntry).where(expr))

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import traceback
@@ -25,7 +25,8 @@ class UiColor(IntEnum):
THEME_LIGHT = 2
RED = 3
GREEN = 4
PURPLE = 5
BLUE = 5
PURPLE = 6
TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = {
@@ -309,6 +310,12 @@ UI_COLORS: dict[UiColor, dict[ColorType, Any]] = {
ColorType.LIGHT_ACCENT: "#DDFFCC",
ColorType.DARK_ACCENT: "#0d3828",
},
UiColor.BLUE: {
ColorType.PRIMARY: "#3b87f0",
ColorType.BORDER: "#4e95f2",
ColorType.LIGHT_ACCENT: "#aedbfa",
ColorType.DARK_ACCENT: "#122948",
},
UiColor.PURPLE: {
ColorType.PRIMARY: "#C76FF3",
ColorType.BORDER: "#c364f2",

View File

@@ -126,7 +126,7 @@ class TagStudioCore:
is_new = field["id"] not in entry_field_types
field_key = field["id"]
if is_new:
lib.add_entry_field_type(entry.id, field_key, field["value"])
lib.add_field_to_entry(entry.id, field_key, field["value"])
else:
lib.update_entry_field(entry.id, field_key, field["value"])

View File

@@ -46,11 +46,9 @@ def open_file(path: str | Path, file_manager: bool = False):
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
command_name = "start"
# first parameter is for title, NOT filepath
command_args = ["", normpath]
command = f'"{normpath}"'
subprocess.Popen(
[command_name] + command_args,
command,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP

View File

@@ -1,14 +1,4 @@
# -*- coding: utf-8 -*-
################################################################################
# Form generated from reading UI file 'home.ui'
##
# Created by: Qt User Interface Compiler version 6.5.1
##
# WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -51,10 +41,6 @@ class Ui_MainWindow(QMainWindow):
# self.windowFX = WindowEffect()
# self.windowFX.setAcrylicEffect(self.winId(), isEnableShadow=False)
# # self.setStyleSheet(
# # 'background:#EE000000;'
# # )
def setupUi(self, MainWindow):
if not MainWindow.objectName():
@@ -76,6 +62,15 @@ class Ui_MainWindow(QMainWindow):
spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_3.addItem(spacerItem)
# Sorting Dropdowns
self.sorting_mode_combobox = QComboBox(self.centralwidget)
self.sorting_mode_combobox.setObjectName(u"sortingModeComboBox")
self.horizontalLayout_3.addWidget(self.sorting_mode_combobox)
self.sorting_direction_combobox = QComboBox(self.centralwidget)
self.sorting_direction_combobox.setObjectName(u"sortingDirectionCombobox")
self.horizontalLayout_3.addWidget(self.sorting_direction_combobox)
# Thumbnail Size placeholder
self.thumb_size_combobox = QComboBox(self.centralwidget)
self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox")
@@ -126,9 +121,9 @@ class Ui_MainWindow(QMainWindow):
self.horizontalLayout.addWidget(self.splitter)
self.splitter.addWidget(self.frame_container)
self.splitter.setStretchFactor(0, 1)
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 1)
nav_button_style = "font-size:14;font-weight:bold;"
self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
self.horizontalLayout_2.setSizeConstraint(QLayout.SetMinimumSize)
@@ -136,46 +131,31 @@ class Ui_MainWindow(QMainWindow):
self.backButton.setObjectName(u"backButton")
self.backButton.setMinimumSize(QSize(0, 32))
self.backButton.setMaximumSize(QSize(32, 16777215))
font = QFont()
font.setPointSize(14)
font.setBold(True)
self.backButton.setFont(font)
self.backButton.setStyleSheet(nav_button_style)
self.horizontalLayout_2.addWidget(self.backButton)
self.forwardButton = QPushButton(">", self.centralwidget)
self.forwardButton.setObjectName(u"forwardButton")
self.forwardButton.setMinimumSize(QSize(0, 32))
self.forwardButton.setMaximumSize(QSize(32, 16777215))
font1 = QFont()
font1.setPointSize(14)
font1.setBold(True)
font1.setKerning(True)
self.forwardButton.setFont(font1)
self.forwardButton.setStyleSheet(nav_button_style)
self.horizontalLayout_2.addWidget(self.forwardButton)
self.searchField = QLineEdit(self.centralwidget)
Translations.translate_with_setter(self.searchField.setPlaceholderText, "home.search_entries")
self.searchField.setObjectName(u"searchField")
self.searchField.setMinimumSize(QSize(0, 32))
font2 = QFont()
font2.setPointSize(11)
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)
Translations.translate_qobject(self.searchButton, "home.search")
self.searchButton.setObjectName(u"searchButton")
self.searchButton.setMinimumSize(QSize(0, 32))
self.searchButton.setFont(font2)
self.horizontalLayout_2.addWidget(self.searchButton)
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
@@ -190,6 +170,7 @@ class Ui_MainWindow(QMainWindow):
sizePolicy1.setHeightForWidth(
self.statusbar.sizePolicy().hasHeightForWidth())
self.statusbar.setSizePolicy(sizePolicy1)
self.statusbar.setSizeGripEnabled(False)
MainWindow.setStatusBar(self.statusbar)
QMetaObject.connectSlotsByName(MainWindow)

View File

@@ -25,7 +25,6 @@ class AddFieldModal(QWidget):
# - OR -
# [Cancel] [Save]
super().__init__()
self.is_connected = False
self.lib = library
Translations.translate_with_setter(self.setWindowTitle, "library.field.add")
self.setWindowModality(Qt.WindowModality.ApplicationModal)

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -10,8 +10,10 @@ import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
@@ -56,6 +58,7 @@ class BuildTagPanel(PanelWidget):
def __init__(self, library: Library, tag: Tag | None = None):
super().__init__()
self.lib = library
self.tag: Tag # NOTE: This gets set at the end of the init.
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
@@ -117,23 +120,22 @@ class BuildTagPanel(PanelWidget):
self.alias_add_button.clicked.connect(self.add_alias_callback)
# Subtags ------------------------------------------------------------
# Parent Tags ----------------------------------------------------------
self.parent_tags_widget = QWidget()
self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget)
self.parent_tags_layout.setStretch(1, 1)
self.parent_tags_layout.setContentsMargins(0, 0, 0, 0)
self.parent_tags_layout.setSpacing(0)
self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.subtags_widget = QWidget()
self.subtags_layout = QVBoxLayout(self.subtags_widget)
self.subtags_layout.setStretch(1, 1)
self.subtags_layout.setContentsMargins(0, 0, 0, 0)
self.subtags_layout.setSpacing(0)
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.subtags_title = QLabel()
Translations.translate_qobject(self.subtags_title, "tag.parent_tags")
self.subtags_layout.addWidget(self.subtags_title)
self.parent_tags_title = QLabel()
Translations.translate_qobject(self.parent_tags_title, "tag.parent_tags")
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
self.subtags_scroll_layout = QVBoxLayout(self.scroll_contents)
self.subtags_scroll_layout.setContentsMargins(6, 0, 6, 0)
self.subtags_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
self.parent_tags_scroll_layout.setContentsMargins(6, 0, 6, 0)
self.parent_tags_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus)
@@ -144,29 +146,29 @@ class BuildTagPanel(PanelWidget):
self.scroll_area.setWidget(self.scroll_contents)
# self.scroll_area.setMinimumHeight(60)
self.subtags_layout.addWidget(self.scroll_area)
self.parent_tags_layout.addWidget(self.scroll_area)
self.subtags_add_button = QPushButton()
self.subtags_add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.subtags_add_button.setText("+")
self.subtags_layout.addWidget(self.subtags_add_button)
self.parent_tags_add_button = QPushButton()
self.parent_tags_add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.parent_tags_add_button.setText("+")
self.parent_tags_layout.addWidget(self.parent_tags_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))
tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
self.add_tag_modal = PanelModal(tsp)
Translations.translate_with_setter(self.add_tag_modal.setTitle, "tag.parent_tags.add")
Translations.translate_with_setter(self.add_tag_modal.setWindowTitle, "tag.parent_tags.add")
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Shorthand ------------------------------------------------------------
# Color ----------------------------------------------------------------
self.color_widget = QWidget()
self.color_layout = QVBoxLayout(self.color_widget)
self.color_layout.setStretch(1, 1)
self.color_layout.setContentsMargins(0, 0, 0, 0)
self.color_layout.setContentsMargins(0, 0, 0, 24)
self.color_layout.setSpacing(0)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel()
@@ -193,16 +195,47 @@ class BuildTagPanel(PanelWidget):
)
self.color_layout.addWidget(self.color_field)
# Category -------------------------------------------------------------
self.cat_widget = QWidget()
self.cat_layout = QHBoxLayout(self.cat_widget)
self.cat_layout.setStretch(1, 1)
self.cat_layout.setContentsMargins(0, 0, 0, 0)
self.cat_layout.setSpacing(0)
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.cat_title = QLabel()
self.cat_title.setText("Is Category")
self.cat_checkbox = QCheckBox()
# TODO: Style checkbox
self.cat_checkbox.setStyleSheet(
"QCheckBox::indicator{"
"width: 19px; height: 19px;"
# f"background: #1e1e1e;"
# f"border-color: #333333;"
# f"border-radius: 6px;"
# f"border-style:solid;"
# f"border-width:{math.ceil(self.devicePixelRatio())}px;"
"}"
# f"QCheckBox::indicator::hover"
# f"{{"
# f"border-color: #CCCCCC;"
# f"background: #555555;"
# f"}}"
)
self.cat_layout.addWidget(self.cat_checkbox)
self.cat_layout.addWidget(self.cat_title)
# 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_table)
self.root_layout.addWidget(self.alias_add_button)
self.root_layout.addWidget(self.subtags_widget)
self.root_layout.addWidget(self.parent_tags_widget)
self.root_layout.addWidget(self.color_widget)
self.root_layout.addWidget(QLabel("<h3>Properties</h3>"))
self.root_layout.addWidget(self.cat_widget)
self.subtag_ids: set[int] = set()
self.parent_ids: set[int] = set()
self.alias_ids: list[int] = []
self.alias_names: list[str] = []
self.new_alias_names: dict = {}
@@ -240,26 +273,23 @@ class BuildTagPanel(PanelWidget):
if isinstance(focused_widget, CustomTableItem):
self.add_alias_callback()
def add_subtag_callback(self, tag_id: int):
logger.info("add_subtag_callback", tag_id=tag_id)
self.subtag_ids.add(tag_id)
self.set_subtags()
def add_parent_tag_callback(self, tag_id: int):
logger.info("add_parent_tag_callback", tag_id=tag_id)
self.parent_ids.add(tag_id)
self.set_parent_tags()
def remove_subtag_callback(self, tag_id: int):
logger.info("removing subtag", tag_id=tag_id)
self.subtag_ids.remove(tag_id)
self.set_subtags()
def remove_parent_tag_callback(self, tag_id: int):
logger.info("remove_parent_tag_callback", tag_id=tag_id)
self.parent_ids.remove(tag_id)
self.set_parent_tags()
def add_alias_callback(self):
logger.info("add_alias_callback")
id = self.new_item_id
self.alias_ids.append(id)
self.new_alias_names[id] = ""
self.new_item_id -= 1
self._set_aliases()
row = self.aliases_table.rowCount() - 1
@@ -272,25 +302,26 @@ class BuildTagPanel(PanelWidget):
self.alias_ids.remove(alias_id)
self._set_aliases()
def set_subtags(self):
while self.subtags_scroll_layout.itemAt(0):
self.subtags_scroll_layout.takeAt(0).widget().deleteLater()
def set_parent_tags(self):
while self.parent_tags_scroll_layout.itemAt(0):
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater()
last: QWidget = self.aliases_table.cellWidget(self.aliases_table.rowCount() - 1, 1)
c = QWidget()
layout = QVBoxLayout(c)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
for tag_id in self.subtag_ids:
for tag_id in self.parent_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))
tw.on_remove.connect(lambda t=tag_id: self.remove_parent_tag_callback(t))
layout.addWidget(tw)
self.setTabOrder(last, tw.bg_button)
last = tw.bg_button
self.subtags_scroll_layout.addWidget(c)
self.setTabOrder(last, self.name_field)
self.parent_tags_scroll_layout.addWidget(c)
def add_aliases(self):
names: set[str] = set()
for i in range(0, self.aliases_table.rowCount()):
@@ -361,22 +392,18 @@ class BuildTagPanel(PanelWidget):
self.new_alias_names[item.id] = item.text()
def set_tag(self, tag: Tag):
logger.info("[BuildTagPanel] Setting Tag", tag=tag)
self.tag = tag
logger.info("setting tag", tag=tag)
self.name_field.setText(tag.name)
self.shorthand_field.setText(tag.shorthand or "")
for alias_id in tag.alias_ids:
self.alias_ids.append(alias_id)
self._set_aliases()
for subtag in tag.subtag_ids:
self.subtag_ids.add(subtag)
self.set_subtags()
for parent_id in tag.parent_ids:
self.parent_ids.add(parent_id)
self.set_parent_tags()
# select item in self.color_field where the userData value matched tag.color
for i in range(self.color_field.count()):
@@ -384,6 +411,8 @@ class BuildTagPanel(PanelWidget):
self.color_field.setCurrentIndex(i)
break
self.cat_checkbox.setChecked(tag.is_category)
def on_name_changed(self):
is_empty = not self.name_field.text().strip()
@@ -398,14 +427,13 @@ class BuildTagPanel(PanelWidget):
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
tag.is_category = self.cat_checkbox.isChecked()
logger.info("built tag", tag=tag)
return tag

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -20,7 +20,6 @@ from PySide6.QtWidgets import (
)
from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from src.core.library import Library, Tag
from src.core.library.alchemy.fields import _FieldID
from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
from src.qt.translations import Translations
@@ -42,7 +41,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
branch = tree
for folder in items:
if folder not in branch.dirs:
# TODO - subtags
# TODO: Reimplement parent tags
new_tag = Tag(name=folder)
library.add_tag(new_tag)
branch.dirs[folder] = BranchData(tag=new_tag)
@@ -73,7 +72,7 @@ def folders_to_tags(library: Library):
tag = add_folders_to_tree(library, tree, folders).tag
if tag and not entry.has_tag(tag):
library.add_field_tag(entry, tag, _FieldID.TAGS.name, create_field=True)
library.add_tags_to_entry(entry.id, tag.id)
logger.info("Done")
@@ -82,11 +81,11 @@ def reverse_tag(library: Library, tag: Tag, items: list[Tag] | None) -> list[Tag
items = items or []
items.append(tag)
if not tag.subtag_ids:
if not tag.parent_ids:
items.reverse()
return items
for subtag_id in tag.subtag_ids:
for subtag_id in tag.parent_ids:
subtag = library.get_tag(subtag_id)
return reverse_tag(library, subtag, items)
@@ -127,11 +126,10 @@ def generate_preview_data(library: Library) -> BranchData:
branch = _add_folders_to_tree(folders)
if branch:
has_tag = False
for tag_field in entry.tag_box_fields:
for tag in tag_field.tags:
if tag.name == branch.tag.name:
has_tag = True
break
for tag in entry.tags:
if tag.name == branch.tag.name:
has_tag = True
break
if not has_tag:
branch.files.append(entry.path.name)

View File

@@ -65,14 +65,14 @@ class TagDatabasePanel(PanelWidget):
self.create_tag_button = QPushButton()
Translations.translate_qobject(self.create_tag_button, "tag.create")
self.create_tag_button.clicked.connect(self.build_tag)
self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))
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):
def build_tag(self, name: str):
panel = BuildTagPanel(self.lib)
self.modal = PanelModal(
panel,
@@ -80,12 +80,14 @@ class TagDatabasePanel(PanelWidget):
)
Translations.translate_with_setter(self.modal.setTitle, "tag.new")
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.add")
if name.strip():
panel.name_field.setText(name)
self.modal.saved.connect(
lambda: (
self.lib.add_tag(
tag=panel.build_tag(),
subtag_ids=panel.subtag_ids,
parent_ids=panel.parent_ids,
alias_names=panel.alias_names,
alias_ids=panel.alias_ids,
),
@@ -119,7 +121,7 @@ class TagDatabasePanel(PanelWidget):
row.setSpacing(3)
if tag.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
tag_widget = TagWidget(tag, has_edit=False, has_remove=False)
tag_widget = TagWidget(tag, has_edit=True, has_remove=False)
else:
tag_widget = TagWidget(tag, has_edit=True, has_remove=True)
@@ -149,9 +151,6 @@ class TagDatabasePanel(PanelWidget):
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(
@@ -167,7 +166,7 @@ class TagDatabasePanel(PanelWidget):
def edit_tag_callback(self, btp: BuildTagPanel):
self.lib.update_tag(
btp.build_tag(), set(btp.subtag_ids), set(btp.alias_names), set(btp.alias_ids)
btp.build_tag(), set(btp.parent_ids), set(btp.alias_names), set(btp.alias_ids)
)
self.update_tags(self.search_field.text())

View File

@@ -70,7 +70,7 @@ class TagSearchPanel(PanelWidget):
self.update_tags()
else:
self.search_field.setFocus()
self.parentWidget().hide()
self.parentWidget().hide()
def update_tags(self, query: str | None = None):
logger.info("[Tag Search Modal] Updating Tags")

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -15,8 +15,6 @@ import re
import sys
import time
import webbrowser
from collections.abc import Sequence
from itertools import zip_longest
from pathlib import Path
from queue import Queue
@@ -65,6 +63,7 @@ from src.core.library.alchemy.enums import (
FieldTypeEnum,
FilterState,
ItemType,
SortingModeEnum,
)
from src.core.library.alchemy.fields import _FieldID
from src.core.library.alchemy.library import Entry, LibraryStatus
@@ -92,6 +91,12 @@ from src.qt.widgets.preview_panel import PreviewPanel
from src.qt.widgets.progress import ProgressWidget
from src.qt.widgets.thumb_renderer import ThumbRenderer
BADGE_TAGS = {
BadgeType.FAVORITE: TAG_FAVORITE,
BadgeType.ARCHIVED: TAG_ARCHIVED,
}
# SIGQUIT is not defined on Windows
if sys.platform == "win32":
from signal import SIGINT, SIGTERM, signal
@@ -136,8 +141,8 @@ class QtDriver(DriverMixin, QObject):
self.lib = backend.Library()
self.rm: ResourceManager = ResourceManager()
self.args = args
self.frame_content = []
self.filter = FilterState.show_all()
self.frame_content: list[int] = [] # List of Entry IDs on the current page
self.pages_count = 0
self.scrollbar_pos = 0
@@ -151,9 +156,7 @@ class QtDriver(DriverMixin, QObject):
self.thumb_job_queue: Queue = Queue()
self.thumb_threads: list[Consumer] = []
self.thumb_cutoff: float = time.time()
# grid indexes of selected items
self.selected: list[int] = []
self.selected: list[int] = [] # Selected Entry IDs
self.SIGTERM.connect(self.handle_sigterm)
@@ -238,6 +241,19 @@ class QtDriver(DriverMixin, QObject):
splash_pixmap = QPixmap(":/images/splash.png")
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
splash_pixmap = splash_pixmap.scaledToWidth(
math.floor(
min(
(
QGuiApplication.primaryScreen().geometry().width()
* self.main_window.devicePixelRatio()
)
/ 4,
splash_pixmap.width(),
)
),
Qt.TransformationMode.SmoothTransformation,
)
self.splash = QSplashScreen(splash_pixmap, Qt.WindowType.WindowStaysOnTopHint)
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.splash.show()
@@ -281,6 +297,26 @@ class QtDriver(DriverMixin, QObject):
open_library_action.setToolTip("Ctrl+O")
file_menu.addAction(open_library_action)
self.open_recent_library_menu = QMenu(menu_bar)
Translations.translate_qobject(
self.open_recent_library_menu, "menu.file.open_recent_library"
)
file_menu.addMenu(self.open_recent_library_menu)
self.update_recent_lib_menu()
open_on_start_action = QAction(self)
Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
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)
file_menu.addSeparator()
save_library_backup_action = QAction(menu_bar)
Translations.translate_qobject(save_library_backup_action, "menu.file.save_backup")
save_library_backup_action.triggered.connect(
@@ -321,17 +357,6 @@ class QtDriver(DriverMixin, QObject):
file_menu.addAction(close_library_action)
file_menu.addSeparator()
open_on_start_action = QAction(self)
Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start")
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(menu_bar)
Translations.translate_qobject(new_tag_action, "menu.edit.new_tag")
@@ -387,13 +412,6 @@ class QtDriver(DriverMixin, QObject):
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),
)
)
view_menu.addAction(show_libs_list_action)
show_filenames_action = QAction(menu_bar)
Translations.translate_qobject(show_filenames_action, "settings.show_filenames_in_grid")
@@ -475,6 +493,17 @@ class QtDriver(DriverMixin, QObject):
self.main_window.searchField.textChanged.connect(self.update_completions_list)
self.preview_panel = PreviewPanel(self.lib, self)
self.preview_panel.fields.archived_updated.connect(
lambda hidden: self.update_badges(
{BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False
)
)
self.preview_panel.fields.favorite_updated.connect(
lambda hidden: self.update_badges(
{BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False
)
)
splitter = self.main_window.splitter
splitter.addWidget(self.preview_panel)
@@ -538,16 +567,40 @@ class QtDriver(DriverMixin, QObject):
search_button.clicked.connect(
lambda: self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
.with_sorting_mode(self.sorting_mode)
.with_sorting_direction(self.sorting_direction)
)
)
# Search Field
search_field: QLineEdit = self.main_window.searchField
search_field.returnPressed.connect(
# TODO - parse search field for filters
lambda: self.filter_items(
FilterState.from_search_query(self.main_window.searchField.text())
.with_sorting_mode(self.sorting_mode)
.with_sorting_direction(self.sorting_direction)
)
)
# Sorting Dropdowns
sort_mode_dropdown: QComboBox = self.main_window.sorting_mode_combobox
for sort_mode in SortingModeEnum:
sort_mode_dropdown.addItem(Translations[sort_mode.value], sort_mode)
sort_mode_dropdown.setCurrentIndex(
list(SortingModeEnum).index(self.filter.sorting_mode)
) # set according to self.filter
sort_mode_dropdown.currentIndexChanged.connect(self.sorting_mode_callback)
sort_dir_dropdown: QComboBox = self.main_window.sorting_direction_combobox
sort_dir_dropdown.addItem("Ascending", userData=True)
sort_dir_dropdown.addItem("Descending", userData=False)
Translations.translate_with_setter(
lambda text: sort_dir_dropdown.setItemText(0, text), "sorting.direction.ascending"
)
Translations.translate_with_setter(
lambda text: sort_dir_dropdown.setItemText(1, text), "sorting.direction.descending"
)
sort_dir_dropdown.setCurrentIndex(0) # Default: Ascending
sort_dir_dropdown.currentIndexChanged.connect(self.sorting_direction_callback)
# Thumbnail Size ComboBox
thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox
for size in self.thumb_sizes:
@@ -575,13 +628,6 @@ class QtDriver(DriverMixin, QObject):
self.splash.finish(self.main_window)
self.preview_panel.update_widgets()
def toggle_libs_list(self, value: bool):
if value:
self.preview_panel.libs_flow_container.show()
else:
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)
@@ -672,7 +718,7 @@ class QtDriver(DriverMixin, QObject):
lambda: (
self.lib.add_tag(
panel.build_tag(),
set(panel.subtag_ids),
set(panel.parent_ids),
set(panel.alias_names),
set(panel.alias_ids),
),
@@ -682,10 +728,12 @@ class QtDriver(DriverMixin, QObject):
self.modal.show()
def select_all_action_callback(self):
self.selected = list(range(0, len(self.frame_content)))
for grid_idx in self.selected:
self.item_thumbs[grid_idx].thumb_button.set_selected(True)
"""Set the selection to all visible items."""
self.selected.clear()
for item in self.item_thumbs:
if item.mode and item.item_id not in self.selected:
self.selected.append(item.item_id)
item.thumb_button.set_selected(True)
self.set_macro_menu_viability()
self.preview_panel.update_widgets()
@@ -801,29 +849,20 @@ class QtDriver(DriverMixin, QObject):
def new_file_macros_runnable(self, new_ids):
"""Threaded method that runs macros on a set of Entry IDs."""
# sleep(1)
# for i, id in enumerate(new_ids):
# # pb.setValue(i)
# # pb.setLabelText(f'Running Configured Macros on {i}/{len(new_ids)} New Entries')
# # self.run_macro('autofill', id)
# NOTE: I don't know. I don't know why it needs this. The whole program
# falls apart if this method doesn't run, and it DOESN'T DO ANYTHING
yield 0
# self.main_window.statusbar.showMessage('', 3)
# sleep(5)
# pb.deleteLater()
def run_macros(self, name: MacroID, grid_idx: list[int]):
def run_macros(self, name: MacroID, entry_ids: list[int]):
"""Run a specific Macro on a group of given entry_ids."""
for gid in grid_idx:
self.run_macro(name, gid)
for entry_id in entry_ids:
self.run_macro(name, entry_id)
def run_macro(self, name: MacroID, grid_idx: int):
def run_macro(self, name: MacroID, entry_id: int):
"""Run a specific Macro on an Entry given a Macro name."""
entry: Entry = self.frame_content[grid_idx]
entry: Entry = self.lib.get_entry(entry_id)
full_path = self.lib.library_dir / entry.path
source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower()
@@ -832,21 +871,21 @@ class QtDriver(DriverMixin, QObject):
source=source,
macro=name,
entry_id=entry.id,
grid_idx=grid_idx,
grid_idx=entry_id,
)
if name == MacroID.AUTOFILL:
for macro_id in MacroID:
if macro_id == MacroID.AUTOFILL:
continue
self.run_macro(macro_id, grid_idx)
self.run_macro(macro_id, entry_id)
elif name == MacroID.SIDECAR:
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(
self.lib.add_field_to_entry(
entry.id,
field_id=field_id,
value=value,
@@ -855,7 +894,7 @@ class QtDriver(DriverMixin, QObject):
elif name == MacroID.BUILD_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)
self.lib.add_field_to_entry(entry.id, field_id=_FieldID.SOURCE, value=url)
elif name == MacroID.MATCH:
TagStudioCore.match_conditions(self.lib, entry.id)
elif name == MacroID.CLEAN_URL:
@@ -867,6 +906,24 @@ class QtDriver(DriverMixin, QObject):
content=strip_web_protocol(field.value),
)
@property
def sorting_direction(self) -> bool:
"""Whether to Sort the results in ascending order."""
return self.main_window.sorting_direction_combobox.currentData()
def sorting_direction_callback(self):
logger.info("Sorting Direction Changed", ascending=self.sorting_direction)
self.filter_items()
@property
def sorting_mode(self) -> SortingModeEnum:
"""What to sort by."""
return self.main_window.sorting_mode_combobox.currentData()
def sorting_mode_callback(self):
logger.info("Sorting Mode Changed", mode=self.sorting_mode)
self.filter_items()
def thumb_size_callback(self, index: int):
"""Perform actions needed when the thumbnail size selection is changed.
@@ -923,6 +980,8 @@ class QtDriver(DriverMixin, QObject):
page_index = max(0, min(page_index, self.pages_count - 1))
self.filter.page_index = page_index
# TODO: Re-allow selecting entries across multiple pages at once.
# This works fine with additive selection but becomes a nightmare with bridging.
self.filter_items()
def remove_grid_item(self, grid_idx: int):
@@ -936,13 +995,12 @@ class QtDriver(DriverMixin, QObject):
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# TODO - init after library is loaded, it can have different page_size
for grid_idx in range(self.filter.page_size):
for _ in range(self.filter.page_size):
item_thumb = ItemThumb(
None,
self.lib,
self,
(self.thumb_size, self.thumb_size),
grid_idx,
bool(
self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool)
),
@@ -959,44 +1017,74 @@ class QtDriver(DriverMixin, QObject):
sa.setWidgetResizable(True)
sa.setWidget(self.flow_container)
def select_item(self, grid_index: int, append: bool, bridge: bool):
"""Select one or more items in the Thumbnail Grid."""
logger.info("selecting item", grid_index=grid_index, append=append, bridge=bridge)
def toggle_item_selection(self, item_id: int, append: bool, bridge: bool):
"""Toggle the selection of an item in the Thumbnail Grid.
If an item is not selected, this selects it. If an item is already selected, this will
deselect it as long as append and bridge are False.
Args:
item_id(int): The ID of the item/entry to select.
append(bool): Whether or not to add this item to the previous selection
or to restart the selection with this item.
Setting to True acts like "Ctrl + Click" selecting.
bridge(bool): Whether or not to select items in the visual range of the last item
selected and this current item.
Setting to True acts like "Shift + Click" selecting.
"""
logger.info("[QtDriver] Selecting Items:", item_id=item_id, append=append, bridge=bridge)
if append:
if grid_index not in self.selected:
self.selected.append(grid_index)
self.item_thumbs[grid_index].thumb_button.set_selected(True)
if item_id not in self.selected:
self.selected.append(item_id)
for it in self.item_thumbs:
if it.item_id == item_id:
it.thumb_button.set_selected(True)
else:
self.selected.remove(grid_index)
self.item_thumbs[grid_index].thumb_button.set_selected(False)
self.selected.remove(item_id)
for it in self.item_thumbs:
if it.item_id == item_id:
it.thumb_button.set_selected(False)
# TODO: Allow bridge selecting across pages.
elif bridge and self.selected:
select_from = min(self.selected)
select_to = max(self.selected)
last_index = -1
current_index = -1
try:
contents = self.frame_content
last_index = self.frame_content.index(self.selected[-1])
current_index = self.frame_content.index(item_id)
index_range: list = contents[
min(last_index, current_index) : max(last_index, current_index) + 1
]
if select_to < grid_index:
index_range = range(select_from, grid_index + 1)
else:
index_range = range(grid_index, select_to + 1)
# Preserve bridge direction for correct appending order.
if last_index < current_index:
index_range.reverse()
for entry_id in index_range:
for it in self.item_thumbs:
if it.item_id == entry_id:
it.thumb_button.set_selected(True)
if entry_id not in self.selected:
self.selected.append(entry_id)
except Exception as e:
# TODO: Allow bridge selecting across pages.
logger.error(
"[QtDriver] Previous selected item not on current page!",
error=e,
item_id=item_id,
current_index=current_index,
last_index=last_index,
)
self.selected = list(index_range)
for selected_idx in self.selected:
self.item_thumbs[selected_idx].thumb_button.set_selected(True)
else:
self.selected = [grid_index]
for thumb_idx, item_thumb in enumerate(self.item_thumbs):
item_matched = thumb_idx == grid_index
item_thumb.thumb_button.set_selected(item_matched)
# NOTE: By using the preview panel's "set_tags_updated_slot" method,
# only the last of multiple identical item selections are connected.
# If attaching the slot to multiple duplicate selections is needed,
# just bypass the method and manually disconnect and connect the slots.
if len(self.selected) == 1:
for it in self.item_thumbs:
if it.item_id == id:
self.preview_panel.set_tags_updated_slot(it.refresh_badge)
self.selected.clear()
self.selected.append(item_id)
for it in self.item_thumbs:
if it.item_id in self.selected:
it.thumb_button.set_selected(True)
else:
it.thumb_button.set_selected(False)
self.set_macro_menu_viability()
self.preview_panel.update_widgets()
@@ -1093,18 +1181,26 @@ class QtDriver(DriverMixin, QObject):
self.main_window.update()
is_grid_thumb = True
# Show loading placeholder icons
for entry, item_thumb in zip_longest(self.frame_content, self.item_thumbs):
if not entry:
logger.info("[QtDriver] Loading Entries...")
# TODO: The full entries with joins don't need to be grabbed here.
# Use a method that only selects the frame content but doesn't include the joins.
entries: list[Entry] = list(self.lib.get_entries_full(self.frame_content))
logger.info("[QtDriver] Building Filenames...")
filenames: list[Path] = [self.lib.library_dir / e.path for e in entries]
logger.info("[QtDriver] Done! Processing ItemThumbs...")
for index, item_thumb in enumerate(self.item_thumbs, start=0):
entry = None
try:
entry = entries[index]
except IndexError:
item_thumb.hide()
continue
if not entry:
continue
item_thumb.set_mode(ItemType.ENTRY)
item_thumb.set_item_id(entry)
# TODO - show after item is rendered
item_thumb.set_item_id(entry.id)
item_thumb.show()
is_loading = True
self.thumb_job_queue.put(
(
@@ -1114,29 +1210,29 @@ class QtDriver(DriverMixin, QObject):
)
# Show rendered thumbnails
for idx, (entry, item_thumb) in enumerate(
zip_longest(self.frame_content, self.item_thumbs)
):
for index, item_thumb in enumerate(self.item_thumbs, start=0):
entry = None
try:
entry = entries[index]
except IndexError:
item_thumb.hide()
continue
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),
(time.time(), filenames[index], base_size, ratio, is_loading, is_grid_thumb),
)
)
entry_tag_ids = {tag.id for tag in entry.tags}
item_thumb.assign_badge(BadgeType.ARCHIVED, TAG_ARCHIVED in entry_tag_ids)
item_thumb.assign_badge(BadgeType.FAVORITE, TAG_FAVORITE in entry_tag_ids)
item_thumb.assign_badge(BadgeType.ARCHIVED, entry.is_archived)
item_thumb.assign_badge(BadgeType.FAVORITE, entry.is_favorite)
item_thumb.update_clickable(
clickable=(
lambda checked=False, index=idx: self.select_item(
index,
lambda checked=False, item_id=entry.id: self.toggle_item_selection(
item_id,
append=(
QGuiApplication.keyboardModifiers()
== Qt.KeyboardModifier.ControlModifier
@@ -1149,27 +1245,28 @@ class QtDriver(DriverMixin, QObject):
)
# Restore Selected Borders
is_selected = (item_thumb.mode, item_thumb.item_id) in self.selected
is_selected = item_thumb.item_id in self.selected
item_thumb.thumb_button.set_selected(is_selected)
self.thumb_job_queue.put(
(
item_thumb.renderer.render,
(time.time(), filepath, base_size, ratio, False, True),
)
)
def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add_tags=True):
"""Update the tag badges for item_thumbs.
def update_badges(self, grid_item_ids: Sequence[int] = None):
if not grid_item_ids:
# no items passed, update all items in grid
grid_item_ids = range(min(len(self.item_thumbs), len(self.frame_content)))
Args:
badge_values(dict[BadgeType, bool]): The BadgeType and associated viability state.
origin_id(int): The ID of the item_thumb calling this method. If the ID is found as a
part of the current selection, or if the ID is 0, the the entire current selection
will be updated. Otherwise, only item_thumbs with that ID will be updated.
add_tags(bool): Flag determining if tags associated with the badges need to be added to
the items. Defaults to True.
"""
item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id]
logger.info("updating badges for items", grid_item_ids=grid_item_ids)
for grid_idx in grid_item_ids:
# get the entry from grid to avoid loading from db again
entry = self.frame_content[grid_idx]
self.item_thumbs[grid_idx].refresh_badge(entry)
for it in self.item_thumbs:
if it.item_id in item_ids:
for badge_type, value in badge_values.items():
if add_tags:
it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type])
it.assign_badge(badge_type, value)
def filter_items(self, filter: FilterState | None = None) -> None:
if not self.lib.library_dir:
@@ -1179,19 +1276,18 @@ class QtDriver(DriverMixin, QObject):
if filter:
self.filter = dataclasses.replace(self.filter, **dataclasses.asdict(filter))
else:
self.filter.sorting_mode = self.sorting_mode
self.filter.ascending = self.sorting_direction
# inform user about running search
self.main_window.statusbar.showMessage(Translations["status.library_search_query"])
self.main_window.statusbar.repaint()
# search the library
start_time = time.time()
results = self.lib.search_library(self.filter)
logger.info("items to render", count=len(results))
end_time = time.time()
# inform user about completed search
@@ -1204,7 +1300,7 @@ class QtDriver(DriverMixin, QObject):
)
# update page content
self.frame_content = results.items
self.frame_content = [item.id for item in results.items]
self.update_thumbs()
# update pagination
@@ -1244,6 +1340,63 @@ class QtDriver(DriverMixin, QObject):
self.settings.endGroup()
self.settings.sync()
self.update_recent_lib_menu()
def update_recent_lib_menu(self):
"""Updates the recent library menu from the latest values from the settings file."""
actions: list[QAction] = []
lib_items: dict[str, tuple[str, str]] = {}
settings = self.settings
settings.beginGroup(SettingItems.LIBS_LIST)
for item_tstamp in settings.allKeys():
val = str(settings.value(item_tstamp, type=str))
cut_val = val
if len(val) > 45:
cut_val = f"{val[0:10]} ... {val[-10:]}"
lib_items[item_tstamp] = (val, cut_val)
# Sort lib_items by the key
libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True)
settings.endGroup()
# Create actions for each library
for library_key in libs_sorted:
path = Path(library_key[1][0])
action = QAction(self.open_recent_library_menu)
action.setText(str(path))
action.triggered.connect(lambda checked=False, p=path: self.open_library(p))
actions.append(action)
clear_recent_action = QAction(self.open_recent_library_menu)
Translations.translate_qobject(clear_recent_action, "menu.file.clear_recent_libraries")
clear_recent_action.triggered.connect(self.clear_recent_libs)
actions.append(clear_recent_action)
# Clear previous actions
for action in self.open_recent_library_menu.actions():
self.open_recent_library_menu.removeAction(action)
# Add new actions
for action in actions:
self.open_recent_library_menu.addAction(action)
# Only enable add "clear recent" if there are still recent libraries.
if len(actions) > 1:
self.open_recent_library_menu.setDisabled(False)
self.open_recent_library_menu.addSeparator()
self.open_recent_library_menu.addAction(clear_recent_action)
else:
self.open_recent_library_menu.setDisabled(True)
def clear_recent_libs(self):
"""Clear the list of recent libraries from the settings file."""
settings = self.settings
settings.beginGroup(SettingItems.LIBS_LIST)
self.settings.remove("")
self.settings.endGroup()
self.settings.sync()
self.update_recent_lib_menu()
def open_library(self, path: Path) -> None:
"""Open a TagStudio library."""
@@ -1256,7 +1409,12 @@ class QtDriver(DriverMixin, QObject):
)
self.main_window.repaint()
open_status: LibraryStatus = self.lib.open_library(path)
open_status: LibraryStatus = None
try:
open_status = self.lib.open_library(path)
except Exception as e:
logger.exception(e)
open_status = LibraryStatus(success=False, library_path=path, message=type(e).__name__)
# Migration is required
if open_status.json_migration_req:

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -14,7 +14,6 @@ from PySide6.QtCore import (
Signal,
)
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
@@ -43,27 +42,7 @@ class CollageIconRenderer(QObject):
try:
if data_tint_mode or data_only_mode:
if entry.fields:
has_any_tags: bool = False
has_content_tags: bool = False
has_meta_tags: bool = False
for field in entry.tag_box_fields:
if field.tags:
has_any_tags = True
if field.type_key == _FieldID.TAGS_CONTENT.name:
has_content_tags = True
elif field.type_key == _FieldID.TAGS_META.name:
has_meta_tags = True
if has_content_tags and has_meta_tags:
color = "#28bb48" # Green
elif has_any_tags:
color = "#ffd63d" # Yellow
# color = '#95e345' # Yellow-Green
else:
# color = '#fa9a2c' # Yellow-Orange
color = "#ed8022" # Orange
else:
color = "#e22c3c" # Red
color = "#28bb48" if entry.tags else "#e22c3c"
if data_only_mode:
pic = Image.new("RGB", size, color)

View File

@@ -1,18 +1,18 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
from pathlib import Path
from types import MethodType
from typing import Callable, Optional
from typing import Callable
from warnings import catch_warnings
from PIL import Image, ImageQt
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QEnterEvent, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.enums import Theme
class FieldContainer(QWidget):
@@ -32,6 +32,19 @@ class FieldContainer(QWidget):
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
trash_icon_128.load()
# TODO: There should be a global button theme somewhere.
container_style = (
f"QWidget#fieldContainer{{"
"border-radius:4px;"
f"}}"
f"QWidget#fieldContainer::hover{{"
f"background-color:{Theme.COLOR_HOVER.value};"
f"}}"
f"QWidget#fieldContainer::pressed{{"
f"background-color:{Theme.COLOR_PRESSED.value};"
f"}}"
)
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
self.setObjectName("fieldContainer")
@@ -48,12 +61,12 @@ class FieldContainer(QWidget):
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.inner_layout.setContentsMargins(6, 0, 6, 6)
self.inner_layout.setSpacing(0)
self.inner_container = QWidget()
self.inner_container.setObjectName("innerContainer")
self.inner_container.setLayout(self.inner_layout)
self.root_layout.addWidget(self.inner_container)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
self.field_container.setLayout(self.inner_layout)
self.root_layout.addWidget(self.field_container)
self.title_container = QWidget()
self.title_layout = QHBoxLayout(self.title_container)
@@ -67,12 +80,12 @@ class FieldContainer(QWidget):
self.title_widget.setMinimumHeight(button_size)
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
self.title_widget.setText(title)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
self.copy_button = QPushButtonWrapper()
self.copy_button = QPushButton()
self.copy_button.setObjectName("copyButton")
self.copy_button.setMinimumSize(button_size, button_size)
self.copy_button.setMaximumSize(button_size, button_size)
self.copy_button.setFlat(True)
@@ -81,7 +94,8 @@ class FieldContainer(QWidget):
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
self.edit_button = QPushButtonWrapper()
self.edit_button = QPushButton()
self.edit_button.setObjectName("editButton")
self.edit_button.setMinimumSize(button_size, button_size)
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
@@ -90,7 +104,8 @@ class FieldContainer(QWidget):
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
self.remove_button = QPushButtonWrapper()
self.remove_button = QPushButton()
self.remove_button.setObjectName("removeButton")
self.remove_button.setMinimumSize(button_size, button_size)
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
@@ -99,37 +114,39 @@ class FieldContainer(QWidget):
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
self.field = QWidget()
self.field.setObjectName("field")
self.field_layout = QHBoxLayout()
self.field_layout.setObjectName("fieldLayout")
self.field_layout.setContentsMargins(0, 0, 0, 0)
self.field_container.setLayout(self.field_layout)
self.inner_layout.addWidget(self.field_container)
self.field.setLayout(self.field_layout)
self.inner_layout.addWidget(self.field)
def set_copy_callback(self, callback: Optional[MethodType]):
if self.copy_button.is_connected:
self.setStyleSheet(FieldContainer.container_style)
def set_copy_callback(self, callback: Callable | None = None):
with catch_warnings(record=True):
self.copy_button.clicked.disconnect()
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
self.copy_button.is_connected = callable(callback)
if callback:
self.copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Callable):
if self.edit_button.is_connected:
def set_edit_callback(self, callback: Callable | None = None):
with catch_warnings(record=True):
self.edit_button.clicked.disconnect()
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
self.edit_button.is_connected = callable(callback)
if callback:
self.edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Callable):
if self.remove_button.is_connected:
def set_remove_callback(self, callback: Callable | None = None):
with catch_warnings(record=True):
self.remove_button.clicked.disconnect()
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
self.remove_button.is_connected = callable(callback)
if callback:
self.remove_button.clicked.connect(callback)
def set_inner_widget(self, widget: "FieldWidget"):
if self.field_layout.itemAt(0):
@@ -145,8 +162,8 @@ class FieldContainer(QWidget):
return None
def set_title(self, title: str):
self.title = title
self.title_widget.setText(title)
self.title = self.title = f"<h4>{title}</h4>"
self.title_widget.setText(self.title)
def set_inline(self, inline: bool):
self.inline = inline

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import time
@@ -7,6 +7,7 @@ from enum import Enum
from functools import wraps
from pathlib import Path
from typing import TYPE_CHECKING
from warnings import catch_warnings
import structlog
from PIL import Image, ImageQt
@@ -24,8 +25,7 @@ from src.core.constants import (
TAG_ARCHIVED,
TAG_FAVORITE,
)
from src.core.library import Entry, ItemType, Library
from src.core.library.alchemy.fields import _FieldID
from src.core.library import ItemType, Library
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
@@ -119,11 +119,9 @@ class ItemThumb(FlowWidget):
library: Library,
driver: "QtDriver",
thumb_size: tuple[int, int],
grid_idx: int,
show_filename_label: bool = False,
):
super().__init__()
self.grid_idx = grid_idx
self.lib = library
self.mode: ItemType = mode
self.driver = driver
@@ -206,10 +204,10 @@ class ItemThumb(FlowWidget):
self.thumb_button = ThumbButton(self.thumb_container, thumb_size)
self.renderer = ThumbRenderer()
self.renderer.updated.connect(
lambda ts, i, s, fn, ext: (
self.update_thumb(ts, image=i),
self.update_size(ts, size=s),
self.set_filename_text(fn),
lambda timestamp, image, size, filename, ext: (
self.update_thumb(timestamp, image=image),
self.update_size(timestamp, size=size),
self.set_filename_text(filename),
self.set_extension(ext),
)
)
@@ -325,7 +323,7 @@ class ItemThumb(FlowWidget):
return self.badge_active[BadgeType.FAVORITE]
@property
def is_archived(self):
def is_archived(self) -> bool:
return self.badge_active[BadgeType.ARCHIVED]
def set_mode(self, mode: ItemType | None) -> None:
@@ -399,8 +397,9 @@ 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_text(self, filename: Path | None):
self.set_item_path(filename)
self.file_label.setText(str(filename.name))
def set_filename_visibility(self, set_visible: bool):
"""Toggle the visibility of the filename label.
@@ -439,30 +438,17 @@ 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.pressed.disconnect()
if clickable:
with catch_warnings(record=True):
self.thumb_button.pressed.disconnect()
self.thumb_button.pressed.connect(clickable)
self.thumb_button.is_connected = True
def refresh_badge(self, entry: Entry | None = None):
if not entry:
if not self.item_id:
logger.error("missing both entry and item_id")
return None
def set_item_id(self, item_id: int):
self.item_id = item_id
entry = self.lib.get_entry(self.item_id)
if not entry:
logger.error("Entry not found", item_id=self.item_id)
return
self.assign_badge(BadgeType.ARCHIVED, entry.is_archived)
self.assign_badge(BadgeType.FAVORITE, entry.is_favorited)
def set_item_id(self, entry: Entry):
filepath = self.lib.library_dir / entry.path
self.opener.set_filepath(filepath)
self.item_id = entry.id
def set_item_path(self, path: Path | str | None):
"""Set the absolute filepath for the item. Used for locating on disk."""
self.opener.set_filepath(path)
def assign_badge(self, badge_type: BadgeType, value: bool) -> None:
mode = self.mode
@@ -496,47 +482,22 @@ class ItemThumb(FlowWidget):
return
toggle_value = self.badges[badge_type].isChecked()
self.badge_active[badge_type] = toggle_value
tag_id = BADGE_TAGS[badge_type]
# check if current item is selected. if so, update all selected items
if self.grid_idx in self.driver.selected:
update_items = self.driver.selected
else:
update_items = [self.grid_idx]
for idx in update_items:
entry = self.driver.frame_content[idx]
self.toggle_item_tag(
entry, toggle_value, tag_id, _FieldID.TAGS_META.name, create_field=True
)
# update the entry
self.driver.frame_content[idx] = self.lib.get_entry_full(entry.id)
self.driver.update_badges(update_items)
badge_values: dict[BadgeType, bool] = {badge_type: toggle_value}
self.driver.update_badges(badge_values, self.item_id)
def toggle_item_tag(
self,
entry: Entry,
entry_id: int,
toggle_value: bool,
tag_id: int,
field_key: str,
create_field: bool = False,
):
logger.info(
"toggle_item_tag",
entry_id=entry.id,
toggle_value=toggle_value,
tag_id=tag_id,
field_key=field_key,
)
logger.info("toggle_item_tag", entry_id=entry_id, toggle_value=toggle_value, tag_id=tag_id)
tag = self.lib.get_tag(tag_id)
if toggle_value:
self.lib.add_field_tag(entry, tag, field_key, create_field)
self.lib.add_tags_to_entry(entry_id, tag_id)
else:
self.lib.remove_field_tag(entry, tag.id, field_key)
self.lib.remove_tags_from_entry(entry_id, tag_id)
if self.driver.preview_panel.is_open:
self.driver.preview_panel.update_widgets()
@@ -549,13 +510,13 @@ class ItemThumb(FlowWidget):
paths = []
mimedata = QMimeData()
selected_idxs = self.driver.selected
if self.grid_idx not in selected_idxs:
selected_idxs = [self.grid_idx]
selected_ids = self.driver.selected
if self.item_id not in selected_ids:
selected_ids = [self.item_id]
for grid_idx in selected_idxs:
id = self.driver.item_thumbs[grid_idx].item_id
entry = self.lib.get_entry(id)
for selected_id in selected_ids:
item_id = self.driver.item_thumbs[selected_id].item_id
entry = self.lib.get_entry(item_id)
if not entry:
continue
@@ -565,4 +526,4 @@ class ItemThumb(FlowWidget):
mimedata.setUrls(paths)
drag.setMimeData(mimedata)
drag.exec(Qt.DropAction.CopyAction)
logger.info("dragged files to external program", thumbnail_indexs=selected_idxs)
logger.info("dragged files to external program", thumbnail_indexs=selected_ids)

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -17,16 +17,17 @@ from PySide6.QtWidgets import (
QVBoxLayout,
QWidget,
)
from sqlalchemy import and_, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from src.core.constants import TS_FOLDER_NAME
from src.core.constants import LEGACY_TAG_FIELD_IDS, 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.enums import TagColor
from src.core.library.alchemy.joins import TagParent
from src.core.library.alchemy.library import TAG_ARCHIVED, TAG_FAVORITE, TAG_META
from src.core.library.alchemy.library import Library as SqliteLibrary
from src.core.library.alchemy.models import Entry, Tag, TagAlias
from src.core.library.alchemy.models import Entry, TagAlias
from src.core.library.json.library import Library as JsonLibrary # type: ignore
from src.core.library.json.library import Tag as JsonTag # 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
@@ -115,7 +116,7 @@ class JsonMigrationModal(QObject):
entries_text: str = Translations["json_migration.heading.entires"]
tags_text: str = Translations["json_migration.heading.tags"]
shorthand_text: str = tab + Translations["json_migration.heading.shorthands"]
subtags_text: str = tab + Translations["json_migration.heading.parent_tags"]
parent_tags_text: str = tab + Translations["json_migration.heading.parent_tags"]
aliases_text: str = tab + Translations["json_migration.heading.aliases"]
colors_text: str = tab + Translations["json_migration.heading.colors"]
ext_text: str = Translations["json_migration.heading.file_extension_list"]
@@ -129,7 +130,7 @@ class JsonMigrationModal(QObject):
self.fields_row: int = 2
self.tags_row: int = 3
self.shorthands_row: int = 4
self.subtags_row: int = 5
self.parent_tags_row: int = 5
self.aliases_row: int = 6
self.colors_row: int = 7
self.ext_row: int = 8
@@ -151,7 +152,7 @@ class JsonMigrationModal(QObject):
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(parent_tags_text), self.parent_tags_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)
@@ -183,7 +184,7 @@ class JsonMigrationModal(QObject):
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_subtag_value, self.parent_tags_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)
@@ -192,7 +193,7 @@ class JsonMigrationModal(QObject):
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.parent_tags_row, 2)
self.old_content_layout.addWidget(QLabel(), self.aliases_row, 2)
self.old_content_layout.addWidget(QLabel(), self.colors_row, 2)
@@ -214,7 +215,7 @@ class JsonMigrationModal(QObject):
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(parent_tags_text), self.parent_tags_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)
@@ -246,7 +247,7 @@ class JsonMigrationModal(QObject):
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(subtag_parity_value, self.parent_tags_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)
@@ -257,7 +258,7 @@ class JsonMigrationModal(QObject):
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.parent_tags_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)
@@ -283,7 +284,6 @@ class JsonMigrationModal(QObject):
Translations.translate_qobject(start_button, "json_migration.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()
Translations.translate_qobject(finish_button, "json_migration.finish_migration")
@@ -311,6 +311,7 @@ class JsonMigrationModal(QObject):
# Open the JSON Library
self.json_lib = JsonLibrary()
self.json_lib.open_library(self.path)
self.update_json_builtins()
# Update JSON UI
self.update_json_entry_count(len(self.json_lib.entries))
@@ -321,6 +322,26 @@ class JsonMigrationModal(QObject):
self.migration_progress(skip_ui=skip_ui)
self.is_migration_initialized = True
def update_json_builtins(self):
"""Updates the built-in JSON values to include any future changes or additions.
Used to preserve user-modified built-in tags and to
match values between JSON and SQL during parity checking.
"""
# v9.5.0: Add "Meta Tags" tag and parent that to "Archived" and "Favorite".
meta_tags: JsonTag = JsonTag(TAG_META, "Meta Tags", "", ["Meta", "Meta Tag"], [], "")
# self.json_lib.add_tag_to_library(meta_tags)
self.json_lib.tags.append(meta_tags)
self.json_lib._map_tag_id_to_index(meta_tags, len(self.json_lib.tags) - 1)
archived_tag: JsonTag = self.json_lib.get_tag(TAG_ARCHIVED)
archived_tag.subtag_ids.append(TAG_META)
self.json_lib.update_tag(archived_tag)
favorite_tag: JsonTag = self.json_lib.get_tag(TAG_FAVORITE)
favorite_tag.subtag_ids.append(TAG_META)
self.json_lib.update_tag(favorite_tag)
def migration_progress(self, skip_ui: bool = False):
"""Initialize the progress bar and iterator for the library migration."""
pb = QProgressDialog(
@@ -350,6 +371,8 @@ class JsonMigrationModal(QObject):
self.update_sql_value_ui(show_msg_box=not skip_ui),
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
self.stack[1].buttons[4].setDisabled(False), # type: ignore
)
)
QThreadPool.globalInstance().start(r)
@@ -367,9 +390,7 @@ class JsonMigrationModal(QObject):
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
)
self.sql_lib.open_sqlite_library(self.json_lib.library_dir, is_new=True)
yield Translations.translate_formatted(
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
)
@@ -382,15 +403,19 @@ class JsonMigrationModal(QObject):
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 Translations["json_migration.migration_complete"]
else:
yield Translations["json_migration.migration_complete_with_discrepancies"]
self.update_parity_ui()
QApplication.beep()
QApplication.alert(self.paged_panel)
self.done = True
except Exception as e:
yield f"Error: {type(e).__name__}"
QApplication.beep()
QApplication.alert(self.paged_panel)
self.done = True
def update_parity_ui(self):
@@ -398,7 +423,7 @@ class JsonMigrationModal(QObject):
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.parent_tags_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()
@@ -429,7 +454,7 @@ class JsonMigrationModal(QObject):
if self.discrepancies:
logger.warning("Discrepancies found:")
logger.warning("\n".join(self.discrepancies))
QApplication.beep()
QApplication.alert(self.paged_panel)
if not show_msg_box:
return
msg_box = QMessageBox()
@@ -498,28 +523,10 @@ class JsonMigrationModal(QObject):
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."""
"""Check if all JSON field and tag data matches the new SQL 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_field(entry: Entry, value, type, type_key):
return value if value else None
def sanitize_json_field(value):
if isinstance(value, list):
@@ -527,107 +534,68 @@ class JsonMigrationModal(QObject):
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] = []
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)
sql_entry: Entry = self.sql_lib.get_entry_full(json_entry.id + 1)
if not sql_entry:
logger.info(
"[Field Comparison]",
message=f"NEW (SQL): SQL Entry ID mismatch: {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
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:
for sf in sql_entry.fields:
if sf.type.type.value not in LEGACY_TAG_FIELD_IDS:
sql_fields.append(
(
sql_entry.id,
sf.type.key,
sanitize_field(session, sql_entry, sf.value, sf.type.type, sf.type_key),
sanitize_field(sql_entry, sf.value, sf.type.type, sf.type_key),
)
)
sql_fields.sort()
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])
# NOTE: The JSON database stored tags inside of special "tag field" types which
# no longer exist. The SQL database instead associates tags directly with entries.
tags_count: int = 0
json_tags: set[int] = set()
for jf in json_entry.fields:
int_key: int = list(jf.keys())[0]
value = sanitize_json_field(list(jf.values())[0])
if int_key in LEGACY_TAG_FIELD_IDS:
tags_count += 1
json_tags = json_tags.union(value or [])
else:
key: str = self.sql_lib.get_field_name_from_id(int_key).name
json_fields.append((json_entry.id + 1, key, value))
json_fields.sort()
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))
sql_tags = {t.id for t in sql_entry.tags}
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)]),
if not (
json_fields is not None
and sql_fields is not None
and (json_fields == sql_fields)
and (json_tags == sql_tags)
):
self.discrepancies.append(
f"[Field Comparison]:\n"
f"OLD (JSON):{json_fields}\n{json_tags}\n"
f"NEW (SQL):{sql_fields}\n{sql_tags}"
)
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
@@ -643,35 +611,36 @@ class JsonMigrationModal(QObject):
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
"""Check if all JSON parent tags match the new SQL parent tags."""
sql_parent_tags: set[int] = None
json_parent_tags: 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))
sql_parent_tags = set(
session.scalars(select(TagParent.child_id).where(TagParent.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])
)
json_parent_tags = set(self.json_lib.get_tag(tag_id).subtag_ids)
json_parent_tags.discard(tag_id)
logger.info(
"[Subtag Parity]",
tag_id=tag_id,
json_subtags=json_subtags,
sql_subtags=sql_subtags,
json_parent_tags=json_parent_tags,
sql_parent_tags=sql_parent_tags,
)
if not (
sql_subtags is not None
and json_subtags is not None
and (sql_subtags == json_subtags)
sql_parent_tags is not None
and json_parent_tags is not None
and (sql_parent_tags == json_parent_tags)
):
self.discrepancies.append(
f"[Subtag Parity]:\nOLD (JSON):{json_subtags}\nNEW (SQL):{sql_subtags}"
f"[Subtag Parity][Tag ID: {tag_id}]:"
f"\nOLD (JSON):{json_parent_tags}\nNEW (SQL):{sql_parent_tags}"
)
self.subtag_parity = False
return self.subtag_parity
@@ -707,7 +676,8 @@ class JsonMigrationModal(QObject):
and (sql_aliases == json_aliases)
):
self.discrepancies.append(
f"[Alias Parity]:\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}"
f"[Alias Parity][Tag ID: {tag_id}]:"
f"\nOLD (JSON):{json_aliases}\nNEW (SQL):{sql_aliases}"
)
self.alias_parity = False
return self.alias_parity
@@ -720,10 +690,14 @@ class JsonMigrationModal(QObject):
sql_shorthand: str = None
json_shorthand: str = None
def sanitize(value):
"""Return value or convert a "not" value into None."""
return value if value else 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
sql_shorthand = sanitize(tag.shorthand)
json_shorthand = sanitize(self.json_lib.get_tag(tag_id).shorthand)
logger.info(
"[Shorthand Parity]",
@@ -732,13 +706,10 @@ class JsonMigrationModal(QObject):
sql_shorthand=sql_shorthand,
)
if not (
sql_shorthand is not None
and json_shorthand is not None
and (sql_shorthand == json_shorthand)
):
if sql_shorthand != json_shorthand:
self.discrepancies.append(
f"[Shorthand Parity]:\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}"
f"[Shorthand Parity][Tag ID: {tag_id}]:"
f"\nOLD (JSON):{json_shorthand}\nNEW (SQL):{sql_shorthand}"
)
self.shorthand_parity = False
return self.shorthand_parity
@@ -756,7 +727,7 @@ class JsonMigrationModal(QObject):
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 != ""
if (self.json_lib.get_tag(tag_id).color) != ""
else TagColor.DEFAULT.name
)
@@ -769,7 +740,8 @@ class JsonMigrationModal(QObject):
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}"
f"[Color Parity][Tag ID: {tag_id}]:"
f"\nOLD (JSON):{json_color}\nNEW (SQL):{sql_color}"
)
self.color_parity = False
return self.color_parity

View File

@@ -0,0 +1,520 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import sys
import typing
from collections.abc import Callable
from datetime import datetime as dt
from warnings import catch_warnings
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QMessageBox,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from src.core.constants import (
TAG_ARCHIVED,
TAG_FAVORITE,
)
from src.core.enums import Theme
from src.core.library.alchemy.fields import (
BaseField,
DatetimeField,
FieldTypeEnum,
TextField,
)
from src.core.library.alchemy.library import Library
from src.core.library.alchemy.models import Entry, Tag
from src.qt.translations import Translations
from src.qt.widgets.fields import FieldContainer
from src.qt.widgets.panel import PanelModal
from src.qt.widgets.tag_box import TagBoxWidget
from src.qt.widgets.text import TextWidget
from src.qt.widgets.text_box_edit import EditTextBox
from src.qt.widgets.text_line_edit import EditTextLine
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class FieldContainers(QWidget):
"""The Preview Panel Widget."""
favorite_updated = Signal(bool)
archived_updated = Signal(bool)
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver: QtDriver = driver
self.initialized = False
self.is_open: bool = False
self.common_fields: list = []
self.mixed_fields: list = []
self.cached_entries: list[Entry] = []
self.containers: list[FieldContainer] = []
self.panel_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_BG_LIGHT.value
)
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(3, 3, 3, 3)
self.scroll_layout.setSpacing(0)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName("entryScrollContainer")
scroll_container.setLayout(self.scroll_layout)
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0, 0, 0, 0)
info_layout.setSpacing(0)
self.scroll_area = QScrollArea()
self.scroll_area.setObjectName("entryScrollArea")
self.scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# NOTE: I would rather have this style applied to the scroll_area
# background and NOT the scroll container background, so that the
# rounded corners are maintained when scrolling. I was unable to
# find the right trick to only select that particular element.
self.scroll_area.setStyleSheet(
"QWidget#entryScrollContainer{"
f"background:{self.panel_bg_color};"
"border-radius:6px;"
"}"
)
self.scroll_area.setWidget(scroll_container)
root_layout = QHBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.addWidget(self.scroll_area)
def update_from_entry(self, entry_id: int, update_badges: bool = True):
"""Update tags and fields from a single Entry source."""
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
self.cached_entries = [self.lib.get_entry_full(entry_id)]
entry_ = self.cached_entries[0]
container_len: int = len(entry_.fields)
container_index = 0
# Write tag container(s)
if entry_.tags:
categories = self.get_tag_categories(entry_.tags)
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
self.write_tag_container(
container_index, tags=tags, category_tag=cat, is_mixed=False
)
container_index += 1
container_len += 1
if update_badges:
self.emit_badge_signals({t.id for t in entry_.tags})
# Write field container(s)
for index, field in enumerate(entry_.fields, start=container_index):
self.write_container(index, field, is_mixed=False)
# Hide leftover container(s)
if len(self.containers) > container_len:
for i, c in enumerate(self.containers):
if i > (container_len - 1):
c.setHidden(True)
def hide_containers(self):
"""Hide all field and tag containers."""
for c in self.containers:
c.setHidden(True)
def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]:
"""Get a dictionary of category tags mapped to their respective tags."""
cats: dict[Tag | None, set[Tag]] = {}
cats[None] = set()
base_tag_ids: set[int] = {x.id for x in tags}
exhausted: set[int] = set()
cluster_map: dict[int, set[int]] = {}
def add_to_cluster(tag_id: int, p_ids: list[int] | None = None):
"""Maps a Tag's child tags' IDs back to it's parent tag's ID.
Example:
Tag: ["Johnny Bravo", Parent Tags: "Cartoon Network (TV)", "Character"] maps to:
"Cartoon Network" -> Johnny Bravo,
"Character" -> "Johnny Bravo",
"TV" -> Johnny Bravo"
"""
tag_obj = self.lib.get_tag(tag_id) # Get full object
if p_ids is None:
p_ids = tag_obj.parent_ids
for p_id in p_ids:
if cluster_map.get(p_id) is None:
cluster_map[p_id] = set()
# If the p_tag has p_tags of its own, recursively link those to the original Tag.
if tag_id not in cluster_map[p_id]:
cluster_map[p_id].add(tag_id)
p_tag = self.lib.get_tag(p_id) # Get full object
if p_tag.parent_ids:
add_to_cluster(
tag_id,
[sub_id for sub_id in p_tag.parent_ids if sub_id != tag_id],
)
exhausted.add(p_id)
exhausted.add(tag_id)
for tag in tags:
add_to_cluster(tag.id)
logger.info("[FieldContainers] Entry Cluster", entry_cluster=exhausted)
logger.info("[FieldContainers] Cluster Map", cluster_map=cluster_map)
# Initialize all categories from parents.
tags_ = {self.lib.get_tag(x) for x in exhausted}
for tag in tags_:
if tag.is_category:
cats[tag] = set()
logger.info("[FieldContainers] Blank Tag Categories", cats=cats)
# Add tags to any applicable categories.
added_ids: set[int] = set()
for key in cats:
logger.info("[FieldContainers] Checking category tag key", key=key)
if key:
logger.info(
"[FieldContainers] Key cluster:", key=key, cluster=cluster_map.get(key.id)
)
if final_tags := cluster_map.get(key.id, set()).union([key.id]):
cats[key] = {self.lib.get_tag(x) for x in final_tags if x in base_tag_ids}
added_ids = added_ids.union({x for x in final_tags if x in base_tag_ids})
# Add remaining tags to None key (general case).
cats[None] = {self.lib.get_tag(x) for x in base_tag_ids if x not in added_ids}
logger.info(
f"[FieldContainers] [{key}] Key cluster: None, general case!",
general_tags=cats[key],
added=added_ids,
base_tag_ids=base_tag_ids,
)
# Remove unused categories
empty: list[Tag] = []
for k, v in list(cats.items()):
if not v:
empty.append(k)
for key in empty:
cats.pop(key, None)
logger.info("[FieldContainers] Tag Categories", categories=cats)
return cats
def remove_field_prompt(self, name: str) -> str:
return Translations.translate_formatted("library.field.confirm_remove", name=name)
def add_field_to_selected(self, field_list: list):
"""Add list of entry fields to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
"""
logger.info(
"[FieldContainers][add_field_to_selected]",
selected=self.driver.selected,
fields=field_list,
)
for entry_id in self.driver.selected:
for field_item in field_list:
self.lib.add_field_to_entry(
entry_id,
field_id=field_item.data(Qt.ItemDataRole.UserRole),
)
def add_tags_to_selected(self, tags: int | list[int]):
"""Add list of tags to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
"""
if isinstance(tags, int):
tags = [tags]
logger.info(
"[FieldContainers][add_tags_to_selected]",
selected=self.driver.selected,
tags=tags,
)
for entry_id in self.driver.selected:
self.lib.add_tags_to_entry(
entry_id,
tag_ids=tags,
)
self.emit_badge_signals(tags, emit_on_absent=False)
def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
"""Update/Create data for a FieldContainer.
Args:
index(int): The container index.
field(BaseField): The type of field to write to.
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_field_container]", index=index)
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
if field.type.type == FieldTypeEnum.TEXT_LINE:
container.set_title(field.type.name)
container.set_inline(False)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, (str, type(None)))
text = field.value or ""
else:
text = "<i>Mixed Data</i>"
title = f"{field.type.name} ({field.type.type.value})"
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
modal = PanelModal(
EditTextLine(field.value),
title=title,
window_title=f"Edit {field.type.type.value}",
save_callback=(
lambda content: (
self.update_field(field, content),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
if "pytest" in sys.modules:
# for better testability
container.modal = modal # type: ignore
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.type.value),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
elif field.type.type == FieldTypeEnum.TEXT_BOX:
container.set_title(field.type.name)
container.set_inline(False)
# Normalize line endings in any text content.
if not is_mixed:
assert isinstance(field.value, (str, type(None)))
text = (field.value or "").replace("\r", "\n")
else:
text = "<i>Mixed Data</i>"
title = f"{field.type.name} (Text Box)"
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
if not is_mixed:
modal = PanelModal(
EditTextBox(field.value),
title=title,
window_title=f"Edit {field.type.name}",
save_callback=(
lambda content: (
self.update_field(field, content),
self.update_from_entry(self.cached_entries[0].id),
)
),
)
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
elif field.type.type == FieldTypeEnum.DATETIME:
if not is_mixed:
try:
container.set_title(field.type.name)
container.set_inline(False)
# TODO: Localize this and/or add preferences.
date = dt.strptime(field.value, "%Y-%m-%d %H:%M:%S")
title = f"{field.type.name} (Date)"
inner_widget = TextWidget(title, date.strftime("%D - %r"))
container.set_inner_widget(inner_widget)
except Exception:
container.set_title(field.type.name)
container.set_inline(False)
title = f"{field.type.name} (Date) (Unknown Format)"
inner_widget = TextWidget(title, str(field.value))
container.set_inner_widget(inner_widget)
container.set_edit_callback()
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
else:
text = "<i>Mixed Data</i>"
title = f"{field.type.name} (Wacky Date)"
inner_widget = TextWidget(title, text)
container.set_inner_widget(inner_widget)
else:
logger.warning("[FieldContainers][write_container] Unknown Field", field=field)
container.set_title(field.type.name)
container.set_inline(False)
title = f"{field.type.name} (Unknown Field Type)"
inner_widget = TextWidget(title, field.type.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
),
)
)
container.setHidden(False)
def write_tag_container(
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
):
"""Update/Create tag data for a FieldContainer.
Args:
index(int): The container index.
tags(set[Tag]): The list of tags for this container.
category_tag(Tag|None): The category tag this container represents.
is_mixed(bool): Relevant when multiple items are selected.
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_tag_container]", index=index)
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
container.set_title("Tags" if not category_tag else category_tag.name)
container.set_inline(False)
if not is_mixed:
inner_widget = container.get_inner_widget()
if isinstance(inner_widget, TagBoxWidget):
inner_widget.set_tags(tags)
with catch_warnings(record=True):
inner_widget.updated.disconnect()
else:
inner_widget = TagBoxWidget(
tags,
"Tags",
self.driver,
)
container.set_inner_widget(inner_widget)
inner_widget.updated.connect(
lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True))
)
else:
text = "<i>Mixed Data</i>"
inner_widget = TextWidget("Mixed Tags", text)
container.set_inner_widget(inner_widget)
container.set_edit_callback()
container.set_remove_callback()
container.setHidden(False)
def remove_field(self, field: BaseField):
"""Remove a field from all selected Entries."""
logger.info(
"[FieldContainers] Removing Field",
field=field,
selected=[x.path for x in self.cached_entries],
)
entry_ids = [e.id for e in self.cached_entries]
self.lib.remove_entry_field(field, entry_ids)
def update_field(self, field: BaseField, content: str) -> None:
"""Update a field in all selected Entries, given a field object."""
assert isinstance(
field,
(TextField, DatetimeField),
), f"instance: {type(field)}"
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_entry_field(
entry_ids,
field,
content,
)
def remove_message_box(self, prompt: str, callback: Callable) -> None:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle("Remove Field")
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
)
remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
remove_mb.setDefaultButton(cancel_button)
remove_mb.setEscapeButton(cancel_button)
result = remove_mb.exec_()
if result == 3: # TODO - what is this magic number?
callback()
def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True):
"""Emit any connected signals for updating badge icons."""
logger.info("[emit_badge_signals] Emitting", tag_ids=tag_ids, emit_on_absent=emit_on_absent)
if TAG_ARCHIVED in tag_ids:
self.archived_updated.emit(True) # noqa: FBT003
elif emit_on_absent:
self.archived_updated.emit(False) # noqa: FBT003
if TAG_FAVORITE in tag_ids:
self.favorite_updated.emit(True) # noqa: FBT003
elif emit_on_absent:
self.favorite_updated.emit(False) # noqa: FBT003

View File

@@ -0,0 +1,228 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import os
import platform
import typing
from datetime import datetime as dt
from datetime import timedelta
from pathlib import Path
import structlog
from humanfriendly import format_size
from PIL import ImageFont
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QLabel,
QVBoxLayout,
QWidget,
)
from src.core.enums import Theme
from src.core.library.alchemy.library import Library
from src.core.media_types import MediaCategories
from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class FileAttributes(QWidget):
"""The Preview Panel Widget."""
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__()
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
label_bg_color = (
Theme.COLOR_BG_DARK.value
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else Theme.COLOR_DARK_LABEL.value
)
self.date_style = "font-size:12px;"
self.file_label_style = "font-size: 12px"
self.properties_style = (
f"background-color:{label_bg_color};"
"color:#FFFFFF;"
"font-family:Oxanium;"
"font-weight:bold;"
"font-size:12px;"
"border-radius:3px;"
"padding-top: 4px;"
"padding-right: 1px;"
"padding-bottom: 1px;"
"padding-left: 1px;"
)
self.file_label = FileOpenerLabel()
self.file_label.setObjectName("filenameLabel")
self.file_label.setTextFormat(Qt.TextFormat.RichText)
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.file_label.setStyleSheet(self.file_label_style)
self.date_created_label = QLabel()
self.date_created_label.setObjectName("dateCreatedLabel")
self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
self.date_created_label.setStyleSheet(self.date_style)
self.date_created_label.setHidden(True)
self.date_modified_label = QLabel()
self.date_modified_label.setObjectName("dateModifiedLabel")
self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
self.date_modified_label.setStyleSheet(self.date_style)
self.date_modified_label.setHidden(True)
self.dimensions_label = QLabel()
self.dimensions_label.setObjectName("dimensionsLabel")
self.dimensions_label.setWordWrap(True)
self.dimensions_label.setStyleSheet(self.properties_style)
self.dimensions_label.setHidden(True)
self.date_container = QWidget()
date_layout = QVBoxLayout(self.date_container)
date_layout.setContentsMargins(0, 2, 0, 0)
date_layout.setSpacing(0)
date_layout.addWidget(self.date_created_label)
date_layout.addWidget(self.date_modified_label)
root_layout.addWidget(self.file_label)
root_layout.addWidget(self.date_container)
root_layout.addWidget(self.dimensions_label)
def update_date_label(self, filepath: Path | None = None) -> None:
"""Update the "Date Created" and "Date Modified" file property labels."""
if filepath and filepath.is_file():
created: dt = None
if platform.system() == "Windows" or platform.system() == "Darwin":
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore]
else:
created = dt.fromtimestamp(filepath.stat().st_ctime)
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
self.date_created_label.setText(
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}" # TODO: Translate
)
self.date_modified_label.setText(
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}" # TODO: Translate
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
elif filepath:
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>") # TODO: Translate
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>") # TODO: Translate
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
else:
self.date_created_label.setHidden(True)
self.date_modified_label.setHidden(True)
def update_stats(self, filepath: Path | None = None, ext: str = ".", stats: dict = None):
"""Render the panel widgets with the newest data from the Library."""
if not stats:
stats = {}
if not filepath:
self.layout().setSpacing(0)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label.setText("<i>No Items Selected</i>") # TODO: Translate
self.file_label.set_file_path("")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.dimensions_label.setText("")
self.dimensions_label.setHidden(True)
else:
self.layout().setSpacing(6)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.file_label.set_file_path(filepath)
self.dimensions_label.setHidden(False)
file_str: str = ""
separator: str = f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
for i, part in enumerate(filepath.parts):
part_ = part.strip(os.path.sep)
if i != len(filepath.parts) - 1:
file_str += f"{"\u200b".join(part_)}{separator}</b>"
else:
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
self.file_label.setText(file_str)
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
self.opener = FileOpenerHelper(filepath)
# Initialize the possible stat variables
stats_label_text = ""
ext_display: str = ""
file_size: str = ""
width_px_text: str = ""
height_px_text: str = ""
duration_text: str = ""
font_family: str = ""
# Attempt to populate the stat variables
width_px_text = stats.get("width", "")
height_px_text = stats.get("height", "")
duration_text = stats.get("duration", "")
font_family = stats.get("font_family", "")
if ext:
ext_display = ext.upper()[1:]
if filepath:
try:
file_size = format_size(filepath.stat().st_size)
if MediaCategories.is_ext_in_category(
ext, MediaCategories.FONT_TYPES, mime_fallback=True
):
font = ImageFont.truetype(filepath)
font_family = f"{font.getname()[0]} ({font.getname()[1]}) "
except (FileNotFoundError, OSError) as e:
logger.error(
"[FileAttributes] Could not process file stats", filepath=filepath, error=e
)
# Format and display any stat variables
def add_newline(stats_label_text: str) -> str:
if stats_label_text and stats_label_text[-2:] != "\n":
return stats_label_text + "\n"
return stats_label_text
if ext_display:
stats_label_text += ext_display
if file_size:
stats_label_text += f"{file_size}"
elif file_size:
stats_label_text += file_size
if width_px_text and height_px_text:
stats_label_text = add_newline(stats_label_text)
stats_label_text += f"{width_px_text} x {height_px_text} px"
if duration_text:
stats_label_text = add_newline(stats_label_text)
dur_str = str(timedelta(seconds=float(duration_text)))[:-7]
if dur_str.startswith("0:"):
dur_str = dur_str[2:]
if dur_str.startswith("0"):
dur_str = dur_str[1:]
stats_label_text += f"{dur_str}"
if font_family:
stats_label_text = add_newline(stats_label_text)
stats_label_text += f"{font_family}"
self.dimensions_label.setText(stats_label_text)
def update_multi_selection(self, count: int):
"""Format attributes for multiple selected items."""
self.layout().setSpacing(0)
self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label.setText(f"<b>{count}</b> Items Selected") # TODO: Translate
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
self.file_label.set_file_path("")
self.dimensions_label.setText("")
self.dimensions_label.setHidden(True)

View File

@@ -0,0 +1,376 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import io
import time
import typing
from pathlib import Path
import cv2
import rawpy
import structlog
from PIL import Image, UnidentifiedImageError
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt
from PySide6.QtGui import QAction, QMovie, QResizeEvent
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QWidget,
)
from src.core.library.alchemy.library import Library
from src.core.media_types import MediaCategories
from src.qt.helpers.file_opener import FileOpenerHelper, open_file
from src.qt.helpers.file_tester import is_readable_video
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
from src.qt.platform_strings import PlatformStrings
from src.qt.translations import Translations
from src.qt.widgets.media_player import MediaPlayer
from src.qt.widgets.thumb_renderer import ThumbRenderer
from src.qt.widgets.video_player import VideoPlayer
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
class PreviewThumb(QWidget):
"""The Preview Panel Widget."""
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__()
self.is_connected = False
self.lib = library
self.driver: QtDriver = driver
self.img_button_size: tuple[int, int] = (266, 266)
self.image_ratio: float = 1.0
image_layout = QHBoxLayout(self)
image_layout.setContentsMargins(0, 0, 0, 0)
self.open_file_action = QAction(self)
Translations.translate_qobject(self.open_file_action, "file.open_file")
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*self.img_button_size)
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
self.preview_gif.hide()
self.gif_buffer: QBuffer = QBuffer()
self.preview_vid = VideoPlayer(driver)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer()
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
self.thumb_renderer.updated_ratio.connect(
lambda ratio: (
self.set_image_ratio(ratio),
self.update_image_size(
(
self.size().width(),
self.size().height(),
),
ratio,
),
)
)
self.media_player = MediaPlayer(driver)
self.media_player.hide()
image_layout.addWidget(self.preview_img)
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
image_layout.addWidget(self.preview_gif)
image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
image_layout.addWidget(self.preview_vid)
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
self.setMinimumSize(*self.img_button_size)
def set_image_ratio(self, ratio: float):
self.image_ratio = ratio
def update_image_size(self, size: tuple[int, int], ratio: float = None):
if ratio:
self.set_image_ratio(ratio)
adj_width: float = size[0]
adj_height: float = size[1]
# Landscape
if self.image_ratio > 1:
adj_height = size[0] * (1 / self.image_ratio)
# Portrait
elif self.image_ratio <= 1:
adj_width = size[1] * self.image_ratio
if adj_width > size[0]:
adj_height = adj_height * (size[0] / adj_width)
adj_width = size[0]
elif adj_height > size[1]:
adj_width = adj_width * (size[1] / adj_height)
adj_height = size[1]
adj_size = QSize(int(adj_width), int(adj_height))
self.img_button_size = (int(adj_width), int(adj_height))
self.preview_img.setMaximumSize(adj_size)
self.preview_img.setIconSize(adj_size)
self.preview_vid.resize_video(adj_size)
self.preview_vid.setMaximumSize(adj_size)
self.preview_vid.setMinimumSize(adj_size)
self.preview_gif.setMaximumSize(adj_size)
self.preview_gif.setMinimumSize(adj_size)
proxy_style = RoundedPixmapStyle(radius=8)
self.preview_gif.setStyle(proxy_style)
self.preview_vid.setStyle(proxy_style)
m = self.preview_gif.movie()
if m:
m.setScaledSize(adj_size)
def get_preview_size(self) -> tuple[int, int]:
return (
self.size().width(),
self.size().height(),
)
def switch_preview(self, preview: str):
if preview != "image" and preview != "media":
self.preview_img.hide()
if preview != "video_legacy":
self.preview_vid.stop()
self.preview_vid.hide()
if preview != "media":
self.media_player.stop()
self.media_player.hide()
if preview != "animated":
if self.preview_gif.movie():
self.preview_gif.movie().stop()
self.gif_buffer.close()
self.preview_gif.hide()
def _display_fallback_image(self, filepath: Path, ext=str) -> dict:
"""Renders the given file as an image, no matter its media type.
Useful for fallback scenarios.
"""
self.switch_preview("image")
self.thumb_renderer.render(
time.time(),
filepath,
(512, 512),
self.devicePixelRatio(),
update_on_ratio_change=True,
)
self.preview_img.show()
return self._update_image(filepath, ext)
def _update_image(self, filepath: Path, ext: str) -> dict:
"""Update the static image preview from a filepath."""
stats: dict = {}
self.switch_preview("image")
image: Image.Image = None
if MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
):
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess()
image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
stats["width"] = image.width
stats["height"] = image.height
except (
rawpy._rawpy.LibRawIOError,
rawpy._rawpy.LibRawFileUnsupportedError,
):
pass
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True
):
try:
image = Image.open(str(filepath))
stats["width"] = image.width
stats["height"] = image.height
except UnidentifiedImageError as e:
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True
):
pass
self.preview_img.show()
return stats
def _update_animation(self, filepath: Path, ext: str) -> dict:
"""Update the animated image preview from a filepath."""
stats: dict = {}
# Ensure that any movie and buffer from previous animations are cleared.
if self.preview_gif.movie():
self.preview_gif.movie().stop()
self.gif_buffer.close()
try:
image: Image.Image = Image.open(filepath)
stats["width"] = image.width
stats["height"] = image.height
self.update_image_size((image.width, image.height), image.width / image.height)
anim_image: Image.Image = image
image_bytes_io: io.BytesIO = io.BytesIO()
anim_image.save(
image_bytes_io,
"GIF",
lossless=True,
save_all=True,
loop=0,
disposal=2,
)
image_bytes_io.seek(0)
ba: bytes = image_bytes_io.read()
self.gif_buffer.setData(ba)
movie = QMovie(self.gif_buffer, QByteArray())
self.preview_gif.setMovie(movie)
# If the animation only has 1 frame, display it like a normal image.
if movie.frameCount() == 1:
self._display_fallback_image(filepath, ext)
return stats
# The animation has more than 1 frame, continue displaying it as an animation
self.switch_preview("animated")
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
movie.start()
self.preview_gif.show()
stats["duration"] = movie.frameCount() // 60
except UnidentifiedImageError as e:
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
return self._display_fallback_image(filepath, ext)
return stats
def _update_video_legacy(self, filepath: Path) -> dict:
stats: dict = {}
self.switch_preview("video_legacy")
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()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
stats["width"] = image.width
stats["height"] = image.height
if success:
self.preview_vid.play(str(filepath), QSize(image.width, image.height))
self.update_image_size((image.width, image.height), image.width / image.height)
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
self.preview_vid.show()
stats["duration"] = video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS)
return stats
def _update_media(self, filepath: Path) -> dict:
stats: dict = {}
self.switch_preview("media")
self.preview_img.show()
self.media_player.show()
self.media_player.play(filepath)
stats["duration"] = self.media_player.player.duration() * 1000
return stats
def update_preview(self, filepath: Path, ext: str) -> dict:
"""Render a single file preview."""
stats: dict = {}
# Video (Legacy)
if MediaCategories.is_ext_in_category(
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
) and is_readable_video(filepath):
stats = self._update_video_legacy(filepath)
# Audio
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
):
self._update_image(filepath, ext)
stats = self._update_media(filepath)
self.thumb_renderer.render(
time.time(),
filepath,
(512, 512),
self.devicePixelRatio(),
update_on_ratio_change=True,
)
# Animated Images
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True
):
stats = self._update_animation(filepath, ext)
# Other Types (Including Images)
else:
# TODO: Get thumb renderer to return this stuff to pass on
stats = self._update_image(filepath, ext)
self.thumb_renderer.render(
time.time(),
filepath,
(512, 512),
self.devicePixelRatio(),
update_on_ratio_change=True,
)
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
self.preview_img.is_connected = True
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
self.opener = FileOpenerHelper(filepath)
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
return stats
def hide_preview(self):
"""Completely hide the file preview."""
self.switch_preview("")
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
self.update_image_size((self.size().width(), self.size().height()))
return super().resizeEvent(event)

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -114,8 +114,6 @@ class TagWidget(QWidget):
self.tag = tag
self.has_edit = has_edit
self.has_remove = has_remove
# self.bg_label = QLabel()
# self.setStyleSheet('background-color:blue;')
# if on_click_callback:
self.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -148,7 +146,7 @@ class TagWidget(QWidget):
self.inner_layout.setContentsMargins(2, 2, 2, 2)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22)
self.bg_button.setMinimumSize(math.ceil(22 * 2), 22)
self.bg_button.setStyleSheet(
f"QPushButton{{"

111
tagstudio/src/qt/widgets/tag_box.py Executable file → Normal file
View File

@@ -1,22 +1,16 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
import typing
import structlog
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QPushButton
from src.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from src.core.library import Entry, Tag
from PySide6.QtCore import Signal
from src.core.library import Tag
from src.core.library.alchemy.enums import FilterState
from src.core.library.alchemy.fields import TagBoxField
from src.qt.flowlayout import FlowLayout
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.modals.tag_search import TagSearchPanel
from src.qt.translations import Translations
from src.qt.widgets.fields import FieldWidget
from src.qt.widgets.panel import PanelModal
from src.qt.widgets.tag import TagWidget
@@ -33,15 +27,13 @@ class TagBoxWidget(FieldWidget):
def __init__(
self,
field: TagBoxField,
tags: set[Tag],
title: str,
driver: "QtDriver",
) -> None:
super().__init__(title)
assert isinstance(field, TagBoxField), f"field is {type(field)}"
self.field = field
self.tags: set[Tag] = tags
self.driver = (
driver # Used for creating tag click callbacks that search entries for that tag.
)
@@ -51,51 +43,13 @@ class TagBoxWidget(FieldWidget):
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
self.add_button = QPushButton()
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_button.setMinimumSize(23, 23)
self.add_button.setMaximumSize(23, 23)
self.add_button.setText("+")
self.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"}}"
)
tsp = TagSearchPanel(self.driver.lib)
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
self.add_modal = PanelModal(tsp, title)
Translations.translate_with_setter(self.add_modal.setWindowTitle, "tag.add.plural")
self.add_button.clicked.connect(
lambda: (
tsp.update_tags(),
self.add_modal.show(),
)
)
self.set_tags(field.tags)
def set_field(self, field: TagBoxField):
self.field = field
self.set_tags(self.tags)
def set_tags(self, tags: typing.Iterable[Tag]):
tags_ = sorted(list(tags), key=lambda tag: tag.name)
is_recycled = False
while self.base_layout.itemAt(0) and self.base_layout.itemAt(1):
logger.info("[TagBoxWidget] Tags:", tags=tags)
while self.base_layout.itemAt(0):
self.base_layout.takeAt(0).widget().deleteLater()
is_recycled = True
for tag in tags_:
tag_widget = TagWidget(tag, has_edit=True, has_remove=True)
@@ -115,70 +69,35 @@ class TagBoxWidget(FieldWidget):
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
self.base_layout.addWidget(tag_widget)
# Move or add the '+' button.
if is_recycled:
self.base_layout.addWidget(self.base_layout.takeAt(0).widget())
else:
self.base_layout.addWidget(self.add_button)
# Handles an edge case where there are no more tags and the '+' button
# doesn't move all the way to the left.
if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1):
self.base_layout.update()
def edit_tag(self, tag: Tag):
assert isinstance(tag, Tag), f"tag is {type(tag)}"
build_tag_panel = BuildTagPanel(self.driver.lib, tag=tag)
self.edit_modal = PanelModal(
build_tag_panel,
title=tag.name, # TODO - display name including subtags
tag.name, # TODO - display name including parent tags
"Edit Tag",
done_callback=self.driver.preview_panel.update_widgets,
has_save=True,
)
Translations.translate_with_setter(self.edit_modal.setWindowTitle, "tag.edit")
# TODO - this was update_tag()
self.edit_modal.saved.connect(
lambda: self.driver.lib.update_tag(
build_tag_panel.build_tag(),
subtag_ids=set(build_tag_panel.subtag_ids),
parent_ids=set(build_tag_panel.parent_ids),
alias_names=set(build_tag_panel.alias_names),
alias_ids=set(build_tag_panel.alias_ids),
)
)
self.edit_modal.show()
def add_tag_callback(self, tag_id: int):
logger.info("add_tag_callback", tag_id=tag_id, selected=self.driver.selected)
tag = self.driver.lib.get_tag(tag_id=tag_id)
for idx in self.driver.selected:
entry: Entry = self.driver.frame_content[idx]
if not self.driver.lib.add_field_tag(entry, tag, self.field.type_key):
# TODO - add some visible error
self.error_occurred.emit(Exception("Failed to add tag"))
self.updated.emit()
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
self.driver.update_badges()
def edit_tag_callback(self, tag: Tag):
self.driver.lib.update_tag(tag)
def remove_tag(self, tag_id: int):
logger.info(
"remove_tag",
"[TagBoxWidget] remove_tag",
selected=self.driver.selected,
field_type=self.field.type,
)
for grid_idx in self.driver.selected:
entry = self.driver.frame_content[grid_idx]
self.driver.lib.remove_field_tag(entry, tag_id, self.field.type_key)
for entry_id in self.driver.selected:
self.driver.lib.remove_tags_from_entry(entry_id, tag_id)
self.updated.emit()
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
self.driver.update_badges()
self.updated.emit()

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -11,14 +11,11 @@ from src.qt.widgets.fields import FieldWidget
class TextWidget(FieldWidget):
def __init__(self, title, text: str) -> None:
super().__init__(title)
# self.item = item
self.setObjectName("textBox")
# self.setStyleSheet('background-color:purple;')
self.base_layout = QHBoxLayout()
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.base_layout)
self.text_label = QLabel()
# self.text_label.textFormat(Qt.TextFormat.RichText)
self.text_label.setStyleSheet("font-size: 12px")
self.text_label.setWordWrap(True)
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -10,7 +10,6 @@ from src.qt.widgets.panel import PanelWidget
class EditTextBox(PanelWidget):
def __init__(self, text):
super().__init__()
# self.setLayout()
self.setMinimumSize(480, 480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import Callable
@@ -10,7 +10,6 @@ from src.qt.widgets.panel import PanelWidget
class EditTextLine(PanelWidget):
def __init__(self, text):
super().__init__()
# self.setLayout()
self.setMinimumWidth(480)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -72,7 +72,7 @@ class ThumbRenderer(QObject):
"""A class for rendering image and file thumbnails."""
rm: ResourceManager = ResourceManager()
updated = Signal(float, QPixmap, QSize, str, str)
updated = Signal(float, QPixmap, QSize, Path, str)
updated_ratio = Signal(float)
def __init__(self) -> None:
@@ -1208,7 +1208,7 @@ class ThumbRenderer(QObject):
math.ceil(adj_size / pixel_ratio),
math.ceil(final.size[1] / pixel_ratio),
),
str(_filepath.name),
_filepath,
_filepath.suffix.lower(),
)
@@ -1217,6 +1217,6 @@ class ThumbRenderer(QObject):
timestamp,
QPixmap(),
QSize(*base_size),
str(_filepath.name),
_filepath,
_filepath.suffix.lower(),
)

View File

@@ -12,7 +12,6 @@ sys.path.insert(0, str(CWD.parent))
from src.core.library import Entry, Library, Tag
from src.core.library import alchemy as backend
from src.core.library.alchemy.enums import TagColor
from src.core.library.alchemy.fields import TagBoxField, _FieldID
from src.qt.ts_qt import QtDriver
@@ -47,7 +46,7 @@ def file_mediatypes_library():
)
assert lib.add_entries([entry1, entry2, entry3])
assert len(lib.tags) == 2
assert len(lib.tags) == 3
return lib
@@ -72,47 +71,40 @@ def library(request):
)
assert lib.add_tag(tag)
subtag = Tag(
parent_tag = Tag(
id=1500,
name="subbar",
color=TagColor.YELLOW,
)
assert lib.add_tag(parent_tag)
tag2 = Tag(
id=2000,
name="bar",
color=TagColor.BLUE,
subtags={subtag},
parent_tags={parent_tag},
)
assert lib.add_tag(tag2)
# default item with deterministic name
entry = Entry(
id=1,
folder=lib.folder,
path=pathlib.Path("foo.txt"),
fields=lib.default_fields,
)
entry.tag_box_fields = [
TagBoxField(type_key=_FieldID.TAGS.name, tags={tag}, position=0),
TagBoxField(
type_key=_FieldID.TAGS_META.name,
position=0,
),
]
assert lib.add_tags_to_entry(entry.id, tag.id)
entry2 = Entry(
id=2,
folder=lib.folder,
path=pathlib.Path("one/two/bar.md"),
fields=lib.default_fields,
)
entry2.tag_box_fields = [
TagBoxField(
tags={tag2},
type_key=_FieldID.TAGS_META.name,
position=0,
),
]
assert lib.add_tags_to_entry(entry2.id, tag2.id)
assert lib.add_entries([entry, entry2])
assert len(lib.tags) == 5
assert len(lib.tags) == 6
yield lib
@@ -150,6 +142,7 @@ def qt_driver(qtbot, library):
driver.preview_panel = Mock()
driver.flow_container = Mock()
driver.item_thumbs = []
driver.autofill_action = Mock()
driver.lib = library
# TODO - downsize this method and use it

View File

@@ -1,36 +1,37 @@
import shutil
from pathlib import Path
from tempfile import TemporaryDirectory
# import shutil
# from pathlib import Path
# from tempfile import TemporaryDirectory
import pytest
from src.core.enums import MacroID
from src.core.library.alchemy.fields import _FieldID
# import pytest
# from src.core.enums import MacroID
# from src.core.library.alchemy.fields import _FieldID
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
# @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sidecar_macro(qt_driver, library, cwd, entry_full):
entry_full.path = Path("newgrounds/foo.txt")
# TODO: Rework and finalize sidecar loading + macro systems.
pass
# entry_full.path = Path("newgrounds/foo.txt")
fixture = cwd / "fixtures/sidecar_newgrounds.json"
dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json")
dst.parent.mkdir()
shutil.copy(fixture, dst)
# fixture = cwd / "fixtures/sidecar_newgrounds.json"
# dst = library.library_dir / "newgrounds" / (entry_full.path.name + ".json")
# dst.parent.mkdir()
# shutil.copy(fixture, dst)
qt_driver.frame_content = [entry_full]
qt_driver.run_macro(MacroID.SIDECAR, 0)
# qt_driver.frame_content = [entry_full]
# qt_driver.run_macro(MacroID.SIDECAR, entry_full.id)
entry = next(library.get_entries(with_joins=True))
new_fields = (
(_FieldID.DESCRIPTION.name, "NG description"),
(_FieldID.ARTIST.name, "NG artist"),
(_FieldID.SOURCE.name, "https://ng.com"),
(_FieldID.TAGS.name, None),
)
found = [(field.type.key, field.value) for field in entry.fields]
# entry = library.get_entry_full(entry_full.id)
# new_fields = (
# (_FieldID.DESCRIPTION.name, "NG description"),
# (_FieldID.ARTIST.name, "NG artist"),
# (_FieldID.SOURCE.name, "https://ng.com"),
# )
# found = [(field.type.key, field.value) for field in entry.fields]
# `new_fields` should be subset of `found`
for field in new_fields:
assert field in found, f"Field not found: {field} / {found}"
# # `new_fields` should be subset of `found`
# for field in new_fields:
# assert field in found, f"Field not found: {field} / {found}"
expected_tags = {"ng_tag", "ng_tag2"}
assert {x.name in expected_tags for x in entry.tags}
# expected_tags = {"ng_tag", "ng_tag2"}
# assert {x.name in expected_tags for x in entry.tags}

View File

@@ -11,9 +11,9 @@ def test_build_tag_panel_add_sub_tag_callback(library, generate_tag):
panel: BuildTagPanel = BuildTagPanel(library, child)
panel.add_subtag_callback(parent.id)
panel.add_parent_tag_callback(parent.id)
assert len(panel.subtag_ids) == 1
assert len(panel.parent_ids) == 1
def test_build_tag_panel_remove_subtag_callback(library, generate_tag):
@@ -30,9 +30,9 @@ def test_build_tag_panel_remove_subtag_callback(library, generate_tag):
panel: BuildTagPanel = BuildTagPanel(library, child)
panel.remove_subtag_callback(parent.id)
panel.remove_parent_tag_callback(parent.id)
assert len(panel.subtag_ids) == 0
assert len(panel.parent_ids) == 0
import os
@@ -73,20 +73,20 @@ def test_build_tag_panel_remove_alias_callback(library, generate_tag):
assert alias.name not in panel.alias_names
def test_build_tag_panel_set_subtags(library, generate_tag):
def test_build_tag_panel_set_parent_tags(library, generate_tag):
parent = library.add_tag(generate_tag("parent", id=123))
child = library.add_tag(generate_tag("child", id=124))
assert parent
assert child
library.add_subtag(child.id, parent.id)
library.add_parent_tag(child.id, parent.id)
child = library.get_tag(child.id)
panel: BuildTagPanel = BuildTagPanel(library, child)
assert len(panel.subtag_ids) == 1
assert panel.subtags_scroll_layout.count() == 1
assert len(panel.parent_ids) == 1
assert panel.parent_tags_scroll_layout.count() == 1
def test_build_tag_panel_add_aliases(library, generate_tag):

View File

@@ -0,0 +1,172 @@
from src.qt.widgets.preview_panel import PreviewPanel
def test_update_selection_empty(qt_driver, library):
panel = PreviewPanel(library, qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
qt_driver.toggle_item_selection(1, append=True, bridge=False)
panel.update_widgets()
# FieldContainer should hide all containers
for container in panel.fields.containers:
assert container.isHidden()
def test_update_selection_single(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.update_widgets()
# FieldContainer should show all applicable tags and field containers
for container in panel.fields.containers:
assert not container.isHidden()
def test_update_selection_multiple(qt_driver, library):
# TODO: Implement mixed field editing. Currently these containers will be hidden,
# same as the empty selection behavior.
panel = PreviewPanel(library, qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
qt_driver.toggle_item_selection(2, append=True, bridge=False)
panel.update_widgets()
# FieldContainer should show mixed field editing
for container in panel.fields.containers:
assert container.isHidden()
def test_add_tag_to_selection_single(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.update_widgets()
# Add new tag
panel.fields.add_tags_to_selected(2000)
# Then reload entry
refreshed_entry = next(library.get_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000, 2000}
def test_add_same_tag_to_selection_single(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
assert {t.id for t in entry_full.tags} == {1000}
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.update_widgets()
# Add an existing tag
panel.fields.add_tags_to_selected(1000)
# Then reload entry
refreshed_entry = next(library.get_entries(with_joins=True))
assert {t.id for t in refreshed_entry.tags} == {1000}
def test_add_tag_to_selection_multiple(qt_driver, library):
panel = PreviewPanel(library, qt_driver)
all_entries = library.get_entries(with_joins=True)
# We want to verify that tag 1000 is on some, but not all entries already.
tag_present_on_some: bool = False
tag_absent_on_some: bool = False
for e in all_entries:
if 1000 in [t.id for t in e.tags]:
tag_present_on_some = True
else:
tag_absent_on_some = True
assert tag_present_on_some
assert tag_absent_on_some
# Select the multiple entries
for i, e in enumerate(library.get_entries(with_joins=True), start=0):
qt_driver.toggle_item_selection(e.id, append=(True if i == 0 else False), bridge=False) # noqa: SIM210
panel.update_widgets()
# Add new tag
panel.fields.add_tags_to_selected(1000)
# Then reload all entries and recheck the presence of tag 1000
refreshed_entries = library.get_entries(with_joins=True)
tag_present_on_some: bool = False
tag_absent_on_some: bool = False
for e in refreshed_entries:
if 1000 in [t.id for t in e.tags]:
tag_present_on_some = True
else:
tag_absent_on_some = True
assert tag_present_on_some
assert not tag_absent_on_some
def test_meta_tag_category(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
# Ensure the Favorite tag is on entry_full
library.add_tags_to_entry(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.update_widgets()
# FieldContainer should hide all containers
assert len(panel.fields.containers) == 3
for i, container in enumerate(panel.fields.containers):
match i:
case 0:
# Check if the container is the Meta Tags category
assert container.title == f"<h4>{library.get_tag(2).name}</h4>"
case 1:
# Check if the container is the Tags category
assert container.title == "<h4>Tags</h4>"
case 2:
# Make sure the container isn't a duplicate Tags category
assert container.title != "<h4>Tags</h4>"
def test_custom_tag_category(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
# Set tag 1000 (foo) as a category
tag = library.get_tag(1000)
tag.is_category = True
library.update_tag(
tag,
)
# Ensure the Favorite tag is on entry_full
library.add_tags_to_entry(1, entry_full.id)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.update_widgets()
# FieldContainer should hide all containers
assert len(panel.fields.containers) == 3
for i, container in enumerate(panel.fields.containers):
match i:
case 0:
# Check if the container is the Meta Tags category
assert container.title == f"<h4>{library.get_tag(2).name}</h4>"
case 1:
# Check if the container is the custom "foo" category
assert container.title == f"<h4>{tag.name}</h4>"
case 2:
# Make sure the container isn't a plain Tags category
assert container.title != "<h4>Tags</h4>"

View File

@@ -1,124 +1,39 @@
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from src.core.library import Entry
from src.core.library.alchemy.enums import FieldTypeEnum
from src.core.library.alchemy.fields import TextField, _FieldID
from src.qt.widgets.preview_panel import PreviewPanel
def test_update_widgets_not_selected(qt_driver, library):
qt_driver.frame_content = list(library.get_entries())
qt_driver.selected = []
def test_update_selection_empty(qt_driver, library):
panel = PreviewPanel(library, qt_driver)
# Clear the library selection (selecting 1 then unselecting 1)
qt_driver.toggle_item_selection(1, append=False, bridge=False)
qt_driver.toggle_item_selection(1, append=True, bridge=False)
panel.update_widgets()
assert panel.preview_img.isVisible()
assert panel.file_label.text() == "<i>No Items Selected</i>"
# Panel should disable UI that allows for entry modification
assert not panel.add_tag_button.isEnabled()
assert not panel.add_field_button.isEnabled()
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_update_widgets_single_selected(qt_driver, library):
qt_driver.frame_content = list(library.get_entries())
qt_driver.selected = [0]
def test_update_selection_single(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
# Select the single entry
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
panel.update_widgets()
assert panel.preview_img.isVisible()
# Panel should enable UI that allows for entry modification
assert panel.add_tag_button.isEnabled()
assert panel.add_field_button.isEnabled()
def test_update_widgets_multiple_selected(qt_driver, library):
# entry with no tag fields
entry = Entry(
path=Path("test.txt"),
folder=library.folder,
fields=[TextField(type_key=_FieldID.TITLE.name, position=0)],
)
assert not entry.tag_box_fields
library.add_entries([entry])
assert library.entries_count == 3
qt_driver.frame_content = list(library.get_entries())
qt_driver.selected = [0, 1, 2]
def test_update_selection_multiple(qt_driver, library):
panel = PreviewPanel(library, qt_driver)
# Select the multiple entries
qt_driver.toggle_item_selection(1, append=False, bridge=False)
qt_driver.toggle_item_selection(2, append=True, bridge=False)
panel.update_widgets()
assert {f.type_key for f in panel.common_fields} == {
_FieldID.TITLE.name,
}
assert {f.type_key for f in panel.mixed_fields} == {
_FieldID.TAGS.name,
_FieldID.TAGS_META.name,
}
def test_write_container_text_line(qt_driver, entry_full, library):
# Given
panel = PreviewPanel(library, qt_driver)
field = entry_full.text_fields[0]
assert len(entry_full.text_fields) == 1
assert field.type.type == FieldTypeEnum.TEXT_LINE
assert field.type.name == "Title"
# set any value
field.value = "foo"
panel.write_container(0, field)
panel.selected = [0]
assert len(panel.containers) == 1
container = panel.containers[0]
widget = container.get_inner_widget()
# test it's not "mixed data"
assert widget.text_label.text() == "foo"
# When update and submit modal
modal = panel.containers[0].modal
modal.widget.text_edit.setText("bar")
modal.save_button.click()
# Then reload entry
entry_full = next(library.get_entries(with_joins=True))
# the value was updated
assert entry_full.text_fields[0].value == "bar"
def test_remove_field(qt_driver, library):
# Given
panel = PreviewPanel(library, qt_driver)
entries = list(library.get_entries(with_joins=True))
qt_driver.frame_content = entries
# When second entry is selected
panel.selected = [1]
field = entries[1].text_fields[0]
panel.write_container(0, field)
panel.remove_field(field)
entries = list(library.get_entries(with_joins=True))
assert not entries[1].text_fields
def test_update_field(qt_driver, library, entry_full):
panel = PreviewPanel(library, qt_driver)
# select both entries
qt_driver.frame_content = list(library.get_entries())[:2]
qt_driver.selected = [0, 1]
panel.selected = [0, 1]
# update field
title_field = entry_full.text_fields[0]
panel.update_field(title_field, "meow")
for entry in library.get_entries(with_joins=True):
field = [x for x in entry.text_fields if x.type_key == title_field.type_key][0]
assert field.value == "meow"
# Panel should enable UI that allows for entry modification
assert panel.add_tag_button.isEnabled()
assert panel.add_field_button.isEnabled()

View File

@@ -1,76 +1,70 @@
from pathlib import Path
from unittest.mock import Mock
from src.core.library import Entry
from src.core.library.alchemy.enums import FilterState
from src.core.library.json.library import ItemType
from src.qt.widgets.item_thumb import ItemThumb
# def test_update_thumbs(qt_driver):
# qt_driver.frame_content = [
# Entry(
# folder=qt_driver.lib.folder,
# path=Path("/tmp/foo"),
# fields=qt_driver.lib.default_fields,
# )
# ]
def test_update_thumbs(qt_driver):
qt_driver.frame_content = [
Entry(
folder=qt_driver.lib.folder,
path=Path("/tmp/foo"),
fields=qt_driver.lib.default_fields,
)
]
# qt_driver.item_thumbs = []
# for _ in range(3):
# qt_driver.item_thumbs.append(
# ItemThumb(
# mode=ItemType.ENTRY,
# library=qt_driver.lib,
# driver=qt_driver,
# thumb_size=(100, 100),
# )
# )
qt_driver.item_thumbs = []
for i in range(3):
qt_driver.item_thumbs.append(
ItemThumb(
mode=ItemType.ENTRY,
library=qt_driver.lib,
driver=qt_driver,
thumb_size=(100, 100),
grid_idx=i,
)
)
# qt_driver.update_thumbs()
qt_driver.update_thumbs()
for idx, thumb in enumerate(qt_driver.item_thumbs):
# only first item is visible
assert thumb.isVisible() == (idx == 0)
# for idx, thumb in enumerate(qt_driver.item_thumbs):
# # only first item is visible
# assert thumb.isVisible() == (idx == 0)
def test_select_item_bridge(qt_driver, entry_min):
# mock some props since we're not running `start()`
qt_driver.autofill_action = Mock()
qt_driver.sort_fields_action = Mock()
# def test_toggle_item_selection_bridge(qt_driver, entry_min):
# # mock some props since we're not running `start()`
# qt_driver.autofill_action = Mock()
# qt_driver.sort_fields_action = Mock()
# set the content manually
qt_driver.frame_content = [entry_min] * 3
# # set the content manually
# qt_driver.frame_content = [entry_min] * 3
qt_driver.filter.page_size = 3
qt_driver._init_thumb_grid()
assert len(qt_driver.item_thumbs) == 3
# qt_driver.filter.page_size = 3
# qt_driver._init_thumb_grid()
# assert len(qt_driver.item_thumbs) == 3
# select first item
qt_driver.select_item(0, append=False, bridge=False)
assert qt_driver.selected == [0]
# # select first item
# qt_driver.toggle_item_selection(0, append=False, bridge=False)
# assert qt_driver.selected == [0]
# add second item to selection
qt_driver.select_item(1, append=False, bridge=True)
assert qt_driver.selected == [0, 1]
# # add second item to selection
# qt_driver.toggle_item_selection(1, append=False, bridge=True)
# assert qt_driver.selected == [0, 1]
# add third item to selection
qt_driver.select_item(2, append=False, bridge=True)
assert qt_driver.selected == [0, 1, 2]
# # add third item to selection
# qt_driver.toggle_item_selection(2, append=False, bridge=True)
# assert qt_driver.selected == [0, 1, 2]
# select third item only
qt_driver.select_item(2, append=False, bridge=False)
assert qt_driver.selected == [2]
# # select third item only
# qt_driver.toggle_item_selection(2, append=False, bridge=False)
# assert qt_driver.selected == [2]
qt_driver.select_item(0, append=False, bridge=True)
assert qt_driver.selected == [0, 1, 2]
# qt_driver.toggle_item_selection(0, append=False, bridge=True)
# assert qt_driver.selected == [0, 1, 2]
def test_library_state_update(qt_driver):
# Given
for idx, entry in enumerate(qt_driver.lib.get_entries(with_joins=True)):
thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100), idx)
for entry in qt_driver.lib.get_entries(with_joins=True):
thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100))
qt_driver.item_thumbs.append(thumb)
qt_driver.frame_content.append(entry)
@@ -83,21 +77,21 @@ def test_library_state_update(qt_driver):
qt_driver.filter_items(state)
assert qt_driver.filter.page_size == 10
assert len(qt_driver.frame_content) == 1
entry = qt_driver.frame_content[0]
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
assert list(entry.tags)[0].name == "foo"
# When state is not changed, previous one is still applied
qt_driver.filter_items()
assert qt_driver.filter.page_size == 10
assert len(qt_driver.frame_content) == 1
entry = qt_driver.frame_content[0]
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
assert list(entry.tags)[0].name == "foo"
# When state property is changed, previous one is overwritten
state = FilterState.from_path("*bar.md")
qt_driver.filter_items(state)
assert len(qt_driver.frame_content) == 1
entry = qt_driver.frame_content[0]
entry = qt_driver.lib.get_entry_full(qt_driver.frame_content[0])
assert list(entry.tags)[0].name == "bar"

View File

@@ -10,7 +10,7 @@ def test_tag_panel(qtbot, library):
def test_add_tag_callback(qt_driver):
# Given
assert len(qt_driver.lib.tags) == 5
assert len(qt_driver.lib.tags) == 6
qt_driver.add_tag_action_callback()
# When
@@ -20,5 +20,5 @@ def test_add_tag_callback(qt_driver):
# Then
tags: set[Tag] = qt_driver.lib.tags
assert len(tags) == 6
assert len(tags) == 7
assert "xxx" in {tag.name for tag in tags}

View File

@@ -1,110 +0,0 @@
from unittest.mock import patch
from src.core.library.alchemy.fields import _FieldID
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.widgets.tag import TagWidget
from src.qt.widgets.tag_box import TagBoxWidget
def test_tag_widget(qtbot, library, qt_driver):
# given
entry = next(library.get_entries(with_joins=True))
field = entry.tag_box_fields[0]
tag_widget = TagBoxWidget(field, "title", qt_driver)
qtbot.add_widget(tag_widget)
assert not tag_widget.add_modal.isVisible()
# when/then check no exception is raised
tag_widget.add_button.clicked.emit()
# check `tag_widget.add_modal` is visible
assert tag_widget.add_modal.isVisible()
def test_tag_widget_add_existing_raises(library, qt_driver, entry_full):
# Given
tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0]
assert len(entry_full.tags) == 1
tag = next(iter(entry_full.tags))
# When
tag_widget = TagBoxWidget(tag_field, "title", qt_driver)
tag_widget.driver.frame_content = [entry_full]
tag_widget.driver.selected = [0]
# Then
with patch.object(tag_widget, "error_occurred") as mocked:
tag_widget.add_modal.widget.tag_chosen.emit(tag.id)
assert mocked.emit.called
def test_tag_widget_add_new_pass(qtbot, library, qt_driver, generate_tag):
# Given
entry = next(library.get_entries(with_joins=True))
field = entry.tag_box_fields[0]
tag = generate_tag(name="new_tag")
library.add_tag(tag)
tag_widget = TagBoxWidget(field, "title", qt_driver)
qtbot.add_widget(tag_widget)
tag_widget.driver.selected = [0]
with patch.object(tag_widget, "error_occurred") as mocked:
# When
tag_widget.add_modal.widget.tag_chosen.emit(tag.id)
# Then
assert not mocked.emit.called
def test_tag_widget_remove(qtbot, qt_driver, library, entry_full):
tag = list(entry_full.tags)[0]
assert tag
assert entry_full.tag_box_fields
tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0]
tag_widget = TagBoxWidget(tag_field, "title", qt_driver)
tag_widget.driver.selected = [0]
qtbot.add_widget(tag_widget)
tag_widget = tag_widget.base_layout.itemAt(0).widget()
assert isinstance(tag_widget, TagWidget)
tag_widget.remove_button.clicked.emit()
entry = next(qt_driver.lib.get_entries(with_joins=True))
assert not entry.tag_box_fields[0].tags
def test_tag_widget_edit(qtbot, qt_driver, library, entry_full):
# Given
entry = next(library.get_entries(with_joins=True))
library.add_tag(list(entry.tags)[0])
tag = library.get_tag(list(entry.tags)[0].id)
assert tag
assert entry_full.tag_box_fields
tag_field = [f for f in entry_full.tag_box_fields if f.type_key == _FieldID.TAGS.name][0]
tag_box_widget = TagBoxWidget(tag_field, "title", qt_driver)
tag_box_widget.driver.selected = [0]
qtbot.add_widget(tag_box_widget)
tag_widget = tag_box_widget.base_layout.itemAt(0).widget()
assert isinstance(tag_widget, TagWidget)
# When
tag_box_widget.edit_tag(tag)
# Then
panel = tag_box_widget.edit_modal.widget
assert isinstance(panel, BuildTagPanel)
assert panel.tag.name == tag.name
assert panel.name_field.text() == tag.name

View File

@@ -13,14 +13,11 @@ def test_library_add_alias(library, generate_tag):
tag = library.add_tag(generate_tag("xxx", id=123))
assert tag
subtag_ids: set[int] = set()
parent_ids: set[int] = set()
alias_ids: set[int] = set()
alias_names: set[str] = set()
alias_names.add("test_alias")
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
# Note: ask if it is expected behaviour that you need to re-request
# for the tag. Or if the tag in memory should be updated
library.update_tag(tag, parent_ids, alias_names, alias_ids)
alias_ids = library.get_tag(tag.id).alias_ids
assert len(alias_ids) == 1
@@ -30,12 +27,11 @@ def test_library_get_alias(library, generate_tag):
tag = library.add_tag(generate_tag("xxx", id=123))
assert tag
subtag_ids: set[int] = set()
parent_ids: set[int] = set()
alias_ids: set[int] = set()
alias_names: set[str] = set()
alias_names.add("test_alias")
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
library.update_tag(tag, parent_ids, alias_names, alias_ids)
alias_ids = library.get_tag(tag.id).alias_ids
assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias"
@@ -45,23 +41,20 @@ def test_library_update_alias(library, generate_tag):
tag: Tag = library.add_tag(generate_tag("xxx", id=123))
assert tag
subtag_ids: set[int] = set()
parent_ids: set[int] = set()
alias_ids: set[int] = set()
alias_names: set[str] = set()
alias_names.add("test_alias")
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
tag = library.get_tag(tag.id)
alias_ids = tag.alias_ids
library.update_tag(tag, parent_ids, alias_names, alias_ids)
alias_ids = library.get_tag(tag.id).alias_ids
assert library.get_alias(tag.id, alias_ids[0]).name == "test_alias"
alias_names.remove("test_alias")
alias_names.add("alias_update")
library.update_tag(tag, subtag_ids, alias_names, alias_ids)
library.update_tag(tag, parent_ids, alias_names, alias_ids)
tag = library.get_tag(tag.id)
assert len(tag.alias_ids) == 1
assert library.get_alias(tag.id, tag.alias_ids[0]).name == "alias_update"
@@ -77,9 +70,7 @@ def test_library_add_file(library):
)
assert not library.has_path_entry(entry.path)
assert library.add_entries([entry])
assert library.has_path_entry(entry.path)
@@ -96,7 +87,7 @@ def test_create_tag(library, generate_tag):
assert tag_inc.id > 1000
def test_tag_subtag_itself(library, generate_tag):
def test_tag_self_parent(library, generate_tag):
# tag already exists
assert not library.add_tag(generate_tag("foo", id=1000))
@@ -107,7 +98,7 @@ def test_tag_subtag_itself(library, generate_tag):
library.update_tag(tag, {tag.id}, {}, {})
tag = library.get_tag(tag.id)
assert len(tag.subtag_ids) == 0
assert len(tag.parent_ids) == 0
def test_library_search(library, generate_tag, entry_full):
@@ -121,23 +112,13 @@ def test_library_search(library, generate_tag, entry_full):
assert results.total_count == 1
assert len(results) == 1
entry = results[0]
assert {x.name for x in entry.tags} == {
"foo",
}
assert entry.tag_box_fields
def test_tag_search(library):
tag = library.tags[0]
assert library.search_tags(tag.name.lower())
assert library.search_tags(tag.name.upper())
assert library.search_tags(tag.name[2:-2])
assert not library.search_tags(tag.name * 2)
@@ -145,7 +126,7 @@ def test_get_entry(library: Library, entry_min):
assert entry_min.id
result = library.get_entry_full(entry_min.id)
assert result
assert result.tags
assert len(result.tags) == 1
def test_entries_count(library):
@@ -159,58 +140,22 @@ def test_entries_count(library):
assert len(results) == 5
def test_add_field_to_entry(library):
def test_parents_add(library, generate_tag):
# Given
entry = Entry(
folder=library.folder,
path=Path("xxx"),
fields=library.default_fields,
)
# meta tags + content tags
assert len(entry.tag_box_fields) == 2
assert library.add_entries([entry])
# When
library.add_entry_field_type(entry.id, field_id=_FieldID.TAGS)
# Then
entry = [x for x in library.get_entries(with_joins=True) if x.path == entry.path][0]
# meta tags and tags field present
assert len(entry.tag_box_fields) == 3
def test_add_field_tag(library: Library, entry_full, generate_tag):
# Given
tag_name = "xxx"
tag = generate_tag(tag_name)
tag_field = entry_full.tag_box_fields[0]
# When
library.add_field_tag(entry_full, tag, tag_field.type_key)
# Then
result = library.get_entry_full(entry_full.id)
tag_field = result.tag_box_fields[0]
assert [x.name for x in tag_field.tags if x.name == tag_name]
def test_subtags_add(library, generate_tag):
# Given
tag = library.tags[0]
tag: Tag = library.tags[0]
assert tag.id is not None
subtag = generate_tag("subtag1")
subtag = library.add_tag(subtag)
assert subtag.id is not None
parent_tag = generate_tag("parent_tag_01")
parent_tag = library.add_tag(parent_tag)
assert parent_tag.id is not None
# When
assert library.add_subtag(tag.id, subtag.id)
assert library.add_parent_tag(tag.id, parent_tag.id)
# Then
assert tag.id is not None
tag = library.get_tag(tag.id)
assert tag.subtag_ids
assert tag.parent_ids
def test_remove_tag(library, generate_tag):
@@ -286,7 +231,7 @@ def test_remove_field_entry_with_multiple_field(library, entry_full):
# When
# add identical field
assert library.add_entry_field_type(entry_full.id, field_id=title_field.type_key)
assert library.add_field_to_entry(entry_full.id, field_id=title_field.type_key)
# remove entry field
library.remove_entry_field(title_field, [entry_full.id])
@@ -315,7 +260,7 @@ def test_update_entry_with_multiple_identical_fields(library, entry_full):
# When
# add identical field
library.add_entry_field_type(entry_full.id, field_id=title_field.type_key)
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key)
# update one of the fields
library.update_entry_field(
@@ -357,25 +302,21 @@ def test_mirror_entry_fields(library: Library, entry_full):
entry = library.get_entry_full(entry_id)
# make sure fields are there after getting it from the library again
assert len(entry.fields) == 4
assert len(entry.fields) == 2
assert {x.type_key for x in entry.fields} == {
_FieldID.TITLE.name,
_FieldID.NOTES.name,
_FieldID.TAGS_META.name,
_FieldID.TAGS.name,
}
def test_remove_tag_from_field(library, entry_full):
for field in entry_full.tag_box_fields:
for tag in field.tags:
removed_tag = tag.name
library.remove_tag_from_field(tag, field)
break
def test_remove_tag_from_entry(library, entry_full):
removed_tag_id = -1
for tag in entry_full.tags:
removed_tag_id = tag.id
library.remove_tags_from_entry(entry_full.id, tag.id)
entry = next(library.get_entries(with_joins=True))
for field in entry.tag_box_fields:
assert removed_tag not in [tag.name for tag in field.tags]
assert removed_tag_id not in [t.id for t in entry.tags]
@pytest.mark.parametrize(
@@ -398,8 +339,8 @@ def test_update_field_order(library, entry_full):
title_field = entry_full.text_fields[0]
# When add two more fields
library.add_entry_field_type(entry_full.id, field_id=title_field.type_key, value="first")
library.add_entry_field_type(entry_full.id, field_id=title_field.type_key, value="second")
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="first")
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key, value="second")
# remove the one on first position
assert title_field.position == 0

View File

@@ -13,12 +13,12 @@ def verify_count(lib: Library, query: str, count: int):
@pytest.mark.parametrize(
["query", "count"],
[
("", 29),
("path:*", 29),
("", 31),
("path:*", 31),
("path:*inherit*", 24),
("path:*comp*", 5),
("special:untagged", 1),
("filetype:png", 23),
("special:untagged", 2),
("filetype:png", 25),
("filetype:jpg", 6),
("filetype:'jpg'", 6),
("tag_id:1011", 5),
@@ -68,7 +68,7 @@ def test_and(search_library: Library, query: str, count: int):
("circle or green", 14),
("green or circle", 14),
("filetype:jpg or tag:orange", 11),
("red or filetype:png", 25),
("red or filetype:png", 28),
("filetype:jpg or path:*comp*", 11),
],
)
@@ -79,22 +79,22 @@ def test_or(search_library: Library, query: str, count: int):
@pytest.mark.parametrize(
["query", "count"],
[
("not unexistant", 29),
("not unexistant", 31),
("not path:*", 0),
("not not path:*", 29),
("not special:untagged", 28),
("not not path:*", 31),
("not special:untagged", 29),
("not filetype:png", 6),
("not filetype:jpg", 23),
("not tag_id:1011", 24),
("not tag_id:1038", 18),
("not green", 24),
("not filetype:jpg", 25),
("not tag_id:1011", 26),
("not tag_id:1038", 20),
("not green", 26),
("tag:favorite", 0),
("not circle", 18),
("not tag:square", 18),
("not circle", 20),
("not tag:square", 20),
("circle and not square", 6),
("not circle and square", 6),
("special:untagged or not filetype:jpg", 24),
("not square or green", 20),
("special:untagged or not filetype:jpg", 25),
("not square or green", 22),
],
)
def test_not(search_library: Library, query: str, count: int):
@@ -108,7 +108,7 @@ def test_not(search_library: Library, query: str, count: int):
("(((tag_id:1041)))", 11),
("not (not tag_id:1041)", 11),
("((circle) and (not square))", 6),
("(not ((square) OR (green)))", 15),
("(not ((square) OR (green)))", 17),
("filetype:png and (tag:square or green)", 12),
],
)
@@ -121,7 +121,7 @@ def test_parentheses(search_library: Library, query: str, count: int):
[
("ellipse", 17),
("yellow", 15),
("color", 24),
("color", 25),
("shape", 24),
("yellow not green", 10),
],