mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-09-02 17:19:04 +02:00
Compare commits
12 Commits
v9.5.7
..
47c3d5338f
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c3d5338f | |||
| c15e2b56ee | |||
| de7face06b | |||
| cdd399ed71 | |||
| 7af7420167 | |||
| cece7920a8 | |||
| ad2cbbca48 | |||
| 96fc5ef065 | |||
| b0decac610 | |||
| 910d2b735d | |||
| 47d4de5825 | |||
| 741ed5ad86 |
+58
-31
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -4,6 +4,7 @@ from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.core.constants import 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,7 +31,7 @@ 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>",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -157,8 +157,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:",
|
||||
|
||||
@@ -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": "Όνομα:"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 n’ont 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,13 @@
|
||||
"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.match": "Correspondant",
|
||||
"json_migration.heading.names": "Noms :",
|
||||
"json_migration.heading.parent_tags": "Tags Parents :",
|
||||
|
||||
@@ -157,8 +157,6 @@
|
||||
"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.match": "Egységesítve",
|
||||
"json_migration.heading.names": "Nevek:",
|
||||
"json_migration.heading.parent_tags": "Szülőcímkék:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "親タグ:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Родительские теги:",
|
||||
|
||||
@@ -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": "பெற்றோர் குறிச்சொற்கள்:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user