Skip to content

Commit e075282

Browse files
feat: store Entry suffix separately (#503)
* feat: save entry suffix separately * change LibraryPrefs to allow identical values, add test
1 parent 1c7aaf0 commit e075282

File tree

15 files changed

+303
-110
lines changed

15 files changed

+303
-110
lines changed

tagstudio/src/core/constants.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
from enum import Enum
2-
31
VERSION: str = "9.3.2" # Major.Minor.Patch
42
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
53

64
# The folder & file names where TagStudio keeps its data relative to a library.
75
TS_FOLDER_NAME: str = ".TagStudio"
86
BACKUP_FOLDER_NAME: str = "backups"
97
COLLAGE_FOLDER_NAME: str = "collages"
10-
LIBRARY_FILENAME: str = "ts_library.json"
118

129
# TODO: Turn this whitelist into a user-configurable blacklist.
1310
IMAGE_TYPES: list[str] = [
@@ -122,13 +119,5 @@
122119
+ SHORTCUT_TYPES
123120
)
124121

125-
126122
TAG_FAVORITE = 1
127123
TAG_ARCHIVED = 0
128-
129-
130-
class LibraryPrefs(Enum):
131-
IS_EXCLUDE_LIST = True
132-
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
133-
PAGE_SIZE: int = 500
134-
DB_VERSION: int = 1

tagstudio/src/core/driver.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pathlib import Path
2+
3+
import structlog
4+
from PySide6.QtCore import QSettings
5+
from src.core.constants import TS_FOLDER_NAME
6+
from src.core.enums import SettingItems
7+
from src.core.library.alchemy.library import LibraryStatus
8+
9+
logger = structlog.get_logger(__name__)
10+
11+
12+
class DriverMixin:
13+
settings: QSettings
14+
15+
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
16+
"""Check if the path of library is valid."""
17+
library_path: Path | None = None
18+
if open_path:
19+
library_path = Path(open_path)
20+
if not library_path.exists():
21+
logger.error("Path does not exist.", open_path=open_path)
22+
return LibraryStatus(success=False, message="Path does not exist.")
23+
elif self.settings.value(
24+
SettingItems.START_LOAD_LAST, defaultValue=True, type=bool
25+
) and self.settings.value(SettingItems.LAST_LIBRARY):
26+
library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY)))
27+
if not (library_path / TS_FOLDER_NAME).exists():
28+
logger.error(
29+
"TagStudio folder does not exist.",
30+
library_path=library_path,
31+
ts_folder=TS_FOLDER_NAME,
32+
)
33+
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
34+
# dont consider this a fatal error, just skip opening the library
35+
library_path = None
36+
37+
return LibraryStatus(
38+
success=True,
39+
library_path=library_path,
40+
)

tagstudio/src/core/enums.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import enum
2+
from typing import Any
3+
from uuid import uuid4
24

35

46
class SettingItems(str, enum.Enum):
@@ -31,3 +33,31 @@ class MacroID(enum.Enum):
3133
BUILD_URL = "build_url"
3234
MATCH = "match"
3335
CLEAN_URL = "clean_url"
36+
37+
38+
class DefaultEnum(enum.Enum):
39+
"""Allow saving multiple identical values in property called .default."""
40+
41+
default: Any
42+
43+
def __new__(cls, value):
44+
# Create the enum instance
45+
obj = object.__new__(cls)
46+
# make value random
47+
obj._value_ = uuid4()
48+
# assign the actual value into .default property
49+
obj.default = value
50+
return obj
51+
52+
@property
53+
def value(self):
54+
raise AttributeError("access the value via .default property instead")
55+
56+
57+
class LibraryPrefs(DefaultEnum):
58+
"""Library preferences with default value accessible via .default property."""
59+
60+
IS_EXCLUDE_LIST = True
61+
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
62+
PAGE_SIZE: int = 500
63+
DB_VERSION: int = 2

tagstudio/src/core/library/alchemy/fields.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,27 @@ class BaseField(Base):
1818
__abstract__ = True
1919

2020
@declared_attr
21-
def id(cls) -> Mapped[int]: # noqa: N805
21+
def id(self) -> Mapped[int]:
2222
return mapped_column(primary_key=True, autoincrement=True)
2323

2424
@declared_attr
25-
def type_key(cls) -> Mapped[str]: # noqa: N805
25+
def type_key(self) -> Mapped[str]:
2626
return mapped_column(ForeignKey("value_type.key"))
2727

2828
@declared_attr
29-
def type(cls) -> Mapped[ValueType]: # noqa: N805
30-
return relationship(foreign_keys=[cls.type_key], lazy=False) # type: ignore
29+
def type(self) -> Mapped[ValueType]:
30+
return relationship(foreign_keys=[self.type_key], lazy=False) # type: ignore
3131

