Compare commits

...

14 Commits

Author SHA1 Message Date
Travis Abendshien 4a835dc846 fix(translations): moved update url to a constant 2026-05-11 01:05:53 -07:00
Weblate (bot) 29d0294245 translationsL update from Hosted Weblate (#1355)
* Translated using Weblate (German)

Currently translated at 99.4% (363 of 365 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/de/
Translation: TagStudio/Strings

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (365 of 365 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (361 of 361 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/hu/
Translation: TagStudio/Strings

* Translated using Weblate (Amharic)

Currently translated at 12.1% (44 of 361 strings)

Translated using Weblate (Amharic)

Currently translated at 12.1% (44 of 361 strings)

Added translation using Weblate (Amharic)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Co-authored-by: birhant <birhancity@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/am/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (361 of 361 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Co-authored-by: Szíjártó Levente Pál <szijartoleventepal@gmail.com>
Co-authored-by: birhant <birhancity@gmail.com>
Co-authored-by: Med <45147847+kitsumed@users.noreply.github.com>
2026-05-11 01:03:04 -07:00
TheBobBobs 47c3d5338f perf: Optimize searching tags with DB indexes (#1129)
* perf: create sqlite indexes for common columns

* perf: optimize Library.search_tags

* fix(tag_search): do ordering before applying limit

* tag_search: order shorter tag names first

* update tests

* cleanup

* tag_search: use same sorting order when returning all tags

* use dict for deduplicating tags

* fix(tag_search): return descendants instead of ancestors

* perf(tag_search): remove slow calls to method `Library.tags`
2026-05-10 22:48:30 -07:00
Travis Abendshien c15e2b56ee refactor!: uncouple fields from hard-coded values (#1354)
* refactor!: uncouple fields from hardcoded values

* fix(tests): add missing db version fixtures

* fix: correctly add datetime field as datetime in json migration

* tests: update search library file

* fix: implement review feedback and misc fixes

* fix: implement additional feedback
2026-05-10 10:28:03 -07:00
socalledtheraven de7face06b feat: add ability to configure automatic loading of new files over 10000 (#1349)
* feat: add ability to configure automatic loading of new files over 10000
* Renaming things
* rename and reorder
* fix: remove duplicated add_new_files_callback() call
2026-05-10 10:22:35 -07:00
Jann Stute cdd399ed71 fix: correct ESCAPABLE_CHARS in query_lang tokenizer (#1334) 2026-05-10 16:47:21 +02:00
Sola-ris 7af7420167 fix: catch update notification exceptions (#1278)
* feat: add test for failing GitHub API call.

* fix: don't raise exceptions in get_most_recent_release_version.

* style: apply ruff.

* That not should not have been there.
2026-05-08 23:57:07 -07:00
Travis Abendshien cece7920a8 fix(translations): removed unused json migration keys 2026-05-08 20:18:21 -07:00
Jann Stute ad2cbbca48 fix: remove preferences table (#1298)
* refactor: cleanup parameters of open_library and open_sqlite_library

* doc: notes on what tables are affected by which migration steps

* refactor(migration order): move DBv6 repairs

* refactor(migration order): move DBv8 repairs

* refactor(migration order): move DBv9 repairs

* refactor(migration order): move DBv100 repairs

* refactor(migration order): move DBv102 repairs

* refactor: merge migration methods

* doc: final comment changes

* fix: query tag ids independent of future DB changes

* feat: remove preferences table

* refactor: various references to LibraryPrefs

* fix: update josn migration UI

* refactor: remove last vestiges of preferences table

* fix: remove newly unnecessary translations

* doc: document library format changes

* refactor: merge the two methods used for migration 104

* fix: typo in sql statement

* fix: add back support for preferences table in get_version

* fix: properly remove directory in test

* fix: incorrect schema check in get_version

* fix: update search lib via migration

* fix: update assert in test

* fix: ignore element order in assert in test

* fix: use correct path

* fix: better test output

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2026-05-08 20:16:22 -07:00
Weblate (bot) 96fc5ef065 translations: update from Hosted Weblate (#1351)
* Translated using Weblate (Russian)

Currently translated at 88.6% (321 of 362 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: werdei <48105614+WerDei@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/ru/
Translation: TagStudio/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (362 of 362 strings)

Co-authored-by: Banizath <baznitah@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tagstudio/strings/fr/
Translation: TagStudio/Strings

---------

Co-authored-by: werdei <48105614+WerDei@users.noreply.github.com>
Co-authored-by: Banizath <baznitah@gmail.com>
2026-05-08 19:53:28 -07:00
Jann Stute b0decac610 refactor: sql migrations (#1295)
* refactor: cleanup parameters of open_library and open_sqlite_library

* doc: notes on what tables are affected by which migration steps

* refactor(migration order): move DBv6 repairs

* refactor(migration order): move DBv8 repairs

* refactor(migration order): move DBv9 repairs

* refactor(migration order): move DBv100 repairs

* refactor(migration order): move DBv102 repairs

* refactor: merge migration methods

* doc: final comment changes

* fix: query tag ids independent of future DB changes
2026-05-08 19:37:09 -07:00
Jann Stute 910d2b735d refactor: cleanup parameters of open_library and open_sqlite_library (#1294) 2026-05-08 19:36:01 -07:00
Travis Abendshien 47d4de5825 chore: bump rawpy version to ~=0.27 2026-05-08 13:25:31 -07:00
Hieu 741ed5ad86 fix: correct ESCAPABLE_CHARS in query_lang tokenizer
Replace duplicate double-quote with single-quote so that
escaped single quotes inside single-quoted strings are
handled correctly (e.g. 'O''Brien' now tokenizes as O'Brien
instead of O'Brien).

Discovered during automated code review.
2026-04-02 13:18:22 +07:00
62 changed files with 1006 additions and 1219 deletions
+58 -31
View File
@@ -64,8 +64,8 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
- ~~Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key.~~ _See [Version 200](#version-200)_
- Repairs tags that may have a disambiguation_id pointing towards a deleted tag.
---
@@ -75,9 +75,9 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
- Updates Neon colors to use the new `color_border` property.
- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior.
- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)".
- Updates Neon colors to use the new `color_border` property.
---
@@ -87,48 +87,75 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
---
### Version 100
### Versions 100 - 1xx
#### Version 100
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Introduces built-in minor versioning
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
- Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased.
- Swaps `parent_id` and `child_id` values in the `tag_parents` table
- Introduces built-in minor versioning
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
- Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased.
- Swaps `parent_id` and `child_id` values in the `tag_parents` table
#### Version 101
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [12e074b](https://github.com/TagStudioDev/TagStudio/commit/12e074b71d8860282b44e49e0e1a41b7a2e4bae8)/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Deprecates the `preferences` table, set to be removed in a future TagStudio version.
- Introduces the `versions` table
- Has a string `key` column and an int `value` column
- The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'`
- `'INITIAL'` stores the database version number in which in was created
- Pre-existing databases set this number to `100`
- `'CURRENT'` stores the current database version number
- Deprecates the `preferences` table, set to be removed in a future TagStudio version.
- Introduces the `versions` table
- Has a string `key` column and an int `value` column
- The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'`
- `'INITIAL'` stores the database version number in which in was created
- Pre-existing databases set this number to `100`
- `'CURRENT'` stores the current database version number
#### Version 102
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [71d0425](https://github.com/TagStudioDev/TagStudio/commit/71d04254cf87f4200bb7ffc81656e50dfb122e4d) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
#### Version 103
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [88d0b47](https://github.com/TagStudioDev/TagStudio/commit/88d0b47a86821ccfadba653f30a515abce5b24b0)/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Format | Location |
| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
#### Version 104
| Used From | Format | Location |
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [ad2cbbc](https://github.com/TagStudioDev/TagStudio/commit/ad2cbbca483018d245b44348e2c4f5a0e0bb28f1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
### Versions 200 - 2xx
#### Version 200
| Used From | Format | Location |
| --------- | ------ | ----------------------------------------------- |
| TBD | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds `text_field_templates` and `date_field_templates` tables.
- Drops `boolean_fields` and `value_type` tables.
- Adds `name` columns to `text_fields` and `datetime_fields` tables.
- Values in the `name` columns are taken from the `type_key` columns and are changed to "Title Case".
- **Example:** "DATE_CREATED" -> "Date Created"
- Drops `position` columns from `text_fields` and `datetime_fields` tables.
- Adds `is_multiline` column to `text_fields` table.
- Values are set to `TRUE` if the field row was previously a "TEXT_BOX" type.
- Repairs existing "Description" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE` _(Previously done in [Version 7](#version-7))_.
- Repairs existing "Comments" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE`.
+2 -1
View File
@@ -24,7 +24,7 @@ dependencies = [
"pydub~=0.25",
"PySide6==6.8.0.*",
"rarfile==4.2",
"rawpy~=0.24",
"rawpy~=0.27",
"Send2Trash~=1.8",
"SQLAlchemy~=2.0",
"srctools~=2.6",
@@ -46,6 +46,7 @@ pyinstaller = ["Pyinstaller~=6.13"]
pytest = [
"pytest==8.3.5",
"pytest-cov==6.1.1",
"pytest-mock==3.15.1",
"pytest-qt==4.4.0",
"syrupy==4.9.1",
]
+1
View File
@@ -4,6 +4,7 @@
VERSION: str = "9.5.7" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
-29
View File
@@ -3,8 +3,6 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import enum
from typing import Any
from uuid import uuid4
class SettingItems(str, enum.Enum):
@@ -57,30 +55,3 @@ class MacroID(enum.Enum):
BUILD_URL = "build_url"
MATCH = "match"
CLEAN_URL = "clean_url"
class DefaultEnum(enum.Enum):
"""Allow saving multiple identical values in property called .default."""
default: Any
def __new__(cls, value):
# Create the enum instance
obj = object.__new__(cls)
# make value random
obj._value_ = uuid4()
# assign the actual value into .default property
obj.default = value
return obj
@property
def value(self):
raise AttributeError("access the value via .default property instead")
# TODO: Remove DefaultEnum and LibraryPrefs classes once remaining values are removed.
class LibraryPrefs(DefaultEnum):
"""Library preferences with default value accessible via .default property."""
IS_EXCLUDE_LIST = True
EXTENSION_LIST = [".json", ".xmp", ".aae"]
@@ -8,10 +8,9 @@ from sqlalchemy import text
SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
DB_VERSION_LEGACY_KEY: str = "DB_VERSION"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 103
DB_VERSION: int = 200
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
-5
View File
@@ -66,8 +66,3 @@ def make_tables(engine: Engine) -> None:
except OperationalError as e:
logger.error("Could not initialize built-in tags", error=e)
conn.rollback()
def drop_tables(engine: Engine) -> None:
logger.info("dropping db tables")
Base.metadata.drop_all(engine)
@@ -152,11 +152,3 @@ class BrowsingState:
def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState":
return replace(self, show_hidden_entries=show_hidden_entries)
class FieldTypeEnum(enum.Enum):
TEXT_LINE = "Text Line"
TEXT_BOX = "Text Box"
TAGS = "Tags"
DATETIME = "Datetime"
BOOLEAN = "Checkbox"
+103 -84
View File
@@ -5,18 +5,15 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, override
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from tagstudio.core.library.alchemy.db import Base
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.models import Entry, ValueType
from tagstudio.core.library.alchemy.models import Entry
class BaseField(Base):
@@ -27,12 +24,8 @@ class BaseField(Base):
return mapped_column(primary_key=True, autoincrement=True)
@declared_attr
def type_key(self) -> Mapped[str]:
return mapped_column(ForeignKey("value_type.key"))
@declared_attr
def type(self) -> Mapped[ValueType]:
return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore # pyright: ignore[reportArgumentType]
def name(self) -> Mapped[str]:
return mapped_column(nullable=False, default="")
@declared_attr
def entry_id(self) -> Mapped[int]:
@@ -42,50 +35,42 @@ class BaseField(Base):
def entry(self) -> Mapped[Entry]:
return relationship(foreign_keys=[self.entry_id]) # type: ignore # pyright: ignore[reportArgumentType]
@declared_attr
def position(self) -> Mapped[int]:
return mapped_column(default=0)
@property
def class_name(self) -> str:
return self.__class__.__name__
@override
def __hash__(self):
return hash(self.__key())
def __key(self): # pyright: ignore[reportUnknownParameterType]
raise NotImplementedError
def clone_with_entry_id(self, entry_id: int) -> BaseField: # pyright: ignore
raise NotImplementedError()
value: Any # pyright: ignore
class BooleanField(BaseField):
__tablename__ = "boolean_fields"
value: Mapped[bool]
def __key(self):
return (self.type, self.value)
@override
def __eq__(self, value: object) -> bool:
if isinstance(value, BooleanField):
return self.__key() == value.__key()
raise NotImplementedError
class TextField(BaseField):
__tablename__ = "text_fields"
value: Mapped[str | None]
def __key(self) -> tuple[ValueType, str | None]:
return self.type, self.value
is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False)
@override
def __eq__(self, value: object) -> bool:
if isinstance(value, TextField):
return self.__key() == value.__key()
elif isinstance(value, DatetimeField):
def __eq__(self, other: object) -> bool:
if not isinstance(other, TextField):
return False
raise NotImplementedError
return (self.name, self.value, self.is_multiline) == (
other.name,
other.value,
other.is_multiline,
)
@override
def __hash__(self) -> int:
return hash((self.name, self.value, self.is_multiline))
@override
def clone_with_entry_id(self, entry_id: int) -> TextField:
return TextField(
name=self.name, entry_id=entry_id, value=self.value, is_multiline=self.is_multiline
)
class DatetimeField(BaseField):
@@ -93,52 +78,86 @@ class DatetimeField(BaseField):
value: Mapped[str | None]
def __key(self):
return (self.type, self.value)
@override
def __eq__(self, other: object) -> bool:
if not isinstance(other, DatetimeField):
return False
return (self.name, self.value) == (other.name, other.value)
@override
def __eq__(self, value: object) -> bool:
if isinstance(value, DatetimeField):
return self.__key() == value.__key()
raise NotImplementedError
def __hash__(self) -> int:
return hash((self.name, self.value))
@override
def clone_with_entry_id(self, entry_id: int) -> DatetimeField:
return DatetimeField(name=self.name, entry_id=entry_id, value=self.value)
@dataclass
class DefaultField:
id: int
name: str
type: FieldTypeEnum
is_default: bool = field(default=False)
class BaseFieldTemplate(Base):
__abstract__ = True
@declared_attr
def id(self) -> Mapped[int]:
return mapped_column(primary_key=True, autoincrement=True)
@declared_attr
def name(self) -> Mapped[str]:
return mapped_column(nullable=False, default="")
@property
def class_name(self) -> str:
return self.__class__.__name__
def to_field(self, value: Any | None = None) -> BaseField: # pyright: ignore
raise NotImplementedError()
class FieldID(Enum):
"""Only for bootstrapping content of DB table."""
class TextFieldTemplate(BaseFieldTemplate):
__tablename__ = "text_field_templates"
is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False)
TITLE = DefaultField(id=0, name="Title", type=FieldTypeEnum.TEXT_LINE, is_default=True)
AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE)
ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE)
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX)
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
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)
DATE_MODIFIED = DefaultField(id=12, name="Date Modified", type=FieldTypeEnum.DATETIME)
DATE_TAKEN = DefaultField(id=13, name="Date Taken", type=FieldTypeEnum.DATETIME)
DATE_PUBLISHED = DefaultField(id=14, name="Date Published", type=FieldTypeEnum.DATETIME)
# ARCHIVED = DefaultField(id=15, name="Archived", type=CheckboxField.checkbox)
# FAVORITE = DefaultField(id=16, name="Favorite", type=CheckboxField.checkbox)
BOOK = DefaultField(id=17, name="Book", type=FieldTypeEnum.TEXT_LINE)
COMIC = DefaultField(id=18, name="Comic", type=FieldTypeEnum.TEXT_LINE)
SERIES = DefaultField(id=19, name="Series", type=FieldTypeEnum.TEXT_LINE)
MANGA = DefaultField(id=20, name="Manga", type=FieldTypeEnum.TEXT_LINE)
SOURCE = DefaultField(id=21, name="Source", type=FieldTypeEnum.TEXT_LINE)
DATE_UPLOADED = DefaultField(id=22, name="Date Uploaded", type=FieldTypeEnum.DATETIME)
DATE_RELEASED = DefaultField(id=23, name="Date Released", type=FieldTypeEnum.DATETIME)
VOLUME = DefaultField(id=24, name="Volume", type=FieldTypeEnum.TEXT_LINE)
ANTHOLOGY = DefaultField(id=25, name="Anthology", type=FieldTypeEnum.TEXT_LINE)
MAGAZINE = DefaultField(id=26, name="Magazine", type=FieldTypeEnum.TEXT_LINE)
PUBLISHER = DefaultField(id=27, name="Publisher", type=FieldTypeEnum.TEXT_LINE)
GUEST_ARTIST = DefaultField(id=28, name="Guest Artist", type=FieldTypeEnum.TEXT_LINE)
COMPOSER = DefaultField(id=29, name="Composer", type=FieldTypeEnum.TEXT_LINE)
COMMENTS = DefaultField(id=30, name="Comments", type=FieldTypeEnum.TEXT_LINE)
@override
def to_field(self, value: str | None = None) -> TextField:
return TextField(name=self.name, value=value, is_multiline=self.is_multiline)
class DatetimeFieldTemplate(BaseFieldTemplate):
__tablename__ = "datetime_field_templates"
@override
def to_field(self, value: str | None = None) -> DatetimeField:
return DatetimeField(name=self.name, value=value)
# Used for migrating legacy libraries.
# Legacy JSON libraries (<v9.4) use an integer ID.
# SQLite libraries 6 until 200 use a slugfield name (e.g. "DATE_CREATED").
LEGACY_FIELD_MAP = {
0: {"type": TextField, "name": "Title", "is_multiline": False},
1: {"type": TextField, "name": "Author", "is_multiline": False},
2: {"type": TextField, "name": "Artist", "is_multiline": False},
3: {"type": TextField, "name": "URL", "is_multiline": False},
4: {"type": TextField, "name": "Description", "is_multiline": True},
5: {"type": TextField, "name": "Notes", "is_multiline": True},
9: {"type": TextField, "name": "Collation", "is_multiline": False},
10: {"type": DatetimeField, "name": "Date", "is_multiline": False},
11: {"type": DatetimeField, "name": "Date Created"},
12: {"type": DatetimeField, "name": "Date Modified"},
13: {"type": DatetimeField, "name": "Date Taken"},
14: {"type": DatetimeField, "name": "Date Published"},
17: {"type": TextField, "name": "Book", "is_multiline": False},
18: {"type": TextField, "name": "Comic", "is_multiline": False},
19: {"type": TextField, "name": "Series", "is_multiline": False},
20: {"type": TextField, "name": "Manga", "is_multiline": False},
21: {"type": TextField, "name": "Source", "is_multiline": False},
22: {"type": DatetimeField, "name": "Date Uploaded"},
23: {"type": DatetimeField, "name": "Date Released"},
24: {"type": TextField, "name": "Volume", "is_multiline": False},
25: {"type": TextField, "name": "Anthology", "is_multiline": False},
26: {"type": TextField, "name": "Magazine", "is_multiline": False},
27: {"type": TextField, "name": "Publisher", "is_multiline": False},
28: {"type": TextField, "name": "Guest Artist", "is_multiline": False},
29: {"type": TextField, "name": "Composer", "is_multiline": False},
30: {"type": TextField, "name": "Comments", "is_multiline": True},
}
File diff suppressed because it is too large Load Diff
+1 -66
View File
@@ -6,16 +6,13 @@ from datetime import datetime as dt
from pathlib import Path
from typing import override
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing_extensions import deprecated
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
from tagstudio.core.library.alchemy.fields import (
BaseField,
BooleanField,
DatetimeField,
TextField,
)
@@ -224,7 +221,6 @@ class Entry(Base):
fields: list[BaseField] = []
fields.extend(self.text_fields)
fields.extend(self.datetime_fields)
fields = sorted(fields, key=lambda field: field.type.position)
return fields
@property
@@ -276,67 +272,6 @@ class Entry(Base):
self.tags.remove(tag)
class ValueType(Base):
"""Define Field Types in the Library.
Example:
key: content_tags (this field is slugified `name`)
name: Content Tags (this field is human readable name)
kind: type of content (Text Line, Text Box, Tags, Datetime, Checkbox)
is_default: Should the field be present in new Entry?
order: position of the field widget in the Entry form
"""
__tablename__ = "value_type"
key: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
type: Mapped[FieldTypeEnum] = mapped_column(default=FieldTypeEnum.TEXT_LINE)
is_default: Mapped[bool] # pyright: ignore[reportUninitializedInstanceVariable]
position: Mapped[int] # pyright: ignore[reportUninitializedInstanceVariable]
# add relations to other tables
text_fields: Mapped[list[TextField]] = relationship("TextField", back_populates="type")
datetime_fields: Mapped[list[DatetimeField]] = relationship(
"DatetimeField", back_populates="type"
)
boolean_fields: Mapped[list[BooleanField]] = relationship("BooleanField", back_populates="type")
@property
def as_field(self) -> BaseField:
FieldClass = { # noqa: N806
FieldTypeEnum.TEXT_LINE: TextField,
FieldTypeEnum.TEXT_BOX: TextField,
FieldTypeEnum.DATETIME: DatetimeField,
FieldTypeEnum.BOOLEAN: BooleanField,
}
return FieldClass[self.type](
type_key=self.key,
position=self.position,
)
@event.listens_for(ValueType, "before_insert")
def slugify_field_key(mapper, connection, target): # pyright: ignore
"""Slugify the field key before inserting into the database."""
if not target.key:
from tagstudio.core.library.alchemy.library import slugify
target.key = slugify(target.tag)
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
# and is set to be removed in a future release.
@deprecated("Use `Version` for storing version, and `ts_ignore` system for file exclusion.")
class Preferences(Base):
__tablename__ = "preferences"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[dict] = mapped_column(JSON, nullable=False)
class Version(Base):
__tablename__ = "versions"
+17
View File
@@ -91,6 +91,23 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
return glob_patterns
def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str:
# read template
ts_ignore_template = (
Path(__file__).parents[2] / "resources/templates/ts_ignore_template_blank.txt"
)
with open(ts_ignore_template) as f:
out = f.read()
# actual conversion
prefix = ""
if not is_exclude_list:
prefix = "!"
out += "*\n"
out += "\n".join([f"{prefix}*.{x.lstrip('.')}\n" for x in exts])
return out
class Ignore(metaclass=Singleton):
"""Class for processing and managing glob-like file ignore file patterns."""
+2 -2
View File
@@ -145,7 +145,7 @@ class RefreshTracker:
dir_file_count += 1
self.library.included_files.add(f)
if not self.library.has_path_entry(f):
if not self.library.has_entry_with_path(f):
self.files_not_in_library.append(f)
end_time_total = time()
@@ -190,7 +190,7 @@ class RefreshTracker:
relative_path = f.relative_to(library_dir)
if not self.library.has_path_entry(relative_path):
if not self.library.has_entry_with_path(relative_path):
self.files_not_in_library.append(relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
+1 -1
View File
@@ -61,7 +61,7 @@ class Tokenizer:
pos: int
current_char: str | None
ESCAPABLE_CHARS = ["\\", '"', '"']
ESCAPABLE_CHARS = ["\\", '"', "'"]
NOT_IN_ULITERAL = [":", " ", "[", "]", "(", ")", "=", ","]
def __init__(self, text: str) -> None:
+20 -177
View File
@@ -4,19 +4,13 @@
"""The core classes and methods of TagStudio."""
import json
import re
from functools import lru_cache
from pathlib import Path
import requests
import structlog
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.alchemy.fields import FieldID
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
@@ -27,184 +21,33 @@ class TagStudioCore:
def __init__(self):
self.lib: Library = Library()
@classmethod
def get_gdl_sidecar(cls, filepath: Path, source: str = "") -> dict:
"""Attempt to open and dump a Gallery-DL Sidecar file for the filepath.
Return a formatted object with notable values or an empty object if none is found.
"""
raise NotImplementedError("This method is currently broken and needs to be fixed.")
info = {}
_filepath = filepath.parent / (filepath.name + ".json")
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
# This may only occur with sidecar files that are downloaded separate from posts.
if source == "instagram" and not _filepath.is_file():
newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:]
_filepath = _filepath.parent / (newstem + ".json")
logger.info("get_gdl_sidecar", filepath=filepath, source=source, sidecar=_filepath)
try:
with open(_filepath, encoding="utf8") as f:
json_dump = json.load(f)
if not json_dump:
return {}
if source == "twitter":
info[FieldID.DESCRIPTION] = json_dump["content"].strip()
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
elif source == "instagram":
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
elif source == "artstation":
info[FieldID.TITLE] = json_dump["title"].strip()
info[FieldID.ARTIST] = json_dump["user"]["full_name"].strip()
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
info[FieldID.TAGS] = json_dump["tags"]
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
elif source == "newgrounds":
# info["title"] = json_dump["title"]
# info["artist"] = json_dump["artist"]
# info["description"] = json_dump["description"]
info[FieldID.TAGS] = json_dump["tags"]
info[FieldID.DATE_PUBLISHED] = json_dump["date"]
info[FieldID.ARTIST] = json_dump["user"].strip()
info[FieldID.DESCRIPTION] = json_dump["description"].strip()
info[FieldID.SOURCE] = json_dump["post_url"].strip()
except Exception:
logger.exception("Error handling sidecar file.", path=_filepath)
return info
# def scrape(self, entry_id):
# entry = self.lib.get_entry(entry_id)
# if entry.fields:
# urls: list[str] = []
# if self.lib.get_field_index_in_entry(entry, 21):
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
# for x in self.lib.get_field_index_in_entry(entry, 21)])
# if self.lib.get_field_index_in_entry(entry, 3):
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
# for x in self.lib.get_field_index_in_entry(entry, 3)])
# # try:
# if urls:
# for url in urls:
# url = "https://" + url if 'https://' not in url else url
# html_doc = requests.get(url).text
# soup = bs(html_doc, "html.parser")
# print(soup)
# input()
# # except:
# # # print("Could not resolve URL.")
# # pass
@classmethod
def match_conditions(cls, lib: Library, entry_id: int) -> bool:
"""Match defined conditions against a file to add Entry data."""
# TODO - what even is this file format?
# TODO: Make this stored somewhere better instead of temporarily in this JSON file.
cond_file = unwrap(lib.library_dir) / TS_FOLDER_NAME / "conditions.json"
if not cond_file.is_file():
return False
entry: Entry = unwrap(lib.get_entry(entry_id))
try:
with open(cond_file, encoding="utf8") as f:
json_dump = json.load(f)
for c in json_dump["conditions"]:
match: bool = False
for path_c in c["path_conditions"]:
if Path(path_c).is_relative_to(entry.path):
match = True
break
if not match:
return False
if not c.get("fields"):
return False
fields = c["fields"]
entry_field_types = {field.type_key: field for field in entry.fields}
for field in fields:
is_new = field["id"] not in entry_field_types
field_key = field["id"]
if is_new:
lib.add_field_to_entry(
entry.id, field_id=field_key, value=field["value"]
)
else:
lib.update_entry_field(entry.id, field_key, field["value"])
except Exception:
logger.exception("Error matching conditions.", entry=entry)
return False
@classmethod
def build_url(cls, entry: Entry, source: str):
"""Try to rebuild a source URL given a specific filename structure."""
source = source.lower().replace("-", " ").replace("_", " ")
if "twitter" in source:
return cls._build_twitter_url(entry)
elif "instagram" in source:
return cls._build_instagram_url(entry)
@classmethod
def _build_twitter_url(cls, entry: Entry):
"""Build a Twitter URL given a specific filename structure.
Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD'
"""
try:
stubs = str(entry.path.name).rsplit("_", 3)
url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}"
return url
except Exception:
logger.exception("Error building Twitter URL.", entry=entry)
return ""
@classmethod
def _build_instagram_url(cls, entry: Entry):
"""Build an Instagram URL given a specific filename structure.
Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD'
"""
try:
stubs = str(entry.path.name).rsplit("_", 2)
# stubs[0] = stubs[0].replace(f"{author}_", '', 1)
# print(stubs)
# NOTE: Both Instagram usernames AND their ID can have underscores in them,
# so unless you have the exact username (which can change) on hand to remove,
# your other best bet is to hope that the ID is only 11 characters long, which
# seems to more or less be the case... for now...
url = f"www.instagram.com/p/{stubs[-3][-11:]}"
return url
except Exception:
logger.exception("Error building Instagram URL.", entry=entry)
return ""
@staticmethod
@lru_cache(maxsize=1)
def get_most_recent_release_version() -> str:
"""Get the version of the most recent Github release."""
resp = requests.get("https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest")
assert resp.status_code == 200, "Could not fetch information on latest release."
def get_most_recent_release_version() -> str | None:
"""Get the version of the most recent GitHub release."""
try:
resp = requests.get(
"https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest"
)
except Exception as e:
logger.error("Error getting most recent GitHub release.", error=e)
return None
if resp.status_code != 200:
logger.error("Error getting most recent GitHub release.", status_code=resp.status_code)
return None
data = resp.json()
tag: str = data["tag_name"]
assert tag.startswith("v")
if not tag.startswith("v"):
logger.error("Unexpected tag format.", tag=tag)
return None
version = tag[1:]
# the assert does not allow for prerelease/build,
# the assertion does not allow for prerelease/build,
# because the latest release should never have them
assert re.match(r"^\d+\.\d+\.\d+$", version) is not None, "Invalid version format."
if re.match(r"^\d+\.\d+\.\d+$", version) is None:
logger.error("Invalid version format.", version=version)
return None
return version
@@ -65,7 +65,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
def update_stats(self):
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")
self.tag_count_label.setText(f"<b>{len(self.lib.tags)}</b>")
self.field_count_label.setText(f"<b>{len(self.lib.field_types)}</b>")
self.field_count_label.setText(f"<b>{len(self.lib.field_templates)}</b>")
self.namespaces_count_label.setText(f"<b>{len(self.lib.namespaces)}</b>")
colors_total = 0
for c in self.lib.tag_color_groups.values():
@@ -2,8 +2,9 @@ import structlog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox
from tagstudio.core.constants import VERSION
from tagstudio.core.constants import GITHUB_RELEASE_URL, VERSION
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
@@ -30,10 +31,13 @@ class OutOfDateMessageBox(QMessageBox):
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
latest_release_version = TagStudioCore.get_most_recent_release_version()
latest_release_version = unwrap(TagStudioCore.get_most_recent_release_version())
status = Translations.format(
"version_modal.status",
installed_version=f"<span style='color:{red}'>{VERSION}</span>",
latest_release_version=f"<span style='color:{green}'>{latest_release_version}</span>",
)
self.setText(f"{Translations['version_modal.description']}<br><br>{status}")
description = Translations.format(
"version_modal.description", github_url=GITHUB_RELEASE_URL
)
self.setText(f"{description}<br><br>{status}")
@@ -22,12 +22,15 @@ class PreviewPanel(PreviewPanelView):
self.__add_field_modal = AddFieldModal(self.lib)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
@typing.override
def _add_field_button_callback(self):
self.__add_field_modal.show()
@typing.override
def _add_tag_button_callback(self):
self.__add_tag_modal.show()
@typing.override
def _set_selection_callback(self):
with catch_warnings(record=True):
self.__add_field_modal.done.disconnect()
@@ -49,8 +49,8 @@ class PreviewThumb(PreviewThumbView):
stats.width = image.width
stats.height = image.height
except (
rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
rawpy.LibRawIOError,
rawpy.LibRawFileUnsupportedError,
FileNotFoundError,
):
pass
+1
View File
@@ -63,6 +63,7 @@ class GlobalSettings(BaseModel):
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_IMAGE_QUALITY)
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_IMAGE_RES)
autoplay: bool = Field(default=True)
scan_files_on_open: bool = Field(default=True)
loop: bool = Field(default=True)
show_filenames_in_grid: bool = Field(default=True)
page_size: int = Field(default=100)
+2 -1
View File
@@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.enums import Theme
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.previews.vendored import ffmpeg
from tagstudio.qt.resource_manager import ResourceManager
@@ -106,7 +107,7 @@ class AboutModal(QWidget):
# Version
version_title = QLabel("Version")
most_recent_release = TagStudioCore.get_most_recent_release_version()
most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN")
version_content_style = self.form_content_style
if most_recent_release == VERSION:
version_content = QLabel(f"{VERSION}")
+9 -4
View File
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.library.alchemy.library import Library
from tagstudio.qt.translations import Translations
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
logger = structlog.get_logger(__name__)
@@ -73,13 +73,18 @@ class AddFieldModal(QWidget):
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
@override
def show(self):
self.list_widget.clear()
for df in self.lib.field_types.values():
item = QListWidgetItem(f"{df.name} ({df.type.value})")
item.setData(Qt.ItemDataRole.UserRole, df.key)
for field_template in self.lib.field_templates:
field_name_key: str = FIELD_TYPE_KEYS.get(
field_template.class_name, "field_type.unknown"
)
item = QListWidgetItem(f"{field_template.name} ({Translations[field_name_key]})")
item.setData(Qt.ItemDataRole.UserRole, field_template)
self.list_widget.addItem(item)
self.list_widget.setFocus()
self.list_widget.setCurrentRow(0)
super().show()
+66 -55
View File
@@ -15,6 +15,7 @@ from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QListWidgetItem,
QMessageBox,
QScrollArea,
QSizePolicy,
@@ -23,9 +24,9 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.enums import Theme
from tagstudio.core.library.alchemy.enums import FieldTypeEnum
from tagstudio.core.library.alchemy.fields import (
BaseField,
BaseFieldTemplate,
DatetimeField,
TextField,
)
@@ -36,7 +37,7 @@ from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
from tagstudio.qt.mixed.field_widget import FieldContainer
from tagstudio.qt.mixed.text_field import TextWidget
from tagstudio.qt.translations import Translations
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
from tagstudio.qt.views.panel_modal import PanelModal
@@ -205,7 +206,7 @@ class FieldContainers(QWidget):
def remove_field_prompt(self, name: str) -> str:
return Translations.format("library.field.confirm_remove", name=name)
def add_field_to_selected(self, field_list: list):
def add_field_to_selected(self, field_list: list[QListWidgetItem]):
"""Add list of entry fields to one or more selected items.
Uses the current driver selection, NOT the field containers cache.
@@ -216,11 +217,14 @@ class FieldContainers(QWidget):
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),
for field in field_list:
template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole)
logger.info(
"[FieldContainers][add_field_to_selected] Adding field",
name=template.name,
type=template.class_name,
)
self.lib.add_field_to_entries(entry_id, template.to_field())
def add_tags_to_selected(self, tags: int | list[int]):
"""Add list of tags to one or more selected items.
@@ -250,7 +254,12 @@ class FieldContainers(QWidget):
If True, field is not present in all selected items.
"""
logger.info("[FieldContainers][write_field_container]", index=index)
logger.info(
"[FieldContainers][write_container]",
index=index,
name=field.name,
type=field.class_name,
)
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
@@ -258,8 +267,13 @@ class FieldContainers(QWidget):
else:
container = self.containers[index]
if field.type.type == FieldTypeEnum.TEXT_LINE:
container.set_title(field.type.name)
# Set field title
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
title = f"{field.name} ({Translations[field_name_key]})"
# Single-line Text
if type(field) is TextField and not field.is_multiline:
container.set_title(field.name)
container.set_inline(False)
# Normalize line endings in any text content.
@@ -267,19 +281,18 @@ class FieldContainers(QWidget):
assert isinstance(field.value, str | type(None))
text = field.value or ""
else:
text = "<i>Mixed Data</i>"
text = "<i>Mixed Data</i>" # TODO: Localize this
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=(
window_title=f"Edit {field.name}", # TODO: Localize this
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_field(field, content), # type: ignore
self.update_text_field(field, content, is_multiline=False),
self.update_from_entry(self.cached_entries[0].id),
)
),
@@ -291,7 +304,7 @@ class FieldContainers(QWidget):
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.type.value),
prompt=self.remove_field_prompt(title),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
@@ -299,26 +312,26 @@ class FieldContainers(QWidget):
)
)
elif field.type.type == FieldTypeEnum.TEXT_BOX:
container.set_title(field.type.name)
# Multiline Text
elif type(field) is TextField and field.is_multiline:
container.set_title(field.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)"
text = "<i>Mixed Data</i>" # TODO: Localize this
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=(
window_title=f"Edit {field.name}", # TODO: Localize this
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_field(field, content), # type: ignore
self.update_text_field(field, content, is_multiline=True),
self.update_from_entry(self.cached_entries[0].id),
)
),
@@ -326,7 +339,7 @@ class FieldContainers(QWidget):
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.name),
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
@@ -334,20 +347,18 @@ class FieldContainers(QWidget):
)
)
elif field.type.type == FieldTypeEnum.DATETIME:
elif type(field) is DatetimeField:
logger.info("[FieldContainers][write_container] Datetime Field", field=field)
if not is_mixed:
container.set_title(field.type.name)
container.set_title(field.name)
container.set_inline(False)
title = f"{field.type.name} (Date)"
try:
assert field.value is not None
text = self.driver.settings.format_datetime(
DatetimePicker.string2dt(field.value)
)
except (ValueError, AssertionError):
title += " (Unknown Format)"
text = str(field.value)
inner_widget = TextWidget(title, text)
@@ -355,10 +366,10 @@ class FieldContainers(QWidget):
modal = PanelModal(
DatetimePicker(self.driver, field.value or dt.now()),
title=f"Edit {field.type.name}",
save_callback=(
title=f"Edit {field.name}",
save_callback=( # pyright: ignore[reportArgumentType]
lambda content: (
self.update_field(field, content), # type: ignore
self.update_datetime_field(field, content),
self.update_from_entry(self.cached_entries[0].id),
)
),
@@ -367,7 +378,7 @@ class FieldContainers(QWidget):
container.set_edit_callback(modal.show)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.name),
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
@@ -375,20 +386,20 @@ class FieldContainers(QWidget):
)
)
else:
text = "<i>Mixed Data</i>"
title = f"{field.type.name} (Wacky Date)"
text = "<i>Mixed Data</i>" # TODO: Localize this
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)
logger.warning(
"[FieldContainers][write_container] Unknown Field", field=field
) # TODO: Localize this
container.set_title(field.name)
container.set_inline(False)
title = f"{field.type.name} (Unknown Field Type)"
inner_widget = TextWidget(title, field.type.name)
inner_widget = TextWidget(title, field.name)
container.set_inner_widget(inner_widget)
container.set_remove_callback(
lambda: self.remove_message_box(
prompt=self.remove_field_prompt(field.type.name),
prompt=self.remove_field_prompt(field.name),
callback=lambda: (
self.remove_field(field),
self.update_from_entry(self.cached_entries[0].id),
@@ -419,7 +430,9 @@ class FieldContainers(QWidget):
else:
container = self.containers[index]
container.set_title("Tags" if not category_tag else category_tag.name)
container.set_title(
"Tags" if not category_tag else category_tag.name
) # TODO: Localize this
container.set_inline(False)
if not is_mixed:
@@ -431,7 +444,7 @@ class FieldContainers(QWidget):
else:
inner_widget = TagBoxWidget(
"Tags",
"Tags", # TODO: Localize this
self.driver,
)
container.set_inner_widget(inner_widget)
@@ -460,26 +473,24 @@ class FieldContainers(QWidget):
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)}"
def update_text_field(self, field: TextField, value: str, is_multiline: bool):
"""Update a text field across selected entries."""
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,
)
self.lib.update_text_field(entry_ids, field, value, is_multiline)
def update_datetime_field(self, field: DatetimeField, value: str):
"""Update a datetime field across selected entries."""
entry_ids = [e.id for e in self.cached_entries]
assert entry_ids, "No entries selected"
self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value))
def remove_message_box(self, prompt: str, callback: Callable) -> None:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle("Remove Field")
remove_mb.setWindowTitle("Remove Field") # TODO: Localize
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton(
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
+50 -62
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import cast
import structlog
import wcmatch.fnmatch as fnmatch
from PySide6.QtCore import QObject, Qt, QThreadPool, Signal
from PySide6.QtWidgets import (
QApplication,
@@ -24,18 +25,20 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from tagstudio.core.constants import (
IGNORE_NAME,
LEGACY_TAG_FIELD_IDS,
TAG_ARCHIVED,
TAG_FAVORITE,
TAG_META,
TS_FOLDER_NAME,
)
from tagstudio.core.enums import LibraryPrefs
from tagstudio.core.library.alchemy import default_color_groups
from tagstudio.core.library.alchemy.constants import SQL_FILENAME
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.library import Library as SqliteLibrary
from tagstudio.core.library.alchemy.models import Entry, TagAlias
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.library.json.library import Tag as JsonTag
from tagstudio.core.utils.types import unwrap
@@ -72,8 +75,6 @@ class JsonMigrationModal(QObject):
self.old_entry_count: int = 0
self.old_tag_count: int = 0
self.old_ext_count: int = 0
self.old_ext_type: bool = None # pyright: ignore[reportAttributeAccessIssue]
self.field_parity: bool = False
self.path_parity: bool = False
@@ -82,6 +83,7 @@ class JsonMigrationModal(QObject):
self.subtag_parity: bool = False
self.alias_parity: bool = False
self.color_parity: bool = False
self.ext_parity: bool = False
self.init_page_info()
self.init_page_convert()
@@ -129,8 +131,7 @@ class JsonMigrationModal(QObject):
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"]
ext_type_text: str = Translations["json_migration.heading.extension_list_type"]
ext_parity_text: str = Translations["json_migration.heading.extensions"]
desc_text: str = Translations["json_migration.description"]
path_parity_text: str = tab + Translations["json_migration.heading.paths"]
field_parity_text: str = tab + Translations["library_info.stats.fields"]
@@ -145,7 +146,6 @@ class JsonMigrationModal(QObject):
self.aliases_row: int = 7
self.colors_row: int = 8
self.ext_row: int = 9
self.ext_type_row: int = 10
old_lib_container: QWidget = QWidget()
old_lib_layout: QVBoxLayout = QVBoxLayout(old_lib_container)
@@ -166,8 +166,7 @@ class JsonMigrationModal(QObject):
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)
self.old_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0)
self.old_content_layout.addWidget(QLabel(ext_parity_text), self.ext_row, 0)
old_entry_count: QLabel = QLabel()
old_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -187,10 +186,8 @@ class JsonMigrationModal(QObject):
old_alias_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_color_value: QLabel = QLabel()
old_color_value.setAlignment(Qt.AlignmentFlag.AlignRight)
old_ext_count: QLabel = QLabel()
old_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight)
old_ext_type: QLabel = QLabel()
old_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight)
old_ext_value: QLabel = QLabel()
old_ext_value.setAlignment(Qt.AlignmentFlag.AlignRight)
self.old_content_layout.addWidget(old_entry_count, self.entries_row, 1)
self.old_content_layout.addWidget(old_path_value, self.path_row, 1)
@@ -201,8 +198,7 @@ class JsonMigrationModal(QObject):
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)
self.old_content_layout.addWidget(old_ext_type, self.ext_type_row, 1)
self.old_content_layout.addWidget(old_ext_value, self.ext_row, 1)
self.old_content_layout.addWidget(QLabel(), self.path_row, 2)
self.old_content_layout.addWidget(QLabel(), self.fields_row, 2)
@@ -211,6 +207,7 @@ class JsonMigrationModal(QObject):
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)
self.old_content_layout.addWidget(QLabel(), self.ext_row, 2)
old_lib_layout.addWidget(old_content_container)
@@ -233,8 +230,7 @@ class JsonMigrationModal(QObject):
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)
self.new_content_layout.addWidget(QLabel(ext_type_text), self.ext_type_row, 0)
self.new_content_layout.addWidget(QLabel(ext_parity_text), self.ext_row, 0)
new_entry_count: QLabel = QLabel()
new_entry_count.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -254,10 +250,8 @@ class JsonMigrationModal(QObject):
alias_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight)
new_color_value: QLabel = QLabel()
new_color_value.setAlignment(Qt.AlignmentFlag.AlignRight)
new_ext_count: QLabel = QLabel()
new_ext_count.setAlignment(Qt.AlignmentFlag.AlignRight)
new_ext_type: QLabel = QLabel()
new_ext_type.setAlignment(Qt.AlignmentFlag.AlignRight)
ext_parity_value: QLabel = QLabel()
ext_parity_value.setAlignment(Qt.AlignmentFlag.AlignRight)
self.new_content_layout.addWidget(new_entry_count, self.entries_row, 1)
self.new_content_layout.addWidget(path_parity_value, self.path_row, 1)
@@ -268,8 +262,7 @@ class JsonMigrationModal(QObject):
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)
self.new_content_layout.addWidget(new_ext_type, self.ext_type_row, 1)
self.new_content_layout.addWidget(ext_parity_value, self.ext_row, 1)
self.new_content_layout.addWidget(QLabel(), self.entries_row, 2)
self.new_content_layout.addWidget(QLabel(), self.path_row, 2)
@@ -281,7 +274,6 @@ class JsonMigrationModal(QObject):
self.new_content_layout.addWidget(QLabel(), self.aliases_row, 2)
self.new_content_layout.addWidget(QLabel(), self.colors_row, 2)
self.new_content_layout.addWidget(QLabel(), self.ext_row, 2)
self.new_content_layout.addWidget(QLabel(), self.ext_type_row, 2)
new_lib_layout.addWidget(new_content_container)
@@ -334,8 +326,6 @@ class JsonMigrationModal(QObject):
# Update JSON UI
self.update_json_entry_count(len(self.json_lib.entries))
self.update_json_tag_count(len(self.json_lib.tags))
self.update_json_ext_count(len(self.json_lib.ext_list))
self.update_json_ext_type(self.json_lib.is_exclude_list)
self.migration_progress(skip_ui=skip_ui)
self.is_migration_initialized = True
@@ -410,11 +400,12 @@ class JsonMigrationModal(QObject):
self.temp_path: Path = (
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
)
self.sql_lib.storage_path = self.temp_path
if self.temp_path.exists():
logger.info('Temporary migration file "temp_path" already exists. Removing...')
self.temp_path.unlink()
self.sql_lib.open_sqlite_library(self.json_lib.library_dir, is_new=True)
self.sql_lib.open_sqlite_library(
self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path)
)
yield Translations.format(
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
)
@@ -428,6 +419,7 @@ class JsonMigrationModal(QObject):
check_set.add(self.check_subtag_parity())
check_set.add(self.check_alias_parity())
check_set.add(self.check_color_parity())
check_set.add(self.check_ignore_parity())
if False not in check_set:
yield Translations["json_migration.migration_complete"]
else:
@@ -454,6 +446,7 @@ class JsonMigrationModal(QObject):
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.update_parity_value(self.ext_row, self.ext_parity)
self.sql_lib.close()
def update_sql_value_ui(self, show_msg_box: bool = True):
@@ -468,16 +461,6 @@ class JsonMigrationModal(QObject):
len(self.sql_lib.tags),
self.old_tag_count,
)
self.update_sql_value(
self.ext_row,
len(self.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)),
self.old_ext_count,
)
self.update_sql_value(
self.ext_type_row,
self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST), # pyright: ignore[reportArgumentType]
self.old_ext_type,
)
logger.info("Parity check complete!")
if self.discrepancies:
logger.warning("Discrepancies found:")
@@ -509,16 +492,6 @@ class JsonMigrationModal(QObject):
label: QLabel = self.old_content_layout.itemAtPosition(self.tags_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_json_ext_count(self, value: int):
self.old_ext_count = value
label: QLabel = self.old_content_layout.itemAtPosition(self.ext_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_json_ext_type(self, value: bool):
self.old_ext_type = value
label: QLabel = self.old_content_layout.itemAtPosition(self.ext_type_row, 1).widget() # type:ignore
label.setText(self.color_value_default(value))
def update_sql_value(self, row: int, value: int | bool, old_value: int | bool):
label: QLabel = self.new_content_layout.itemAtPosition(row, 1).widget() # type:ignore
warning_icon: QLabel = self.new_content_layout.itemAtPosition(row, 2).widget() # type:ignore
@@ -547,12 +520,31 @@ class JsonMigrationModal(QObject):
color = green if old_value == new_value else red
return str(f"<b><a style='color: {color}'>{new_value}</a></b>")
def assert_ignore_parity(self) -> None:
compiled_pats = fnmatch.compile(
ignore_to_glob(
Ignore._load_ignore_file(
unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME
)
),
PATH_GLOB_FLAGS,
) # copied from Ignore.get_patterns since that method modifies singleton state
path = self.json_lib.library_dir / "filename"
for ext in self.json_lib.ext_list:
assert compiled_pats.match(str(path / ext)) == self.json_lib.is_exclude_list
assert compiled_pats.match(str(path / ".not_a_real_ext")) != self.json_lib.is_exclude_list
def check_ignore_parity(self) -> bool:
try:
self.assert_ignore_parity()
self.ext_parity = True
except AssertionError:
self.ext_parity = False
return self.ext_parity
def check_field_parity(self) -> bool:
"""Check if all JSON field and tag data matches the new SQL data."""
def sanitize_field(entry: Entry, value, type, type_key):
return value if value else None
def sanitize_json_field(value):
if isinstance(value, list):
return set(value) if value else None
@@ -563,7 +555,7 @@ class JsonMigrationModal(QObject):
sql_fields: list[tuple] = []
json_fields: list[tuple] = []
sql_entry: Entry = unwrap(self.sql_lib.get_entry_full(json_entry.id + 1))
sql_entry: Entry | None = self.sql_lib.get_entry_full(json_entry.id + 1)
if not sql_entry:
logger.info(
"[Field Comparison]",
@@ -576,14 +568,13 @@ class JsonMigrationModal(QObject):
return self.field_parity
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(sql_entry, sf.value, sf.type.type, sf.type_key),
)
sql_fields.append(
(
sql_entry.id,
sf.name.upper().replace(" ", "_"),
sf.value if sf.value else None,
)
)
sql_fields.sort()
# NOTE: The JSON database stored tags inside of special "tag field" types which
@@ -597,7 +588,7 @@ class JsonMigrationModal(QObject):
tags_count += 1
json_tags = json_tags.union(value or [])
else:
key: str = unwrap(self.sql_lib.get_field_name_from_id(int_key)).name
key: str = str(LEGACY_FIELD_MAP[int_key]["name"]).upper().replace(" ", "_")
json_fields.append((json_entry.id + 1, key, value))
json_fields.sort()
@@ -670,9 +661,6 @@ class JsonMigrationModal(QObject):
self.subtag_parity = True
return self.subtag_parity
def check_ext_type(self) -> bool:
return self.json_lib.is_exclude_list == self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST)
def check_alias_parity(self) -> bool:
"""Check if all JSON aliases match the new SQL aliases."""
with Session(self.sql_lib.engine) as session:
@@ -95,7 +95,7 @@ class MirrorEntriesModal(QWidget):
mirrored: list = []
lib = self.driver.lib
for i, entries in enumerate(self.tracker.groups):
lib.mirror_entry_fields(*entries)
lib.mirror_entry_fields(entries)
sleep(0.005)
yield i
+9
View File
@@ -176,6 +176,13 @@ class SettingsPanel(PanelWidget):
self.autoplay_checkbox.setChecked(self.driver.settings.autoplay)
form_layout.addRow(Translations["media_player.autoplay"], self.autoplay_checkbox)
# Scan for new files when a library is opened
self.scan_files_on_open_checkbox = QCheckBox()
self.scan_files_on_open_checkbox.setChecked(self.driver.settings.scan_files_on_open)
form_layout.addRow(
Translations["settings.scan_files_on_open"], self.scan_files_on_open_checkbox
)
# Show Filenames in Grid
self.show_filenames_checkbox = QCheckBox()
self.show_filenames_checkbox.setChecked(self.driver.settings.show_filenames_in_grid)
@@ -295,6 +302,7 @@ class SettingsPanel(PanelWidget):
MIN_THUMB_CACHE_SIZE,
),
"autoplay": self.autoplay_checkbox.isChecked(),
"scan_files_on_open": self.scan_files_on_open_checkbox.isChecked(),
"show_filenames_in_grid": self.show_filenames_checkbox.isChecked(),
"page_size": int(self.page_size_line_edit.text()),
"infinite_scroll": self.infinite_scroll.isChecked(),
@@ -313,6 +321,7 @@ class SettingsPanel(PanelWidget):
driver.settings.language = settings["language"]
driver.settings.open_last_loaded_on_startup = settings["open_last_loaded_on_startup"]
driver.settings.autoplay = settings["autoplay"]
driver.settings.scan_files_on_open = settings["scan_files_on_open"]
driver.settings.generate_thumbs = settings["generate_thumbs"]
driver.settings.thumb_cache_size = settings["thumb_cache_size"]
driver.settings.show_filenames_in_grid = settings["show_filenames_in_grid"]
+11 -38
View File
@@ -3,7 +3,6 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import contextlib
from typing import TYPE_CHECKING, Union
from warnings import catch_warnings
@@ -107,9 +106,6 @@ class TagSearchPanel(PanelWidget):
self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items])
self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx)
self.limit_combobox.currentIndexChanged.connect(self.update_limit)
self.previous_limit: int = (
TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
)
self.limit_layout.addWidget(self.limit_combobox)
self.limit_layout.addStretch(1)
@@ -218,32 +214,13 @@ class TagSearchPanel(PanelWidget):
self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget().deleteLater()
self.create_button_in_layout = False
# Get results for the search query
query_lower = "" if not query else query.lower()
# Only use the tag limit if it's an actual number (aka not "All Tags")
tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
tag_results: list[set[Tag]] = self.lib.search_tags(name=query, limit=tag_limit)
if self.exclude:
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}
direct_tags, descendant_tags = self.lib.search_tags(name=query, limit=tag_limit)
# Sort and prioritize the results
results_0 = list(tag_results[0])
results_0.sort(key=lambda tag: tag.name.lower())
results_1 = list(tag_results[1])
results_1.sort(key=lambda tag: tag.name.lower())
raw_results = list(results_0 + results_1)
priority_results: set[Tag] = set()
all_results: list[Tag] = []
all_results = [t for t in direct_tags if t.id not in self.exclude]
all_results.extend(t for t in descendant_tags if t.id not in self.exclude)
if query and query.strip():
for tag in raw_results:
if tag.name.lower().startswith(query_lower):
priority_results.add(tag)
all_results = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [
r for r in raw_results if r not in priority_results
]
if tag_limit > 0:
all_results = all_results[:tag_limit]
@@ -255,15 +232,11 @@ class TagSearchPanel(PanelWidget):
self.first_tag_id = None
# Update every tag widget with the new search result data
norm_previous = self.previous_limit if self.previous_limit > 0 else len(self.lib.tags)
norm_limit = tag_limit if tag_limit > 0 else len(self.lib.tags)
range_limit = max(norm_previous, norm_limit)
for i in range(0, range_limit):
tag = None
with contextlib.suppress(IndexError):
tag = all_results[i]
self.set_tag_widget(tag=tag, index=i)
self.previous_limit = tag_limit
for i in range(0, len(all_results)):
tag = all_results[i]
self.set_tag_widget(tag, i)
for i in range(len(all_results), self.scroll_layout.count()):
self.set_tag_widget(None, i)
# Add back the "Create & Add" button
if query and query.strip():
@@ -326,6 +299,9 @@ class TagSearchPanel(PanelWidget):
def update_limit(self, index: int):
logger.info("[TagSearchPanel] Updating tag limit")
if TagSearchPanel.cur_limit_idx == index:
return
TagSearchPanel.cur_limit_idx = index
if index < len(self._limit_items) - 1:
@@ -337,9 +313,6 @@ class TagSearchPanel(PanelWidget):
if index != self.limit_combobox.currentIndex():
self.limit_combobox.setCurrentIndex(index)
if self.previous_limit == TagSearchPanel.tag_limit:
return
self.update_tags(self.search_field.text())
def on_return(self, text: str):
+2 -2
View File
@@ -1121,8 +1121,8 @@ class ThumbRenderer(QObject):
)
except (
DecompressionBombError,
rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
rawpy.LibRawIOError,
rawpy.LibRawFileUnsupportedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+8
View File
@@ -39,6 +39,14 @@ LANGUAGES = {
"Viossa": "qpv",
}
# A map of field class names to their respective translation keys.
FIELD_TYPE_KEYS = {
"DatetimeField": "field_type.datetime",
"DatetimeFieldTemplate": "field_type.datetime",
"TextField": "field_type.text",
"TextFieldTemplate": "field_type.text",
}
class Translator:
_default_strings: dict[str, str]
+5 -34
View File
@@ -52,10 +52,8 @@ from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import MacroID, SettingItems, ShowFilepathOption
from tagstudio.core.library.alchemy.enums import (
BrowsingState,
FieldTypeEnum,
SortingModeEnum,
)
from tagstudio.core.library.alchemy.fields import FieldID
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
@@ -63,7 +61,7 @@ from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.query_lang.util import ParsingError
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.str_formatting import is_version_outdated, strip_web_protocol
from tagstudio.core.utils.str_formatting import is_version_outdated
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.cache_manager import CacheManager
from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox
@@ -613,7 +611,8 @@ class QtDriver(DriverMixin, QObject):
if not which(FFMPEG_CMD) or not which(FFPROBE_CMD):
FfmpegMissingMessageBox().show()
if is_version_outdated(VERSION, TagStudioCore.get_most_recent_release_version()):
latest_version = TagStudioCore.get_most_recent_release_version()
if latest_version and is_version_outdated(VERSION, latest_version):
OutOfDateMessageBox().exec()
self.app.exec()
@@ -1123,7 +1122,6 @@ class QtDriver(DriverMixin, QObject):
def run_macro(self, name: MacroID, entry_id: int):
"""Run a specific Macro on an Entry given a Macro name."""
entry: Entry = unwrap(self.lib.get_entry(entry_id))
full_path = unwrap(self.lib.library_dir) / entry.path
source = "" if entry.path.parent == Path(".") else entry.path.parts[0].lower()
logger.info(
@@ -1140,32 +1138,6 @@ class QtDriver(DriverMixin, QObject):
continue
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_field_to_entry(
entry.id,
field_id=field_id,
value=value,
)
elif name == MacroID.BUILD_URL:
url = TagStudioCore.build_url(entry, source)
if url is not None:
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:
for field in entry.text_fields:
if field.type.type == FieldTypeEnum.TEXT_LINE and field.value:
self.lib.update_entry_field(
entry_ids=entry.id,
field=field,
content=strip_web_protocol(field.value),
)
def sorting_direction_callback(self):
logger.info("Sorting Direction Changed", ascending=self.main_window.sorting_direction)
self.update_browsing_state(
@@ -1257,7 +1229,7 @@ class QtDriver(DriverMixin, QObject):
if field.type_key == e.type_key and field.value == e.value:
exists = True
if not exists:
self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value)
self.lib.add_field_to_entries(id, field_id=field.type_key, value=field.value)
self.lib.add_tags_to_entries(id, self.copy_buffer["tags"])
if len(self.selected) > 1:
if TAG_ARCHIVED in self.copy_buffer["tags"]:
@@ -1649,8 +1621,7 @@ class QtDriver(DriverMixin, QObject):
Ignore.get_patterns(self.lib.library_dir, include_global=True)
self.__reset_navigation()
# TODO - make this call optional
if self.lib.entries_count < 10000:
if self.settings.scan_files_on_open:
self.add_new_files_callback()
if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS:
@@ -0,0 +1,46 @@
{
"about.config_path": "የመዋቅር መንገድ",
"about.description": "ታግስቱዲዮ የፎቶ እና ፋይል አደረጃጀት በ ታግ መሰረት ላይ የተሰራ ሲስተም ሲሆን ትኩረቱን ለተጠቃሚዎች ነፃነት እና እንደሁኔታዎች ለመቀያየር ምቹ የሆነ መተግበሪያ ነው። ባለቤት የሌላቸው ኮድ ወይም ፎርማት፣ ብዙ ትናንሽ ሜታ ዳታ ፋይል የማይፈጥር እና የፋይል አደረጃጀት ቅርፆ የማይቀይር ነው።",
"about.documentation": "ሰነዶች",
"about.license": "ፈቃድ",
"about.module.found": "ተገኘ",
"about.title": "ስለ ታግስቱዲዮ",
"about.website": "ድህረገፅ",
"app.git": "ጊት ኮሚት",
"app.pre_release": "ቅድመ መለቀቅ",
"app.title": "{base_title} - ላይብረሪ'{library_dir}",
"color.color_border": "ለድንበር ሁለተኛ ቀለም ተጠቀም",
"color.confirm_delete": "\"{color_name}\" ይህን ቀለም ለማጥፋት ወስነሃል?",
"color.delete": "ታግ አጥፋ",
"color.import_pack": "የቀለም እሽግ አስገባ",
"color.name": "ስም",
"color.namespace.delete.prompt": "ይህን የቀለም ምድብ ለማጥፋት ወስነሃል? ይህን ካጠፋህ ሁሉንም ቀለሞች ምድብ ውስጥ ያሉትን አንድ ላይ ያጠፋል!",
"color.namespace.delete.title": "አጥፋ የቀለም ምድብ",
"color.new": "አዲስ ቀለም",
"color.placeholder": "ቀለም",
"color.primary": "ዋና ቀለም",
"color.primary_required": "ዋና ቀለም (አስፈላጊ)",
"color.secondary": "ሁለተኛ ቀለም",
"color.title.no_color": "ቀለም አልባ",
"color_manager.title": "ታግ ቀለሞች አስተዳደር",
"dependency.missing.title": "{dependency} አልተገነኘም",
"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": "አዲስ ፋይሎች በማስገባት ላይ...\n1 ፋይል ገብቷል.{suffix}",
"drop_import.progress.window_title": "ፋይሎች አስገባ",
"drop_import.title": "የሚጋጩ ፋይል(ሎች)",
"edit.color_manager": "የታግ ቀለሞችን አስተዳድር",
"edit.copy_fields": "ኮፒ ፊልዶች",
"edit.paste_fields": "ፔስት ፊልድስ",
"edit.tag_manager": "ታጎች አስተዳድር",
"entries.duplicate.merge": "ተደጋጋሚ መረጃ አዋህድ",
"entries.duplicate.merge.label": "ተደጋጋሚ መረጃ ማዋሃድ ላይ...",
"entries.duplicate.refresh": "ተደጋጋሚ መረጃ ማደስ",
"entries.duplicates.description": "ተደጋጋሚ መረጃዎች ማለት ከአንድ በላይ መረጃዎች ዲስክ ላይ ወዳለ አንድ ፋይል የሚጠቁሙ ከሆነ ነው። እነዚህን ማዋሃድ ታጎችን እና ሜታዳታ ከሁሉ ተደጋጋዊ ፋይሎች ወደ አንድ ይቀይረዋል። ይህ ከ \"ተደጋጋሚ ፋይሎች\" ጋር አንድ አይደለም፤ ይህም ተደጋጋሚ ፋይሎች ከታግ ስቱዲዮ ውጪ የሆኑ ናቸው።",
"entries.generic.refresh_alt": "&ማደስ",
"entries.generic.remove.removing": "መረጃዎች ማጥፋት ላይ",
"entries.generic.remove.removing_count": "{count} መረጃዎች ማጥፋት ላይ ..."
}
+4 -3
View File
@@ -74,6 +74,9 @@
"field.copy": "Feld kopieren",
"field.edit": "Feld bearbeiten",
"field.paste": "Feld einfügen",
"field_type.datetime": "Datum - Uhrzeit",
"field_type.text": "Text",
"field_type.unknown": "Unbekannter Typ",
"file.date_added": "Hinzufügungsdatum",
"file.date_created": "Erstellungsdatum",
"file.date_modified": "Datum geändert",
@@ -157,8 +160,6 @@
"json_migration.heading.aliases": "Aliase:",
"json_migration.heading.colors": "Farben:",
"json_migration.heading.differ": "Diskrepanz",
"json_migration.heading.extension_list_type": "Erweiterungslistentyp:",
"json_migration.heading.file_extension_list": "Liste der Dateiendungen:",
"json_migration.heading.match": "Übereinstimmend",
"json_migration.heading.names": "Namen:",
"json_migration.heading.parent_tags": "Übergeordnete Tags:",
@@ -350,7 +351,7 @@
"trash.dialog.title.singular": "Datei löschen",
"trash.name.generic": "Mülleimer",
"trash.name.windows": "Papierkorb",
"version_modal.description": "Eine neue Version von TagStudio ist verfügbar! Du kannst die neueste Version auf <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">GitHub</a> herunterladen.",
"version_modal.description": "Eine neue Version von TagStudio ist verfügbar! Du kannst die neueste Version auf <a href=\"{github_url}\">GitHub</a> herunterladen.",
"version_modal.status": "Installierte Version: {installed_version}<br>Letzte veröffentlichte Version: {latest_release_version}",
"version_modal.title": "TagStudio Aktualisierung verfügbar",
"view.size.0": "Mini",
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "Ψευδώνυμα:",
"json_migration.heading.colors": "Χρώματα:",
"json_migration.heading.differ": "Ασυμφωνία",
"json_migration.heading.extension_list_type": "Τύπος λίστας επεκτάσεων:",
"json_migration.heading.file_extension_list": "Λίστα επεκτάσεων αρχείων:",
"json_migration.heading.match": "Αντιστοιχίστηκε",
"json_migration.heading.names": "Όνομα:"
}
+6 -3
View File
@@ -71,6 +71,9 @@
"entries.unlinked.unlinked_count": "Unlinked Entries: {count}",
"ffmpeg.missing.description": "FFmpeg and/or FFprobe were not found. FFmpeg is required for multimedia playback and thumbnails.",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
"field_type.datetime": "Datetime",
"field_type.text": "Text",
"field_type.unknown": "Unknown Type",
"field.copy": "Copy Field",
"field.edit": "Edit Field",
"field.paste": "Paste Field",
@@ -157,8 +160,7 @@
"json_migration.heading.aliases": "Aliases:",
"json_migration.heading.colors": "Colors:",
"json_migration.heading.differ": "Discrepancy",
"json_migration.heading.extension_list_type": "Extension List Type:",
"json_migration.heading.file_extension_list": "File Extension List:",
"json_migration.heading.extensions": "Extensions:",
"json_migration.heading.match": "Matched",
"json_migration.heading.names": "Names:",
"json_migration.heading.parent_tags": "Parent Tags:",
@@ -273,6 +275,7 @@
"settings.open_library_on_start": "Open Library on Start",
"settings.page_size": "Page Size",
"settings.restart_required": "Please restart TagStudio for changes to take effect.",
"settings.scan_files_on_open": "Automatically Load New Files",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
"settings.show_recent_libraries": "Show Recent Libraries",
"settings.splash.label": "Splash Screen",
@@ -351,7 +354,7 @@
"trash.name.generic": "Trash",
"trash.name.windows": "Recycle Bin",
"version_modal.title": "TagStudio Update Available",
"version_modal.description": "A new version of TagStudio is available! You can download the latest release from <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">Github</a>.",
"version_modal.description": "A new version of TagStudio is available! You can download the latest release from <a href=\"{github_url}\">GitHub</a>.",
"version_modal.status": "Installed Version: {installed_version}<br>Latest Release Version: {latest_release_version}",
"view.size.0": "Mini",
"view.size.1": "Small",
+1 -3
View File
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "Alias:",
"json_migration.heading.colors": "Colores:",
"json_migration.heading.differ": "Discrepancia",
"json_migration.heading.extension_list_type": "Tipo de lista de extensión:",
"json_migration.heading.file_extension_list": "Lista de extensiones de archivos:",
"json_migration.heading.match": "Igualado",
"json_migration.heading.names": "Nombres:",
"json_migration.heading.parent_tags": "Etiquetas principales:",
@@ -350,7 +348,7 @@
"trash.dialog.title.singular": "Eliminar archivo",
"trash.name.generic": "Basura",
"trash.name.windows": "Papelera de reciclaje",
"version_modal.description": "¡Ya está disponible una nueva versión de TagStudio! Puedes descargar la última versión desde <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">Github</a>.",
"version_modal.description": "¡Ya está disponible una nueva versión de TagStudio! Puedes descargar la última versión desde <a href=\"{github_url}\">Github</a>.",
"version_modal.status": "Versión Instalada: {installed_version}<br>Última Versión Publicada: {latest_release_version}",
"version_modal.title": "Actualización de TagStudio disponible",
"view.size.0": "Mini",
+1 -3
View File
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "Aliaksia:",
"json_migration.heading.colors": "Värit:",
"json_migration.heading.differ": "Poikkeavuus",
"json_migration.heading.extension_list_type": "Laajennusluettelon tyyppi:",
"json_migration.heading.file_extension_list": "Tiedostopääte lista:",
"json_migration.heading.match": "Yhdistetty",
"json_migration.heading.names": "Nimet:",
"json_migration.heading.parent_tags": "Päätunnukset:",
@@ -309,7 +307,7 @@
"trash.dialog.title.singular": "Poista tiedosto",
"trash.name.generic": "Roskakori",
"trash.name.windows": "Roskakori",
"version_modal.description": "TagStudion uusi versio on saatavilla! Voit ladata uusimman version <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">Githubista</a>.",
"version_modal.description": "TagStudion uusi versio on saatavilla! Voit ladata uusimman version <a href=\"{github_url}\">Githubista</a>.",
"version_modal.status": "Asennettu versio: {installed_version}<br>Uusin julkaisuversio: {latest_release_version}",
"version_modal.title": "TagStudio päivitys saatavilla",
"view.size.0": "Mini",
@@ -140,8 +140,6 @@
"json_migration.heading.aliases": "Mga alyas:",
"json_migration.heading.colors": "Mga kulay:",
"json_migration.heading.differ": "May pagkakaiba",
"json_migration.heading.extension_list_type": "Uri ng Listahan ng Extension:",
"json_migration.heading.file_extension_list": "Listahan ng Mga File Extension:",
"json_migration.heading.match": "Tumutugma",
"json_migration.heading.names": "Mga pangalan:",
"json_migration.heading.parent_tags": "Mga parent tag:",
+23 -24
View File
@@ -1,9 +1,9 @@
{
"about.config_path": "Chemin de Configuration",
"about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui mets en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.",
"about.description": "TagStudio est une application d'organisation de photos et de fichiers avec un système de tags qui met en avant la liberté et flexibilité à l'utilisateur. Pas de programmes ou de formats propriétaires, pas la moindre trace de fichiers secondaires, et pas de bouleversement complet de la structure de votre système de fichiers.",
"about.documentation": "Documentation",
"about.license": "Licence",
"about.module.found": "Trouver",
"about.module.found": "Trouvé",
"about.title": "À propos de TagStudio",
"about.website": "Site Internet",
"app.git": "Git Commit",
@@ -12,10 +12,10 @@
"color.color_border": "Utiliser la couleur secondaire sur la bordure",
"color.confirm_delete": "Voulez vous vraiment supprimer la couleur \"{color_name}\"?",
"color.delete": "Supprimer le Tag",
"color.import_pack": "Importer un Pack de Couleur",
"color.import_pack": "Importer un Pack de Couleurs",
"color.name": "Nom",
"color.namespace.delete.prompt": "Voulez-vous vraiment supprimer ce groupe de couleurs? Cela supprimera TOUTES les couleurs du groupe!",
"color.namespace.delete.title": "Supprimer le namespace de couleur",
"color.namespace.delete.prompt": "Voulez-vous vraiment supprimer ce groupe de couleurs ? Cela supprimera TOUTES les couleurs du groupe !",
"color.namespace.delete.title": "Supprimer l'espace de noms de couleurs",
"color.new": "Nouvelle couleur",
"color.placeholder": "Couleur",
"color.primary": "Couleur Primaire",
@@ -33,8 +33,8 @@
"drop_import.progress.window_title": "Importer des Fichiers",
"drop_import.title": "Fichier(s) en Conflit",
"edit.color_manager": "Gérer la Couleur des Tags",
"edit.copy_fields": "Copier les Fields",
"edit.paste_fields": "Coller les Fields",
"edit.copy_fields": "Copier les Champs",
"edit.paste_fields": "Coller les Champs",
"edit.tag_manager": "Gérer les Tags",
"entries.duplicate.merge": "Fusion des entrées dupliquées",
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées...",
@@ -42,19 +42,19 @@
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les tags et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
"entries.generic.refresh_alt": "&Recharger",
"entries.generic.remove.removing": "Suppression des Entrées",
"entries.generic.remove.removing_count": "Suppressions de {count} entrées...",
"entries.generic.remove.removing_count": "Suppression de {count} entrées...",
"entries.ignored.description": "Les entrées de fichier sont considérées comme « ignorées » si elles ont été ajoutées à la bibliothèque avant que les règles d'ignorance de l'utilisateur (via le fichier « .ts_ignore ») aient été mises à jour pour les exclure. Les fichiers ignorés sont conservés dans la bibliothèque par défaut afin d'éviter toute perte accidentelle de données lors de la mise à jour des règles d'ignorance.",
"entries.ignored.ignored_count": "Entrées Ignorées : {count}",
"entries.ignored.remove": "Supprimer les entrées ignorées",
"entries.ignored.remove_alt": "Supprim&er les entrées ignorées",
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque...",
"entries.ignored.title": "Corriger les entrées ignorées",
"entries.mirror": "&Refléter",
"entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes?",
"entries.mirror": "&Répliquer",
"entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes ?",
"entries.mirror.label": "Réplication de {idx}/{total} Entrées...",
"entries.mirror.title": "Réplication des Entrées",
"entries.mirror.window_title": "Entrée Miroir",
"entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les <b>{count}</b> entrées suivantes ? Aucun fichiers sur votre disque ne sera supprimée.",
"entries.mirror.window_title": "Entrées Répliqués",
"entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les <b>{count}</b> entrées suivantes ? Aucun fichier sur votre disque ne sera supprimée.",
"entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.",
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...",
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
@@ -68,7 +68,7 @@
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",
"entries.unlinked.search_and_relink": "&Rechercher && Relier",
"entries.unlinked.title": "Réparation des Entrées non Liées",
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
"ffmpeg.missing.description": "FFmpeg et/ou FFprobe nont pas été trouvée. FFmpeg est nécessaire pour la lecture de média et les vignettes.",
"ffmpeg.missing.status": "{ffmpeg} : {ffmpeg_status}<br>{ffprobe} : {ffprobe_status}",
"field.copy": "Copier le Champ",
@@ -83,25 +83,25 @@
"file.duplicates.dupeguru.file_extension": "Fichiers DupeGuru (*.dupeguru)",
"file.duplicates.dupeguru.load_file": "&Charger un Fichier DupeGuru",
"file.duplicates.dupeguru.no_file": "Aucun Fichier DupeGuru Sélectionné",
"file.duplicates.dupeguru.open_file": "Ouvrire les Fichiers de Résultats de DupeGuru",
"file.duplicates.dupeguru.open_file": "Ouvrir les Fichiers de Résultats de DupeGuru",
"file.duplicates.fix": "Réparer les Fichiers en Double",
"file.duplicates.matches": "Dupliquer les Correspondances de Fichier: {count}",
"file.duplicates.matches_uninitialized": "Dupliquer les Correspondances de Fichier : N/A",
"file.duplicates.matches_uninitialized": "Dupliquer les Correspondances de Fichier : N/A",
"file.duplicates.mirror.description": "Repliquer les données d'entrée dans chaque jeu de correspondances en double, en combinant toutes les données sans supprimer ni dupliquer de champs. Cette opération ne supprime aucun fichier ni aucune donnée.",
"file.duplicates.mirror_entries": "&Répliquer les Entrées",
"file.duration": "Durée",
"file.not_found": "Fichier non trouvé",
"file.open_file": "Ouvrir un Fichier",
"file.open_file_with": "Ouvrir le fichier avec",
"file.open_location.generic": "Ouvrir le Fichier dans l'Explorateur de Fichier",
"file.open_location.generic": "Montrer le fichier dans l'Explorateur de Fichier",
"file.open_location.mac": "Montrer dans le Finder",
"file.open_location.windows": "Montrer dans l'explorateur de Fichiers",
"file.path": "Chemin du Fichier",
"folders_to_tags.close_all": "Tout Fermer",
"folders_to_tags.converting": "Conversion des dossiers en Tags",
"folders_to_tags.description": "Créé des Tags basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les labels qui seront créés et à quelles entrées ils seront appliqués.",
"folders_to_tags.description": "Créé des Tags basés sur votre arborescence de dossier et les applique à vos entrées.\nLa structure ci-dessous affiche tous les Tags qui seront créés et à quelles entrées ils seront appliqués.",
"folders_to_tags.open_all": "Tout Ouvrir",
"folders_to_tags.title": "Créer un Label à partir d'un Dossier",
"folders_to_tags.title": "Créer un Tag à partir d'un Dossier",
"generic.add": "Ajouter",
"generic.apply": "Appliquer",
"generic.apply_alt": "&Appliquer",
@@ -115,7 +115,7 @@
"generic.delete_alt": "&Supprimer",
"generic.done": "Terminé",
"generic.done_alt": "&Terminé",
"generic.edit": "Éditer",
"generic.edit": "Modifier",
"generic.edit_alt": "&Modifier",
"generic.filename": "Nom de fichier",
"generic.missing": "Manquant",
@@ -150,15 +150,14 @@
"ignore.open_file": "Afficher le fichier \"{ts_ignore}\" sur le Disque",
"json_migration.checking_for_parity": "Vérification de la Parité...",
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...",
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie <i>ne</i> sera utilisée que si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
"json_migration.discrepancies_found": "Divergence Détectées dans la Bibliothèque",
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie ne sera utilisée <i>que</i> si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
"json_migration.discrepancies_found": "Divergences Détectées dans la Bibliothèque",
"json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.",
"json_migration.finish_migration": "Terminer la Migration",
"json_migration.heading.aliases": "Alias :",
"json_migration.heading.colors": "Couleurs :",
"json_migration.heading.differ": "Divergence",
"json_migration.heading.extension_list_type": "Type de liste d'extension :",
"json_migration.heading.file_extension_list": "Liste des extensions de fichiers :",
"json_migration.heading.extensions": "Extensions:",
"json_migration.heading.match": "Correspondant",
"json_migration.heading.names": "Noms :",
"json_migration.heading.parent_tags": "Tags Parents :",
@@ -350,7 +349,7 @@
"trash.dialog.title.singular": "Supprimer le Fichier",
"trash.name.generic": "Poubelle",
"trash.name.windows": "Corbeille",
"version_modal.description": "Une nouvelle version de TagStudio est disponible! Vous pouvez télécharger la version la plus récente sur <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">Github</a>.",
"version_modal.description": "Une nouvelle version de TagStudio est disponible! Vous pouvez télécharger la version la plus récente sur <a href=\"{github_url}\">Github</a>.",
"version_modal.status": "Version installer : {installed_version}<br>Dernière version disponible : {latest_release_version}",
"version_modal.title": "Mise à jour de TagStudio disponible",
"view.size.0": "Mini",
+6 -3
View File
@@ -74,6 +74,9 @@
"field.copy": "Mező &másolása",
"field.edit": "Mező szerkesztése",
"field.paste": "Mező &beillesztése",
"field_type.datetime": "Dátum és idő",
"field_type.text": "Szöveg",
"field_type.unknown": "Ismeretlen típus",
"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",
@@ -157,8 +160,7 @@
"json_migration.heading.aliases": "Áljelek:",
"json_migration.heading.colors": "Színek:",
"json_migration.heading.differ": "Eltérés",
"json_migration.heading.extension_list_type": "Kiterjesztési lista típusa:",
"json_migration.heading.file_extension_list": "Fájlkiterjesztési lista:",
"json_migration.heading.extensions": "Kiterjesztések:",
"json_migration.heading.match": "Egységesítve",
"json_migration.heading.names": "Nevek:",
"json_migration.heading.parent_tags": "Szülőcímkék:",
@@ -273,6 +275,7 @@
"settings.open_library_on_start": "&Könyvtár megnyitása a program indulásakor",
"settings.page_size": "&Oldalméret",
"settings.restart_required": "A módosítások érvénybeléptetéséhez<br>újra kell indítani a TagStudiót.",
"settings.scan_files_on_open": "Új fájlok automatikus betöltése",
"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",
"settings.splash.label": "Indítókép",
@@ -350,7 +353,7 @@
"trash.dialog.title.singular": "Fájl törlése",
"trash.name.generic": "kukába",
"trash.name.windows": "lomtárba",
"version_modal.description": "Elérhetővé vált egy TagStudio-frissítés. A legújabb verziót a <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">Githubról</a> töltheti le.",
"version_modal.description": "Elérhetővé vált egy TagStudio-frissítés. A legújabb verziót a <a href=\"{github_url}\">Githubról</a> töltheti le.",
"version_modal.status": "Telepített verzió: {installed_version}<br>Legújabb stabil verzió: {latest_release_version}",
"version_modal.title": "TagStudio-frissítés",
"view.size.0": "Apró",
+1 -3
View File
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "Alias:",
"json_migration.heading.colors": "Colori:",
"json_migration.heading.differ": "Discrepanze",
"json_migration.heading.extension_list_type": "Tipo di Lista di Entensioni:",
"json_migration.heading.file_extension_list": "Elenco Estensioni dei File:",
"json_migration.heading.match": "Abbinato",
"json_migration.heading.names": "Nomi:",
"json_migration.heading.parent_tags": "Etichette Genitore:",
@@ -350,7 +348,7 @@
"trash.dialog.title.singular": "Elimina File",
"trash.name.generic": "Spazzatura",
"trash.name.windows": "Cestino",
"version_modal.description": "Una nuova versione di TagStudio è disponibile! Puoi scaricare l'ultima versione da <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">Github</a>.",
"version_modal.description": "Una nuova versione di TagStudio è disponibile! Puoi scaricare l'ultima versione da <a href=\"{github_url}\">Github</a>.",
"version_modal.status": "Versione Installata: {installed_version}<br>Ultima Versione Rilasciata: {latest_release_version}",
"version_modal.title": "Aggiornamento di TagStudio Disponibile",
"view.size.0": "Mini",
+1 -3
View File
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "エイリアス:",
"json_migration.heading.colors": "色:",
"json_migration.heading.differ": "差異",
"json_migration.heading.extension_list_type": "拡張子リストの種類:",
"json_migration.heading.file_extension_list": "ファイルの拡張子リスト:",
"json_migration.heading.match": "一致",
"json_migration.heading.names": "名前:",
"json_migration.heading.parent_tags": "親タグ:",
@@ -350,7 +348,7 @@
"trash.dialog.title.singular": "ファイルの削除",
"trash.name.generic": "ごみ箱",
"trash.name.windows": "ごみ箱",
"version_modal.description": "TagStudio の新しいバージョンが利用できます。<a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">GitHub</a> から最新リリースをダウンロードできます。",
"version_modal.description": "TagStudio の新しいバージョンが利用できます。<a href=\"{github_url}\">GitHub</a> から最新リリースをダウンロードできます。",
"version_modal.status": "インストール済みのバージョン: {installed_version}<br>最新リリースのバージョン: {latest_release_version}",
"version_modal.title": "TagStudio の更新があります",
"view.size.0": "極小",
@@ -148,8 +148,6 @@
"json_migration.heading.aliases": "Alternative navn:",
"json_migration.heading.colors": "Farger:",
"json_migration.heading.differ": "Avvik",
"json_migration.heading.extension_list_type": "Type av Utvidelsesliste:",
"json_migration.heading.file_extension_list": "Filutvidelse Liste:",
"json_migration.heading.match": "Matchet",
"json_migration.heading.names": "Navn:",
"json_migration.heading.parent_tags": "Overordnede Etiketter:",
@@ -139,8 +139,6 @@
"json_migration.heading.aliases": "Zastępcze nazwy:",
"json_migration.heading.colors": "Kolory:",
"json_migration.heading.differ": "Niezgodność",
"json_migration.heading.extension_list_type": "Typ listy rozszerzeń:",
"json_migration.heading.file_extension_list": "Lista rozszerzeń plików:",
"json_migration.heading.match": "Dopasowane",
"json_migration.heading.names": "Nazwy:",
"json_migration.heading.parent_tags": "Tagi nadrzędne:",
@@ -136,8 +136,6 @@
"json_migration.heading.aliases": "Pseudônimos:",
"json_migration.heading.colors": "Cores:",
"json_migration.heading.differ": "Discrepância",
"json_migration.heading.extension_list_type": "Tipo de Lista de Extensão:",
"json_migration.heading.file_extension_list": "Lista de Extensão de Ficheiro:",
"json_migration.heading.match": "Combinado",
"json_migration.heading.names": "Nomes:",
"json_migration.heading.parent_tags": "Tags Pai:",
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "Pseudônimos:",
"json_migration.heading.colors": "Cores:",
"json_migration.heading.differ": "Discrepância",
"json_migration.heading.extension_list_type": "Lista dos tipos de Extensão:",
"json_migration.heading.file_extension_list": "Lista de Extensão de Arquivo:",
"json_migration.heading.match": "Correspondido",
"json_migration.heading.names": "Nomes:",
"json_migration.heading.parent_tags": "Tags Pai:",
@@ -137,8 +137,6 @@
"json_migration.heading.aliases": "Andrnamae:",
"json_migration.heading.colors": "Varge:",
"json_migration.heading.differ": "Tchigauzma",
"json_migration.heading.extension_list_type": "Fal fu taksanting tumam:",
"json_migration.heading.file_extension_list": "Tumam fu mlafufal:",
"json_migration.heading.match": "Finnajena sama",
"json_migration.heading.names": "Namae:",
"json_migration.heading.parent_tags": "Atama festaretol:",
+10 -5
View File
@@ -35,19 +35,25 @@
"edit.color_manager": "Редактировать цвета тегов",
"edit.copy_fields": "Копировать поля",
"edit.paste_fields": "Вставить поля",
"edit.tag_manager": "Управлять тегами",
"edit.tag_manager": "Управление тегами",
"entries.duplicate.merge": "Объединить записи-дубликаты",
"entries.duplicate.merge.label": "Объединение записей-дубликатов...",
"entries.duplicate.refresh": "Обновить записи-дубликаты",
"entries.duplicates.description": "Записи-дубликаты — это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с несколькими копиями самого файла, которые могут существовать вне TagStudio.",
"entries.duplicates.description": "Записи-дубликаты — это несколько записей, которые одновременно привязаны к одному файлу. Объединение таких дубликатов соединит все теги и мета данные из этих записей в одну. Записи-дубликаты не стоит путать с копиями самого файла, которые существуют вне TagStudio.",
"entries.generic.refresh_alt": "&Обновить",
"entries.generic.remove.removing": "Удаление записей",
"entries.generic.remove.removing_count": "Удаление {count} записей...",
"entries.ignored.ignored_count": "Проигнорированных записей: {count}",
"entries.ignored.remove": "Удалить игнорируемые записи",
"entries.ignored.scanning": "Поиск игнорируемых записей...",
"entries.ignored.title": "Исправить игнорируемые записи",
"entries.mirror": "&Отзеркалить",
"entries.mirror.confirmation": "Вы уверены, что хотите отзеркалить следующие {count} записей?",
"entries.mirror.label": "Отзеркаливание {idx}/{total} записей...",
"entries.mirror.title": "Отзеркаливание записей",
"entries.mirror.window_title": "Отзеркалить записи",
"entries.remove.plural.confirm": "Вы уверены, что хотите удалить {count} записей?",
"entries.remove.plural.confirm": "Вы уверены, что хотите удалить <b>{count}</b> записей? Файлы на диске не будут удалены.",
"entries.remove.singular.confirm": "Вы уверены, что хотите удалить эту запись? Файл на диске не будет удалён.",
"entries.running.dialog.new_entries": "Добавление {total} новых записей...",
"entries.running.dialog.title": "Добавление новых записей",
"entries.tags": "Теги",
@@ -55,6 +61,7 @@
"entries.unlinked.relink.attempting": "Попытка перепривязать {index}/{unlinked_count} записей, {fixed_count} привязано успешно",
"entries.unlinked.relink.manual": "&Ручная привязка",
"entries.unlinked.relink.title": "Привязка записей",
"entries.unlinked.remove": "Удалить откреплённые записи",
"entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...",
"entries.unlinked.search_and_relink": "&Поиск и привязка",
"entries.unlinked.title": "Исправить откреплённые записи",
@@ -141,8 +148,6 @@
"json_migration.heading.aliases": "Псевдонимы:",
"json_migration.heading.colors": "Цвета:",
"json_migration.heading.differ": "Несоответствие",
"json_migration.heading.extension_list_type": "Тип списка расширений:",
"json_migration.heading.file_extension_list": "Список расширений файлов:",
"json_migration.heading.match": "Совпало",
"json_migration.heading.names": "Имена:",
"json_migration.heading.parent_tags": "Родительские теги:",
+1 -3
View File
@@ -157,8 +157,6 @@
"json_migration.heading.aliases": "மாற்றுப்பெயர்கள்:",
"json_migration.heading.colors": "நிறங்கள்:",
"json_migration.heading.differ": "முரண்பாடு",
"json_migration.heading.extension_list_type": "நீட்டிப்பு பட்டியல் வகை:",
"json_migration.heading.file_extension_list": "கோப்பு நீட்டிப்பு பட்டியல்:",
"json_migration.heading.match": "பொருந்தியது",
"json_migration.heading.names": "பெயர்கள்:",
"json_migration.heading.parent_tags": "பெற்றோர் குறிச்சொற்கள்:",
@@ -350,7 +348,7 @@
"trash.dialog.title.singular": "கோப்பை அழி",
"trash.name.generic": "குப்பை",
"trash.name.windows": "மறுசுழற்சி பின்",
"version_modal.description": "முகவரிச்சீட்டுஅறை இன் புதிய பதிப்பு கிடைக்கிறது! சமீபத்திய வெளியீட்டை நீங்கள் பதிவிறக்கம் செய்யலாம் <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">அறிவிலிமையம்</a>.",
"version_modal.description": "முகவரிச்சீட்டுஅறை இன் புதிய பதிப்பு கிடைக்கிறது! சமீபத்திய வெளியீட்டை நீங்கள் பதிவிறக்கம் செய்யலாம் <a href=\"{github_url}\">அறிவிலிமையம்</a>.",
"version_modal.status": "நிறுவப்பட்ட பதிப்பு: {installed_version}<br>அண்மைகால வெளியீட்டு பதிப்பு: {latest_release_version}",
"version_modal.title": "முகவரிச்சீட்டுஅறை புதுப்பிப்பு கிடைக்கிறது",
"view.size.0": "மினி",
@@ -154,7 +154,6 @@
"json_migration.heading.aliases": "nimi ante:",
"json_migration.heading.colors": "kule:",
"json_migration.heading.differ": "ante ike",
"json_migration.heading.file_extension_list": "kulupu pi namako lipu:",
"json_migration.heading.match": "sama",
"json_migration.heading.names": "nimi:",
"json_migration.heading.parent_tags": "poki mama:",
@@ -336,7 +335,7 @@
"trash.dialog.title.singular": "o weka e lipu",
"trash.name.generic": "poki pi ijo weka",
"trash.name.windows": "poki pi ijo weka",
"version_modal.description": "nanpa sin pi ilo TagStudio li lon! sina ken kama jo e ona tan <a href=\"https://github.com/TagStudioDev/TagStudio/releases/latest\">ma Github</a>.",
"version_modal.description": "nanpa sin pi ilo TagStudio li lon! sina ken kama jo e ona tan <a href=\"{github_url}\">ma Github</a>.",
"version_modal.status": "nanpa ni: {installed_version}<br>nanpa sin: {latest_release_version}",
"version_modal.title": "nanpa sin pi ilo TagStudio li lon",
"view.size.0": "lili mute",
@@ -136,8 +136,6 @@
"json_migration.heading.aliases": "Takma Adlar:",
"json_migration.heading.colors": "Renkler:",
"json_migration.heading.differ": "Uyuşmazlık",
"json_migration.heading.extension_list_type": "Uzantı Listesi Türü:",
"json_migration.heading.file_extension_list": "Dosya Uzantı Listesi:",
"json_migration.heading.match": "Eşleşti",
"json_migration.heading.names": "Adlar:",
"json_migration.heading.parent_tags": "Üst Etiketler:",
@@ -152,8 +152,6 @@
"json_migration.heading.aliases": "别名:",
"json_migration.heading.colors": "颜色:",
"json_migration.heading.differ": "差异",
"json_migration.heading.extension_list_type": "扩展名列表类型:",
"json_migration.heading.file_extension_list": "文件扩展名列表:",
"json_migration.heading.match": "已匹配",
"json_migration.heading.names": "名字:",
"json_migration.heading.parent_tags": "上级标签:",
@@ -156,8 +156,6 @@
"json_migration.heading.aliases": "別名:",
"json_migration.heading.colors": "顏色:",
"json_migration.heading.differ": "差異",
"json_migration.heading.extension_list_type": "副檔名清單類型:",
"json_migration.heading.file_extension_list": "檔案副檔名清單:",
"json_migration.heading.match": "已一致",
"json_migration.heading.names": "名稱:",
"json_migration.heading.parent_tags": "父標籤:",
+9 -7
View File
@@ -12,6 +12,8 @@ from unittest.mock import Mock, patch
import pytest
from PySide6.QtWidgets import QScrollArea
from tagstudio.core.library.alchemy.fields import TextField
CWD = Path(__file__).parent
# this needs to be above `src` imports
sys.path.insert(0, str(CWD.parent))
@@ -33,26 +35,26 @@ def cwd():
def file_mediatypes_library():
lib = Library()
status = lib.open_library(Path(""), ":memory:")
status = lib.open_library(Path(""), in_memory=True)
assert status.success
folder = unwrap(lib.folder)
entry1 = Entry(
folder=folder,
path=Path("foo.png"),
fields=lib.default_fields,
fields=[TextField(name="Title", value="I'm a Test Title")],
)
entry2 = Entry(
folder=folder,
path=Path("bar.png"),
fields=lib.default_fields,
fields=[TextField(name="Title", value="I'm a Test Title")],
)
entry3 = Entry(
folder=folder,
path=Path("baz.apng"),
fields=lib.default_fields,
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert lib.add_entries([entry1, entry2, entry3])
@@ -84,7 +86,7 @@ def library(request, library_dir: Path): # pyright: ignore
library_path = Path(request.param)
lib = Library()
status = lib.open_library(library_path, ":memory:")
status = lib.open_library(library_path, in_memory=True)
assert status.success
folder = unwrap(lib.folder)
@@ -117,7 +119,7 @@ def library(request, library_dir: Path): # pyright: ignore
id=1,
folder=folder,
path=Path("foo.txt"),
fields=lib.default_fields,
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert lib.add_tags_to_entries(entry.id, tag.id)
@@ -125,7 +127,7 @@ def library(request, library_dir: Path): # pyright: ignore
id=2,
folder=folder,
path=Path("one/two/bar.md"),
fields=lib.default_fields,
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert lib.add_tags_to_entries(entry2.id, tag2.id)
Binary file not shown.
+5 -2
View File
@@ -4,6 +4,7 @@
from pathlib import Path
from tagstudio.core.library.alchemy.fields import BaseField, TextField
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
@@ -16,16 +17,18 @@ def test_refresh_dupe_files(library: Library):
library.library_dir = Path("/tmp/")
folder = unwrap(library.folder)
fields: list[BaseField] = [TextField(name="Title", value="I'm a Test Title")]
entry = Entry(
folder=folder,
path=Path("bar/foo.txt"),
fields=library.default_fields,
fields=fields,
)
entry2 = Entry(
folder=folder,
path=Path("foo/foo.txt"),
fields=library.default_fields,
fields=fields,
)
library.add_entries([entry, entry2])
+3 -4
View File
@@ -7,7 +7,7 @@ from tempfile import TemporaryDirectory
import pytest
from tagstudio.core.enums import LibraryPrefs
from tagstudio.core.constants import IGNORE_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.utils.types import unwrap
@@ -20,15 +20,14 @@ CWD = Path(__file__).parent
def test_refresh_new_files(library: Library, exclude_mode: bool):
library_dir = unwrap(library.library_dir)
# Given
library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, exclude_mode)
library.set_prefs(LibraryPrefs.EXTENSION_LIST, [".md"])
registry = RefreshTracker(library=library)
library.included_files.clear()
(library_dir / "FOO.MD").touch()
(library_dir / IGNORE_NAME).write_text("*.md" if exclude_mode else "*\n!*.md")
# Test if the single file was added
list(registry.refresh_dir(library_dir, force_internal_tools=True))
assert registry.files_not_in_library == [Path("FOO.MD")]
assert set(registry.files_not_in_library) == set([Path(IGNORE_NAME), Path("FOO.MD")])
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
+14
View File
@@ -0,0 +1,14 @@
from pytestqt.qtbot import QtBot
from tagstudio.qt.mixed.about_modal import AboutModal
def test_github_api_unavailable(qtbot: QtBot, mocker) -> None:
mocker.patch(
"requests.get",
side_effect=ConnectionError(
"Failed to resolve 'api.github.com' ([Errno -3] Temporary failure in name resolution)"
),
)
modal = AboutModal("/tmp")
qtbot.addWidget(modal)
+6 -2
View File
@@ -27,6 +27,10 @@ EMPTY_LIBRARIES = "empty_libraries"
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_101")),
# str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_102")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_103")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_200")),
],
)
def test_library_migrations(path: str):
@@ -46,9 +50,9 @@ def test_library_migrations(path: str):
try:
status = library.open_library(library_dir=temp_path)
library.close()
shutil.rmtree(temp_path)
assert status.success
except Exception as e:
library.close()
shutil.rmtree(temp_path)
raise (e)
finally:
shutil.rmtree(temp_path)
+1 -8
View File
@@ -6,7 +6,6 @@
from pathlib import Path
from time import time
from tagstudio.core.enums import LibraryPrefs
from tagstudio.qt.mixed.migration_modal import JsonMigrationModal
CWD = Path(__file__)
@@ -43,10 +42,4 @@ def test_json_migration():
assert modal.check_color_parity()
# Extension Filter List ====================================================
# Count
assert len(modal.json_lib.ext_list) == len(modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST))
# List Type
assert modal.check_ext_type()
# No Leading Dot
for ext in modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST): # pyright: ignore[reportUnknownVariableType]
assert ext[0] != "."
modal.assert_ignore_parity()
+84 -109
View File
@@ -10,10 +10,9 @@ from tempfile import TemporaryDirectory
import pytest
import structlog
from tagstudio.core.enums import DefaultEnum, LibraryPrefs
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.fields import (
FieldID, # pyright: ignore[reportPrivateUsage]
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.library import Library
@@ -82,12 +81,12 @@ def test_library_add_file(library: Library):
entry = Entry(
path=Path("bar.txt"),
folder=unwrap(library.folder),
fields=library.default_fields,
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert not library.has_path_entry(entry.path)
assert not library.has_entry_with_path(entry.path)
assert library.add_entries([entry])
assert library.has_path_entry(entry.path)
assert library.has_entry_with_path(entry.path)
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
@@ -131,10 +130,10 @@ def test_library_search(library: Library, entry_full: Entry):
def test_tag_search(library: 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 library.search_tags(tag.name * 2) == [set(), set()]
assert library.search_tags(tag.name.lower())[0]
assert library.search_tags(tag.name.upper())[0]
assert library.search_tags(tag.name[2:-2])[0]
assert library.search_tags(tag.name * 2) == ([], [])
def test_get_entry(library: Library, entry_min: Entry):
@@ -199,11 +198,6 @@ def test_search_library_case_insensitive(library: Library):
assert results[0] == entry.id
def test_preferences(library: Library):
for pref in LibraryPrefs:
assert library.prefs(pref) == pref.default
def test_remove_entry_field(library: Library, entry_full: Entry):
title_field = entry_full.text_fields[0]
@@ -213,13 +207,13 @@ def test_remove_entry_field(library: Library, entry_full: Entry):
assert not entry.text_fields
def test_remove_field_entry_with_multiple_field(library: Library, entry_full: Entry):
def test_remove_text_field_entry_with_multiple_fields(library: Library, entry_full: Entry):
# Given
title_field = entry_full.text_fields[0]
# When
# add identical field
assert library.add_field_to_entry(entry_full.id, field_id=title_field.type_key)
assert library.add_field_to_entries(entry_full.id, field=title_field)
# remove entry field
library.remove_entry_field(title_field, [entry_full.id])
@@ -232,30 +226,23 @@ def test_remove_field_entry_with_multiple_field(library: Library, entry_full: En
def test_update_entry_field(library: Library, entry_full: Entry):
title_field = entry_full.text_fields[0]
library.update_entry_field(
entry_full.id,
title_field,
"new value",
)
library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)
entry = next(library.all_entries(with_joins=True))
assert entry.text_fields[0].value == "new value"
def test_update_entry_with_multiple_identical_fields(library: Library, entry_full: Entry):
def test_update_entry_with_multiple_identical_text_fields(library: Library, entry_full: Entry):
# Given
title_field = entry_full.text_fields[0]
# When
# add identical field
library.add_field_to_entry(entry_full.id, field_id=title_field.type_key)
empty_title = TextField(name="Title", value="")
library.add_field_to_entries(entry_full.id, field=empty_title)
# update one of the fields
library.update_entry_field(
entry_full.id,
title_field,
"new value",
)
library.update_text_field(entry_full.id, title_field, "new value", title_field.is_multiline)
# Then only one should be updated
entry = next(library.all_entries(with_joins=True))
@@ -263,37 +250,70 @@ def test_update_entry_with_multiple_identical_fields(library: Library, entry_ful
assert entry.text_fields[1].value == "new value"
def test_mirror_entry_fields(library: Library, entry_full: Entry):
# new entry
target_entry = Entry(
def test_mirror_entry_fields(library: Library):
# Create and add entries with fields
entry_a = Entry(
folder=unwrap(library.folder),
path=Path("xxx"),
path=Path("title_and_date.txt"),
fields=[
TextField(
type_key=FieldID.NOTES.name,
value="notes",
position=0,
)
TextField(name="Title", value="I'm a Test Title"),
DatetimeField(name="Date", value="2026-05-07 12:59:24"),
],
)
entry_b = Entry(
folder=unwrap(library.folder),
path=Path("notes.txt"),
fields=[
TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True),
TextField(name="Title", value="I'm a Test Title"),
],
)
entry_c = Entry(
folder=unwrap(library.folder),
path=Path("date_published.txt"),
fields=[
DatetimeField(name="Date Published", value="2000-01-01 12:00:00"),
],
)
entry_a_id, entry_b_id, entry_c_id = library.add_entries([entry_a, entry_b, entry_c])
# insert new entry and get id
entry_id = library.add_entries([target_entry])[0]
# Retrieve from library
entry_a_ = unwrap(library.get_entry_full(entry_a_id))
entry_b_ = unwrap(library.get_entry_full(entry_b_id))
entry_c_ = unwrap(library.get_entry_full(entry_c_id))
# get new entry from library
new_entry = unwrap(library.get_entry_full(entry_id))
# Sanity check for initial fields
assert entry_a_.fields[0].name == "Title"
assert entry_a_.fields[1].name == "Date"
assert entry_b_.fields[0].name == "Notes"
assert entry_c_.fields[0].name == "Date Published"
assert len(entry_a_.fields) == 2
assert len(entry_b_.fields) == 2
assert len(entry_c_.fields) == 1
# mirror fields onto new entry
library.mirror_entry_fields(new_entry, entry_full)
# Mirror fields between entries
library.mirror_entry_fields([entry_b_, entry_a_, entry_c_])
# get new entry from library again
entry = unwrap(library.get_entry_full(entry_id))
# Retrieve from library, again
entry_a_mirrored = unwrap(library.get_entry_full(entry_a_id))
entry_b_mirrored = unwrap(library.get_entry_full(entry_b_id))
entry_c_mirrored = unwrap(library.get_entry_full(entry_c_id))
# make sure fields are there after getting it from the library again
assert len(entry.fields) == 2
assert {x.type_key for x in entry.fields} == {
FieldID.TITLE.name,
FieldID.NOTES.name,
for entry in [entry_a_mirrored, entry_b_mirrored, entry_c_mirrored]:
logger.info(
"[Library][mirror_fields]", entry_id=entry.id, field_count_after=len(entry.fields)
)
# Assert presence of all fields on all entries
assert len(entry_a_mirrored.fields) == 4
assert len(entry_b_mirrored.fields) == 4
assert len(entry_c_mirrored.fields) == 4
assert {(type(x), x.name) for x in entry_a_mirrored.fields} == {
(TextField, "Title"),
(TextField, "Notes"),
(DatetimeField, "Date"),
(DatetimeField, "Date Published"),
}
@@ -304,32 +324,32 @@ def test_merge_entries(library: Library):
tag_1: Tag = unwrap(library.add_tag(Tag(id=1011, name="tag_1")))
tag_2: Tag = unwrap(library.add_tag(Tag(id=1012, name="tag_2")))
a = Entry(
entry_a = Entry(
folder=folder,
path=Path("a"),
fields=[
TextField(type_key=FieldID.AUTHOR.name, value="Author McAuthorson", position=0),
TextField(type_key=FieldID.DESCRIPTION.name, value="test description", position=2),
TextField(name="Author", value="Author McAuthorson"),
TextField(name="Description", value="test description", is_multiline=True),
],
)
b = Entry(
entry_b = Entry(
folder=folder,
path=Path("b"),
fields=[TextField(type_key=FieldID.NOTES.name, value="test note", position=1)],
fields=[TextField(name="Notes", value="test note", is_multiline=True)],
)
ids = library.add_entries([a, b])
entry_a_id, entry_b_id = library.add_entries([entry_a, entry_b])
library.add_tags_to_entries(ids[0], [tag_0.id, tag_2.id])
library.add_tags_to_entries(ids[1], [tag_1.id])
library.add_tags_to_entries(entry_a_id, [tag_0.id, tag_2.id])
library.add_tags_to_entries(entry_b_id, [tag_1.id])
entry_a: Entry = unwrap(library.get_entry_full(ids[0]))
entry_b: Entry = unwrap(library.get_entry_full(ids[1]))
entry_a_: Entry = unwrap(library.get_entry_full(entry_a_id))
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
assert library.merge_entries(entry_a, entry_b)
assert not library.has_path_entry(Path("a"))
assert library.has_path_entry(Path("b"))
assert library.merge_entries(entry_a_, entry_b_)
assert not library.has_entry_with_path(Path("a"))
assert library.has_entry_with_path(Path("b"))
entry_b_merged = unwrap(library.get_entry_full(ids[1]))
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))
fields = [field.value for field in entry_b_merged.fields]
assert "Author McAuthorson" in fields
@@ -366,51 +386,6 @@ def test_search_entry_id(library: Library, query_name: int, has_result: bool):
assert (result is not None) == has_result
def test_update_field_order(library: Library, entry_full: Entry):
# Given
title_field = entry_full.text_fields[0]
# When add two more fields
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
library.remove_entry_field(title_field, [entry_full.id])
# recalculate the positions
library.update_field_position(
type(title_field),
title_field.type_key,
entry_full.id,
)
# Then
entry = next(library.all_entries(with_joins=True))
assert entry.text_fields[0].position == 0
assert entry.text_fields[0].value == "first"
assert entry.text_fields[1].position == 1
assert entry.text_fields[1].value == "second"
def test_library_prefs_multiple_identical_vals():
# check the preferences are inherited from DefaultEnum
assert issubclass(LibraryPrefs, DefaultEnum)
# create custom settings with identical values
class TestPrefs(DefaultEnum):
FOO = 1
BAR = 1
assert TestPrefs.FOO.default == 1
assert TestPrefs.BAR.default == 1
assert TestPrefs.BAR.name == "BAR"
# accessing .value should raise exception
with pytest.raises(AttributeError):
assert TestPrefs.BAR.value
def test_path_search_ilike(library: Library):
results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500)
assert results.total_count == 1