Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: begin adding settings menu #647

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12.8
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
appdirs==1.4.4
chardet==5.2.0
ffmpeg-python==0.2.0
humanfriendly==10.0
Expand All @@ -7,13 +8,15 @@ opencv_python==4.10.0.84
pillow-heif==0.16.0
pillow-jxl-plugin==1.3.0
Pillow==10.3.0
pydantic==2.10.4
pydub==0.25.1
PySide6_Addons==6.8.0.1
PySide6_Essentials==6.8.0.1
PySide6==6.8.0.1
rawpy==0.22.0
SQLAlchemy==2.0.34
structlog==24.4.0
typing_extensions>=3.10.0.0,<=4.11.0
toml==0.10.2
typing_extensions
ujson>=5.8.0,<=5.9.0
vtf2img==0.1.0
1 change: 1 addition & 0 deletions tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"preview.no_selection": "No Items Selected",
"select.all": "Select All",
"select.clear": "Clear Selection",
"settings.language": "Language",
"settings.open_library_on_start": "Open Library on Start",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
"settings.show_recent_libraries": "Show Recent Libraries",
Expand Down
2 changes: 2 additions & 0 deletions tagstudio/src/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@
TAG_META = 2
RESERVED_TAG_START = 0
RESERVED_TAG_END = 999

DEFAULT_LIB_VERSION = 3
15 changes: 7 additions & 8 deletions tagstudio/src/core/driver.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
from pathlib import Path

import structlog
from PySide6.QtCore import QSettings
from src.core.constants import TS_FOLDER_NAME
from src.core.enums import SettingItems
from src.core.library.alchemy.library import LibraryStatus
from src.core.settings import TSSettings
from src.core.tscacheddata import TSCachedData

logger = structlog.get_logger(__name__)


class DriverMixin:
settings: QSettings
settings: TSSettings
cache: TSCachedData

def evaluate_path(self, open_path: str | None) -> LibraryStatus:
"""Check if the path of library is valid."""
Expand All @@ -20,17 +21,15 @@ def evaluate_path(self, open_path: str | None) -> LibraryStatus:
if not library_path.exists():
logger.error("Path does not exist.", open_path=open_path)
return LibraryStatus(success=False, message="Path does not exist.")
elif self.settings.value(
SettingItems.START_LOAD_LAST, defaultValue=True, type=bool
) and self.settings.value(SettingItems.LAST_LIBRARY):
library_path = Path(str(self.settings.value(SettingItems.LAST_LIBRARY)))
elif self.settings.open_last_loaded_on_startup and self.cache.last_library:
library_path = Path(str(self.cache.last_library))
if not (library_path / TS_FOLDER_NAME).exists():
logger.error(
"TagStudio folder does not exist.",
library_path=library_path,
ts_folder=TS_FOLDER_NAME,
)
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
self.cache.last_library = ""
# dont consider this a fatal error, just skip opening the library
library_path = None

Expand Down
9 changes: 0 additions & 9 deletions tagstudio/src/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,3 @@ def __new__(cls, value):
@property
def value(self):
raise AttributeError("access the value via .default property instead")


class LibraryPrefs(DefaultEnum):
"""Library preferences with default value accessible via .default property."""

IS_EXCLUDE_LIST = True
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
PAGE_SIZE: int = 500
DB_VERSION: int = 3
106 changes: 53 additions & 53 deletions tagstudio/src/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from os import makedirs
from pathlib import Path
from uuid import uuid4
from warnings import catch_warnings

import structlog
from humanfriendly import format_timespan
Expand All @@ -30,6 +29,7 @@
func,
or_,
select,
text,
update,
)
from sqlalchemy.exc import IntegrityError
Expand All @@ -44,6 +44,7 @@