3232
@declared_attr
33-
def entry_id(cls) -> Mapped[int]: # noqa: N805
33+
def entry_id(self) -> Mapped[int]:
3434
return mapped_column(ForeignKey("entries.id"))
3535

3636
@declared_attr
37-
def entry(cls) -> Mapped[Entry]: # noqa: N805
38-
return relationship(foreign_keys=[cls.entry_id]) # type: ignore
37+
def entry(self) -> Mapped[Entry]:
38+
return relationship(foreign_keys=[self.entry_id]) # type: ignore
3939

4040
@declared_attr
41-
def position(cls) -> Mapped[int]: # noqa: N805
41+
def position(self) -> Mapped[int]:
4242
return mapped_column(default=0)
4343

4444
def __hash__(self):

tagstudio/src/core/library/alchemy/library.py

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
import shutil
3+
import sys
34
import unicodedata
45
from dataclasses import dataclass
56
from datetime import UTC, datetime
@@ -34,8 +35,8 @@
3435
TAG_ARCHIVED,
3536
TAG_FAVORITE,
3637
TS_FOLDER_NAME,
37-
LibraryPrefs,
3838
)
39+
from ...enums import LibraryPrefs
3940
from .db import make_tables
4041
from .enums import FieldTypeEnum, FilterState, TagColor
4142
from .fields import (
@@ -48,8 +49,6 @@
4849
from .joins import TagField, TagSubtag
4950
from .models import Entry, Folder, Preferences, Tag, TagAlias, ValueType
5051

51-
LIBRARY_FILENAME: str = "ts_library.sqlite"
52-
5352
logger = structlog.get_logger(__name__)
5453

5554

@@ -115,6 +114,15 @@ def __getitem__(self, index: int) -> Entry:
115114
return self.items[index]
116115

117116

117+
@dataclass
118+
class LibraryStatus:
119+
"""Keep status of library opening operation."""
120+
121+
success: bool
122+
library_path: Path | None = None
123+
message: str | None = None
124+
125+
118126
class Library:
119127
"""Class for the Library object, and all CRUD operations made upon it."""
120128

@@ -123,30 +131,28 @@ class Library:
123131
engine: Engine | None
124132
folder: Folder | None
125133

134+
FILENAME: str = "ts_library.sqlite"
135+
126136
def close(self):
127137
if self.engine:
128138
self.engine.dispose()
129139
self.library_dir = None
130140
self.storage_path = None
131141
self.folder = None
132142

133-
def open_library(self, library_dir: Path | str, storage_path: str | None = None) -> None:
134-
if isinstance(library_dir, str):
135-
library_dir = Path(library_dir)
136-
137-
self.library_dir = library_dir
143+
def open_library(self, library_dir: Path, storage_path: str | None = None) -> LibraryStatus:
138144
if storage_path == ":memory:":
139145
self.storage_path = storage_path
140146
else:
141-
self.verify_ts_folders(self.library_dir)
142-
self.storage_path = self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME
147+
self.verify_ts_folders(library_dir)
148+
self.storage_path = library_dir / TS_FOLDER_NAME / self.FILENAME
143149

144150
connection_string = URL.create(
145151
drivername="sqlite",
146152
database=str(self.storage_path),
147153
)
148154

149-
logger.info("opening library", connection_string=connection_string)
155+
logger.info("opening library", library_dir=library_dir, connection_string=connection_string)
150156
self.engine = create_engine(connection_string)
151157
with Session(self.engine) as session:
152158
make_tables(self.engine)
@@ -159,9 +165,24 @@ def open_library(self, library_dir: Path | str, storage_path: str | None = None)
159165
# default tags may exist already
160166
session.rollback()
161167

168+
if "pytest" not in sys.modules:
169+
db_version = session.scalar(
170+
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
171+
)
172+
173+
if not db_version:
174+
# TODO - remove after #503 is merged and LibraryPrefs.DB_VERSION increased again
175+
return LibraryStatus(
176+
success=False,
177+
message=(
178+
"Library version mismatch.\n"
179+
f"Found: v0, expected: v{LibraryPrefs.DB_VERSION.default}"
180+
),
181+
)
182+
162183
for pref in LibraryPrefs:
163184
try:
164-
session.add(Preferences(key=pref.name, value=pref.value))
185+
session.add(Preferences(key=pref.name, value=pref.default))
165186
session.commit()
166187
except IntegrityError:
167188
logger.debug("preference already exists", pref=pref)
@@ -183,11 +204,30 @@ def open_library(self, library_dir: Path | str, storage_path: str | None = None)
183204
logger.debug("ValueType already exists", field=field)
184205
session.rollback()
185206

207+
db_version = session.scalar(
208+
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
209+
)
210+
# if the db version is different, we cant proceed
211+
if db_version.value != LibraryPrefs.DB_VERSION.default:
212+
logger.error(
213+
"DB version mismatch",
214+
db_version=db_version.value,
215+
expected=LibraryPrefs.DB_VERSION.default,
216+
)
217+
# TODO - handle migration
218+
return LibraryStatus(
219+
success=False,
220+
message=(
221+
"Library version mismatch.\n"
222+
f"Found: v{db_version.value}, expected: v{LibraryPrefs.DB_VERSION.default}"
223+
),
224+
)
225+
186226
# check if folder matching current path exists already
187-
self.folder = session.scalar(select(Folder).where(Folder.path == self.library_dir))
227+
self.folder = session.scalar(select(Folder).where(Folder.path == library_dir))
188228
if not self.folder:
189229
folder = Folder(
190-
path=self.library_dir,
230+
path=library_dir,
191231
uuid=str(uuid4()),
192232
)
193233
session.add(folder)
@@ -196,6 +236,10 @@ def open_library(self, library_dir: Path | str, storage_path: str | None = None)
196236
session.commit()
197237
self.folder = folder
198238

239+
# everything is fine, set the library path
240+
self.library_dir = library_dir
241+
return LibraryStatus(success=True, library_path=library_dir)
242+
199243
@property
200244
def default_fields(self) -> list[BaseField]:
201245
with Session(self.engine) as session:
@@ -324,15 +368,18 @@ def add_entries(self, items: list[Entry]) -> list[int]:
324368

325369
with Session(self.engine) as session:
326370
# add all items
327-
session.add_all(items)
328-
session.flush()
329371

330-
new_ids = [item.id for item in items]
372+
try:
373+
session.add_all(items)
374+
session.commit()
375+
except IntegrityError:
376+
session.rollback()
377+
logger.exception("IntegrityError")
378+
return []
331379

380+
new_ids = [item.id for item in items]
332381
session.expunge_all()
333382

334-
session.commit()
335-
336383
return new_ids
337384

338385
def remove_entries(self, entry_ids: list[int]) -> None:
@@ -396,9 +443,9 @@ def search_library(
396443

397444
if not search.id: # if `id` is set, we don't need to filter by extensions
398445
if extensions and is_exclude_list:
399-
statement = statement.where(Entry.path.notilike(f"%.{','.join(extensions)}"))
446+
statement = statement.where(Entry.suffix.notin_(extensions))
400447
elif extensions:
401-
statement = statement.where(Entry.path.ilike(f"%.{','.join(extensions)}"))
448+
statement = statement.where(Entry.suffix.in_(extensions))
402449

403450
statement = statement.options(
404451
selectinload(Entry.text_fields),
@@ -770,7 +817,7 @@ def save_library_backup_to_disk(self) -> Path:
770817
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
771818

772819
shutil.copy2(
773-
self.library_dir / TS_FOLDER_NAME / LIBRARY_FILENAME,
820+
self.library_dir / TS_FOLDER_NAME / self.FILENAME,
774821
target_path,
775822
)
776823

tagstudio/src/core/library/alchemy/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ class Entry(Base):
120120
folder: Mapped[Folder] = relationship("Folder")
121121

122122
path: Mapped[Path] = mapped_column(PathType, unique=True)
123+
suffix: Mapped[str] = mapped_column()
123124

124125
text_fields: Mapped[list[TextField]] = relationship(
125126
back_populates="entry",
@@ -177,6 +178,8 @@ def __init__(
177178
self.path = path
178179
self.folder = folder
179180

181+
self.suffix = path.suffix.lstrip(".").lower()
182+
180183
for field in fields:
181184
if isinstance(field, TextField):
182185
self.text_fields.append(field)

tagstudio/src/core/library/json/library.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ def compressed_dict(self):
299299
class Library:
300300
"""Class for the Library object, and all CRUD operations made upon it."""
301301

302+
FILENAME: str = "ts_library.json"
303+
302304
def __init__(self) -> None:
303305
# Library Info =========================================================
304306
self.library_dir: Path = None

tagstudio/src/qt/modals/file_extension.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
QVBoxLayout,
1717
QWidget,
1818
)
19-
from src.core.constants import LibraryPrefs
19+
from src.core.enums import LibraryPrefs
2020
from src.core.library import Library
2121
from src.qt.widgets.panel import PanelWidget
2222

@@ -104,7 +104,7 @@ def save(self):
104104
for i in range(self.table.rowCount()):
105105
ext = self.table.item(i, 0)
106106
if ext and ext.text().strip():
107-
extensions.append(ext.text().strip().lower())
107+
extensions.append(ext.text().strip().lstrip(".").lower())
108108

109109
# save preference
110110
self.lib.set_prefs(LibraryPrefs.EXTENSION_LIST, extensions)

0 commit comments

Comments
 (0)