mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-01 23:59:10 +00:00
Merge branch 'main' into keyboard_nav_improvements
This commit is contained in:
23
README.md
23
README.md
@@ -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 library’s 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, you’ll 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 you’d 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 you’d 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -11,9 +11,14 @@ In order to scan for new files or file changes, you’ll 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 you’d 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 you’d 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.
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "開啟/建立文庫"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
520
tagstudio/src/qt/widgets/preview/field_containers.py
Normal file
520
tagstudio/src/qt/widgets/preview/field_containers.py
Normal 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
|
||||
228
tagstudio/src/qt/widgets/preview/file_attributes.py
Normal file
228
tagstudio/src/qt/widgets/preview/file_attributes.py
Normal 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)
|
||||
376
tagstudio/src/qt/widgets/preview/preview_thumb.py
Normal file
376
tagstudio/src/qt/widgets/preview/preview_thumb.py
Normal 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
@@ -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
111
tagstudio/src/qt/widgets/tag_box.py
Executable file → Normal 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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
172
tagstudio/tests/qt/test_field_containers.py
Normal file
172
tagstudio/tests/qt/test_field_containers.py
Normal 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>"
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user