from ...constants import (
BACKUP_FOLDER_NAME,
DEFAULT_LIB_VERSION,
LEGACY_TAG_FIELD_IDS,
RESERVED_TAG_END,
RESERVED_TAG_START,
Expand All @@ -52,7 +53,7 @@
TAG_META,
TS_FOLDER_NAME,
)
from ...enums import LibraryPrefs
from ...settings import LibSettings
from .db import make_tables
from .enums import MAX_SQL_VARIABLES, FieldTypeEnum, FilterState, SortingModeEnum, TagColor
from .fields import (
Expand Down Expand Up @@ -159,6 +160,7 @@ class Library:
engine: Engine | None
folder: Folder | None
included_files: set[Path] = set()
settings: LibSettings | None = None

SQL_FILENAME: str = "ts_library.sqlite"
JSON_FILENAME: str = "ts_library.json"
Expand Down Expand Up @@ -238,8 +240,8 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary):
)

# Preferences
self.set_prefs(LibraryPrefs.EXTENSION_LIST, [x.strip(".") for x in json_lib.ext_list])
self.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, json_lib.is_exclude_list)
self.settings.extension_list = [x.strip(".") for x in json_lib.ext_list]
self.settings.is_exclude_list = json_lib.is_exclude_list

end_time = time.time()
logger.info(f"Library Converted! ({format_timespan(end_time-start_time)})")
Expand All @@ -255,10 +257,10 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li
if storage_path == ":memory:":
self.storage_path = storage_path
is_new = True
self.settings = LibSettings(filename="")
return self.open_sqlite_library(library_dir, is_new)
else:
self.storage_path = library_dir / TS_FOLDER_NAME / self.SQL_FILENAME

if self.verify_ts_folder(library_dir) and (is_new := not self.storage_path.exists()):
json_path = library_dir / TS_FOLDER_NAME / self.JSON_FILENAME
if json_path.exists():
Expand Down Expand Up @@ -302,29 +304,6 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
session.rollback()

# dont check db version when creating new library
if not is_new:
db_version = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
)

if not db_version:
return LibraryStatus(
success=False,
message=(
"Library version mismatch.\n"
f"Found: v0, expected: v{LibraryPrefs.DB_VERSION.default}"
),
)

for pref in LibraryPrefs:
with catch_warnings(record=True):
try:
session.add(Preferences(key=pref.name, value=pref.default))
session.commit()
except IntegrityError:
logger.debug("preference already exists", pref=pref)
session.rollback()

for field in _FieldID:
try:
session.add(
Expand All @@ -341,21 +320,58 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
logger.debug("ValueType already exists", field=field)
session.rollback()

db_version = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
)
settings_path = library_dir / TS_FOLDER_NAME / "notafile.toml"

# Will be set already if library was opened in-memory
if self.settings is None:
if settings_path.exists():
self.settings = LibSettings.open(settings_path)
else:
if (
session.execute(
text(
"""
SELECT count(*)
FROM sqlite_master
WHERE type='table' AND lower(name)='preferences';
"""
)
).scalar()
== 0
):
# db was not created when settings were in db;
# use default settings, store at default location
self.settings = LibSettings(filename=str(settings_path))
else:
# copy settings from db, store them in default location on next save
prefs = session.scalars(select(Preferences))
settings = LibSettings(filename=str(settings_path))
for pref in prefs:
# the type ignores below are due to the fact that a Preference's value
# is defined as a dict, while none of them are actually dicts.
# i dont know why that is how it is, but it is
if pref.key == "IS_EXCLUDE_LIST":
settings.is_exclude_list = pref.value # type: ignore
elif pref.key == "EXTENSION_LIST":
settings.extension_list = pref.value # type: ignore
elif pref.key == "PAGE_SIZE":
settings.page_size = pref.value # type: ignore
elif pref.key == "DB_VERSION":
settings.db_version = pref.value # type: ignore

self.settings = settings
# if the db version is different, we cant proceed
if db_version.value != LibraryPrefs.DB_VERSION.default:
if not is_new and self.settings.db_version != DEFAULT_LIB_VERSION:
logger.error(
"DB version mismatch",
db_version=db_version.value,
expected=LibraryPrefs.DB_VERSION.default,
db_version=self.settings.db_version,
expected=DEFAULT_LIB_VERSION,
)
return LibraryStatus(
success=False,
message=(
"Library version mismatch.\n"
f"Found: v{db_version.value}, expected: v{LibraryPrefs.DB_VERSION.default}"
f"Found: v{self.settings.db_version}, expected: v{DEFAULT_LIB_VERSION}"
),
)

Expand Down Expand Up @@ -587,10 +603,9 @@ def search_library(
f"SQL Expression Builder finished ({format_timespan(end_time - start_time)})"
)

extensions = self.prefs(LibraryPrefs.EXTENSION_LIST)
is_exclude_list = self.prefs(LibraryPrefs.IS_EXCLUDE_LIST)
extensions = self.settings.extension_list

if extensions and is_exclude_list:
if extensions and self.settings.is_exclude_list:
statement = statement.where(Entry.suffix.notin_(extensions))
elif extensions:
statement = statement.where(Entry.suffix.in_(extensions))
Expand Down Expand Up @@ -1103,21 +1118,6 @@ def update_parent_tags(self, tag, parent_ids, session):
)
session.add(parent_tag)

def prefs(self, key: LibraryPrefs):
# load given item from Preferences table
with Session(self.engine) as session:
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value

def set_prefs(self, key: LibraryPrefs, value) -> None:
# set given item in Preferences table
with Session(self.engine) as session:
# load existing preference and update value
pref = session.scalar(select(Preferences).where(Preferences.key == key.name))
pref.value = value
session.add(pref)
session.commit()
# TODO - try/except

def mirror_entry_fields(self, *entries: Entry) -> None:
"""Mirror fields among multiple Entry items."""
fields = {}
Expand Down
4 changes: 4 additions & 0 deletions tagstudio/src/core/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .libsettings import LibSettings
from .tssettings import TSSettings

__all__ = ["TSSettings", "LibSettings"]
42 changes: 42 additions & 0 deletions tagstudio/src/core/settings/libsettings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from pathlib import Path

import structlog
import toml
from pydantic import BaseModel, Field

from ..constants import DEFAULT_LIB_VERSION

logger = structlog.get_logger(__name__)


class LibSettings(BaseModel):
is_exclude_list: bool = Field(default=True)
extension_list: list[str] = Field(default=[".json", ".xmp", ".aae"])
page_size: int = Field(default=500)
db_version: int = Field(default=DEFAULT_LIB_VERSION)
filename: str = Field(default="")

@staticmethod
def open(path_value: Path | str) -> "LibSettings":
path: Path = Path(path_value) if not isinstance(path_value, Path) else path_value

if path.exists():
with open(path) as settings_file:
filecontents = settings_file.read()
if len(filecontents.strip()) != 0:
settings_data = toml.loads(filecontents)
settings_data["filename"] = str(path)
return LibSettings(**settings_data)

# either settings file did not exist or was empty - either way, use default settings
settings = LibSettings(filename=str(path))
return settings

def save(self):
if self.filename == "": # assume settings were opened for in-memory library
return
if not (parent_path := Path(self.filename).parent).exists():
parent_path.mkdir()

with open(self.filename, "w") as settings_file:
toml.dump(dict(self), settings_file)
44 changes: 44 additions & 0 deletions tagstudio/src/core/settings/tssettings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from pathlib import Path

import toml
from pydantic import BaseModel, Field


# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
# properties to be overwritten with environment variables. as tagstudio is not currently using
# environment variables, i did not base it on that, but that may be useful in the future.
class TSSettings(BaseModel):
dark_mode: bool = Field(default=False)
language: str = Field(default="en")

# settings from the old SettingItem enum
open_last_loaded_on_startup: bool = Field(default=False)
show_library_list: bool = Field(default=True)
autoplay: bool = Field(default=False)
show_filenames_in_grid: bool = Field(default=False)

filename: str = Field()

@staticmethod
def read_settings(path: Path | str) -> "TSSettings":
path_value = Path(path)
if path_value.exists():
with open(path) as file:
filecontents = file.read()
if len(filecontents.strip()) != 0:
settings_data = toml.loads(filecontents)
return TSSettings(**settings_data)

return TSSettings(filename=str(path))

def save(self, path: Path | str | None = None) -> None:
path_value: Path = Path(path) if isinstance(path, str) else Path(self.filename)
if path_value == "":
pass
# settings were probably opened for an in-memory library - save to preferences table

if not path_value.parent.exists():
path_value.parent.mkdir(parents=True, exist_ok=True)

with open(path_value, "w") as f:
toml.dump(dict(self), f)
Loading
Loading