diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..04e207918 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.8 diff --git a/requirements.txt b/requirements.txt index 9eb290390..8a0f69395 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +appdirs==1.4.4 chardet==5.2.0 ffmpeg-python==0.2.0 humanfriendly==10.0 @@ -7,6 +8,7 @@ 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 @@ -14,6 +16,7 @@ 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 \ No newline at end of file diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 988fb0033..8fefc3b86 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -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", diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index baec4a758..06e3125a6 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -24,3 +24,5 @@ TAG_META = 2 RESERVED_TAG_START = 0 RESERVED_TAG_END = 999 + +DEFAULT_LIB_VERSION = 3 diff --git a/tagstudio/src/core/driver.py b/tagstudio/src/core/driver.py index 1f7dd4f79..1f2f10a45 100644 --- a/tagstudio/src/core/driver.py +++ b/tagstudio/src/core/driver.py @@ -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.""" @@ -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 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index 74cdcbe4b..5d0a78563 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -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 diff --git a/tagstudio/src/core/library/alchemy/library.py b/tagstudio/src/core/library/alchemy/library.py index 9d3df5491..8be4ec66f 100644 --- a/tagstudio/src/core/library/alchemy/library.py +++ b/tagstudio/src/core/library/alchemy/library.py @@ -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 @@ -30,6 +29,7 @@ func, or_, select, + text, update, ) from sqlalchemy.exc import IntegrityError @@ -44,6 +44,7 @@ from ...constants import ( BACKUP_FOLDER_NAME, + DEFAULT_LIB_VERSION, LEGACY_TAG_FIELD_IDS, RESERVED_TAG_END, RESERVED_TAG_START, @@ -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 ( @@ -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" @@ -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)})") @@ -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(): @@ -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( @@ -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}" ), ) @@ -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)) @@ -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 = {} diff --git a/tagstudio/src/core/settings/__init__.py b/tagstudio/src/core/settings/__init__.py new file mode 100644 index 000000000..d72c42eaa --- /dev/null +++ b/tagstudio/src/core/settings/__init__.py @@ -0,0 +1,4 @@ +from .libsettings import LibSettings +from .tssettings import TSSettings + +__all__ = ["TSSettings", "LibSettings"] diff --git a/tagstudio/src/core/settings/libsettings.py b/tagstudio/src/core/settings/libsettings.py new file mode 100644 index 000000000..978109cc2 --- /dev/null +++ b/tagstudio/src/core/settings/libsettings.py @@ -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) diff --git a/tagstudio/src/core/settings/tssettings.py b/tagstudio/src/core/settings/tssettings.py new file mode 100644 index 000000000..598214da5 --- /dev/null +++ b/tagstudio/src/core/settings/tssettings.py @@ -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) diff --git a/tagstudio/src/core/tscacheddata.py b/tagstudio/src/core/tscacheddata.py new file mode 100644 index 000000000..b894da26c --- /dev/null +++ b/tagstudio/src/core/tscacheddata.py @@ -0,0 +1,52 @@ +from pathlib import Path + +import structlog +import toml +from appdirs import user_cache_dir +from pydantic import BaseModel, ConfigDict, Field + +logger = structlog.get_logger(__name__) + +cache_dir = Path(user_cache_dir()) / "TagStudio" +cache_location = cache_dir / "cache.toml" + + +class TSCachedData(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + last_library: str | None = Field(default=None) + # a dict of ISO formatted date strings -> paths + library_history: dict[str, str] = Field(default_factory=dict[str, str]) + + path: str = Field() + + @staticmethod + def open(path_value: Path | str | None = None) -> "TSCachedData": + path: Path | None = None + default_cache_location = Path(user_cache_dir()) / "ts_cache.toml" + if isinstance(path_value, str): + path = Path(path_value) + elif isinstance(path_value, Path): + path = path_value + else: + logger.info( + "no cache location was specified, using ", + default_cache_location=default_cache_location, + ) + path = default_cache_location + + if path.exists(): + with open(path) as cache_file: + filecontents = cache_file.read() + if len(filecontents.strip()) != 0: + cache_data = toml.loads(filecontents) + cache_data["path"] = str(path) + logger.info("opening cache file at ", cache_location=path) + return TSCachedData(**cache_data) + + return TSCachedData(path=str(default_cache_location)) + + def save(self): + with open(self.path, "w") as f: + file_data = dict(self) + file_data.pop("path") + toml.dump(file_data, f) diff --git a/tagstudio/src/qt/modals/file_extension.py b/tagstudio/src/qt/modals/file_extension.py index 49b5b1478..aa7a504a9 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -16,7 +16,6 @@ QVBoxLayout, QWidget, ) -from src.core.enums import LibraryPrefs from src.core.library import Library from src.qt.translations import Translations from src.qt.widgets.panel import PanelWidget @@ -43,7 +42,7 @@ def __init__(self, library: "Library"): self.root_layout.setContentsMargins(6, 6, 6, 6) # Create Table Widget -------------------------------------------------- - self.table = QTableWidget(len(self.lib.prefs(LibraryPrefs.EXTENSION_LIST)), 1) + self.table = QTableWidget(len(self.lib.settings.extension_list), 1) self.table.horizontalHeader().setVisible(False) self.table.verticalHeader().setVisible(False) self.table.horizontalHeader().setStretchLastSection(True) @@ -74,7 +73,7 @@ def __init__(self, library: "Library"): lambda text: self.mode_combobox.setItemText(1, text), "ignore_list.mode.exclude" ) - is_exclude_list = int(bool(self.lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST))) + is_exclude_list = int(bool(self.lib.settings.is_exclude_list)) self.mode_combobox.setCurrentIndex(is_exclude_list) self.mode_combobox.currentIndexChanged.connect(lambda i: self.update_list_mode(i)) @@ -97,10 +96,10 @@ def update_list_mode(self, mode: int): mode (int): The list mode, given by the index of the mode inside the mode combobox. 1 for "Exclude", 0 for "Include". """ - self.lib.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, bool(mode)) + self.lib.settings.is_exclude_list = bool(mode) def refresh_list(self): - for i, ext in enumerate(self.lib.prefs(LibraryPrefs.EXTENSION_LIST)): + for i, ext in enumerate(self.lib.settings.extension_list): self.table.setItem(i, 0, QTableWidgetItem(ext)) def add_item(self): @@ -114,4 +113,4 @@ def save(self): extensions.append(ext.text().strip().lstrip(".").lower()) # save preference - self.lib.set_prefs(LibraryPrefs.EXTENSION_LIST, extensions) + self.lib.settings.extension_list = extensions diff --git a/tagstudio/src/qt/modals/settings_modal.py b/tagstudio/src/qt/modals/settings_modal.py new file mode 100644 index 000000000..5a09c0c6a --- /dev/null +++ b/tagstudio/src/qt/modals/settings_modal.py @@ -0,0 +1,62 @@ +import copy +from pathlib import Path +from typing import Any + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QHBoxLayout, + QLabel, + QVBoxLayout, +) +from src.core.settings import TSSettings +from src.qt.translations import Translations +from src.qt.widgets.panel import PanelWidget + + +class SettingsModal(PanelWidget): + def __init__(self, settings: TSSettings): + super().__init__() + self.tempSettings: TSSettings = copy.deepcopy(settings) + + self.main = QVBoxLayout(self) + + # --- + language_row = QHBoxLayout(self) + language_label = QLabel(self) + Translations.translate_qobject(language_label, "settings.language") + language_value = QComboBox(self) + language_row.addWidget(language_label) + language_row.addWidget(language_value) + + translations_folder = Path("tagstudio/resources/translations") + language_list = [x.stem for x in translations_folder.glob("*.json")] + language_value.addItems(language_list) + language_value.setCurrentIndex(language_list.index(self.tempSettings.language)) + language_value.currentTextChanged.connect( + lambda text: setattr(self.tempSettings, "language", text) + ) + + # --- + show_filenames_row = QHBoxLayout(self) + show_filenames_label = QLabel(self) + Translations.translate_qobject(show_filenames_label, "settings.show_filenames_in_grid") + show_filenames_value = QCheckBox(self) + + show_filenames_value.setChecked(self.tempSettings.show_filenames_in_grid) + show_filenames_row.addWidget(show_filenames_label) + show_filenames_row.addWidget(show_filenames_value) + + show_filenames_value.stateChanged.connect( + lambda state: setattr(self.tempSettings, "show_filenames_in_grid", bool(state)) + ) + # --- + + self.main.addLayout(language_row) + self.main.addLayout(show_filenames_row) + + def set_property(self, prop_name: str, value: Any) -> None: + setattr(self.tempSettings, prop_name, value) + + def get_content(self): + return self.tempSettings diff --git a/tagstudio/src/qt/translations.py b/tagstudio/src/qt/translations.py index 5d2dcea0a..4e411377c 100644 --- a/tagstudio/src/qt/translations.py +++ b/tagstudio/src/qt/translations.py @@ -32,7 +32,7 @@ def value(self) -> str: def value(self, value: str): if self.__value != value: self.__value = value - self.changed.emit(self.__value) + self.changed.emit(self.value) class Translator: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index d80e20390..269a22f9b 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -9,6 +9,7 @@ import ctypes import dataclasses +import datetime import math import os import re @@ -23,7 +24,14 @@ import structlog from humanfriendly import format_timespan from PySide6 import QtCore -from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal +from PySide6.QtCore import ( + QObject, + Qt, + QThread, + QThreadPool, + QTimer, + Signal, +) from PySide6.QtGui import ( QAction, QColor, @@ -57,7 +65,7 @@ VERSION_BRANCH, ) from src.core.driver import DriverMixin -from src.core.enums import LibraryPrefs, MacroID, SettingItems +from src.core.enums import MacroID from src.core.library.alchemy import Library from src.core.library.alchemy.enums import ( FieldTypeEnum, @@ -68,7 +76,9 @@ from src.core.library.alchemy.fields import _FieldID from src.core.library.alchemy.library import Entry, LibraryStatus from src.core.media_types import MediaCategories +from src.core.settings import TSSettings from src.core.ts_core import TagStudioCore +from src.core.tscacheddata import TSCachedData from src.core.utils.refresh_dir import RefreshDirTracker from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout @@ -81,6 +91,7 @@ from src.qt.modals.fix_dupes import FixDupeFilesModal from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal from src.qt.modals.folders_to_tags import FoldersToTagsModal +from src.qt.modals.settings_modal import SettingsModal from src.qt.modals.tag_database import TagDatabasePanel from src.qt.resource_manager import ResourceManager from src.qt.translations import Translations @@ -165,18 +176,19 @@ def __init__(self, backend, args): if not path.exists(): logger.warning("Config File does not exist creating", path=path) logger.info("Using Config File", path=path) - self.settings = QSettings(str(path), QSettings.Format.IniFormat) + self.settings = TSSettings.read_settings(path) else: - self.settings = QSettings( - QSettings.Format.IniFormat, - QSettings.Scope.UserScope, - "TagStudio", - "TagStudio", - ) + if sys.platform == "win32": + path = Path.home() / "AppData" / "Roaming" / "TagStudio" / "config.toml" + else: # "linux" and "darwin" should use the same config directory + path = Path.home() / ".config" / "TagStudio" / "config.toml" + + self.settings = TSSettings.read_settings(path) logger.info( - "Config File not specified, using default one", - filename=self.settings.fileName(), + "Config File not specified, using default one", filename=self.settings.filename ) + Translations.change_language(self.settings.language) + self.cache = TSCachedData.open() def init_workers(self): """Init workers for rendering thumbnails.""" @@ -275,8 +287,6 @@ def start(self) -> None: Translations.translate_qobject(file_menu, "menu.file") edit_menu = QMenu(menu_bar) Translations.translate_qobject(edit_menu, "generic.edit_alt") - view_menu = QMenu(menu_bar) - Translations.translate_qobject(view_menu, "menu.view") tools_menu = QMenu(menu_bar) Translations.translate_qobject(tools_menu, "menu.tools") macros_menu = QMenu(menu_bar) @@ -307,11 +317,9 @@ def start(self) -> None: open_on_start_action = QAction(self) Translations.translate_qobject(open_on_start_action, "settings.open_library_on_start") open_on_start_action.setCheckable(True) - open_on_start_action.setChecked( - bool(self.settings.value(SettingItems.START_LOAD_LAST, defaultValue=True, type=bool)) - ) + open_on_start_action.setChecked(self.settings.open_last_loaded_on_startup) open_on_start_action.triggered.connect( - lambda checked: self.settings.setValue(SettingItems.START_LOAD_LAST, checked) + lambda checked: setattr(self.settings, "open_last_loaded_on_startup", checked) ) file_menu.addAction(open_on_start_action) @@ -356,8 +364,12 @@ def start(self) -> None: close_library_action.triggered.connect(self.close_library) file_menu.addAction(close_library_action) file_menu.addSeparator() + settings_menu_action = QAction("&Settings", menu_bar) + settings_menu_action.triggered.connect(lambda: self.open_settings_menu()) + file_menu.addAction(settings_menu_action) # Edit Menu ============================================================ + new_tag_action = QAction(menu_bar) Translations.translate_qobject(new_tag_action, "menu.edit.new_tag") new_tag_action.triggered.connect(lambda: self.add_tag_action_callback()) @@ -409,23 +421,7 @@ def start(self) -> None: show_libs_list_action = QAction(menu_bar) Translations.translate_qobject(show_libs_list_action, "settings.show_recent_libraries") show_libs_list_action.setCheckable(True) - show_libs_list_action.setChecked( - bool(self.settings.value(SettingItems.WINDOW_SHOW_LIBS, defaultValue=True, type=bool)) - ) - - show_filenames_action = QAction(menu_bar) - Translations.translate_qobject(show_filenames_action, "settings.show_filenames_in_grid") - show_filenames_action.setCheckable(True) - show_filenames_action.setChecked( - bool(self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool)) - ) - show_filenames_action.triggered.connect( - lambda checked: ( - self.settings.setValue(SettingItems.SHOW_FILENAMES, checked), - self.show_grid_filenames(checked), - ) - ) - view_menu.addAction(show_filenames_action) + show_libs_list_action.setChecked(self.settings.show_library_list) # Tools Menu =========================================================== def create_fix_unlinked_entries_modal(): @@ -485,7 +481,6 @@ def create_folders_tags_modal(): menu_bar.addMenu(file_menu) menu_bar.addMenu(edit_menu) - menu_bar.addMenu(view_menu) menu_bar.addMenu(tools_menu) menu_bar.addMenu(macros_menu) menu_bar.addMenu(help_menu) @@ -628,10 +623,6 @@ def init_library_window(self): self.splash.finish(self.main_window) self.preview_panel.update_widgets() - def show_grid_filenames(self, value: bool): - for thumb in self.item_thumbs: - thumb.set_filename_visibility(value) - def callback_library_needed_check(self, func): """Check if loaded library has valid path before executing the button function.""" if self.lib.library_dir: @@ -663,8 +654,10 @@ def close_library(self, is_shutdown: bool = False): self.main_window.statusbar.showMessage(Translations["status.library_closing"]) start_time = time.time() - self.settings.setValue(SettingItems.LAST_LIBRARY, str(self.lib.library_dir)) - self.settings.sync() + self.cache.last_library = str(self.lib.library_dir) + self.settings.save() + self.lib.settings.save() + self.cache.save() self.lib.close() @@ -727,6 +720,24 @@ def add_tag_action_callback(self): ) self.modal.show() + def open_settings_menu(self): + self.modal = PanelModal( + SettingsModal(self.settings), + "Settings", + "Settings", + has_save=True, + save_callback=(lambda x: self.update_settings(x)), + ) + self.modal.show() + + def update_settings(self, settings: TSSettings): + self.settings = settings + self.settings.save(self.settings.filename) + Translations.change_language(self.settings.language) + + for thumb in self.item_thumbs: + thumb.set_filename_visibility(self.settings.show_filenames_in_grid) + def select_all_action_callback(self): """Set the selection to all visible items.""" self.selected.clear() @@ -1001,9 +1012,7 @@ def _init_thumb_grid(self): self.lib, self, (self.thumb_size, self.thumb_size), - bool( - self.settings.value(SettingItems.SHOW_FILENAMES, defaultValue=True, type=bool) - ), + self.settings.show_filenames_in_grid, ) layout.addWidget(item_thumb) @@ -1309,37 +1318,28 @@ def filter_items(self, filter: FilterState | None = None) -> None: self.pages_count, self.filter.page_index, emit=False ) - def remove_recent_library(self, item_key: str): - self.settings.beginGroup(SettingItems.LIBS_LIST) - self.settings.remove(item_key) - self.settings.endGroup() - self.settings.sync() + def remove_recent_library(self, item_key: str) -> None: + self.cache.library_history.pop(item_key) def update_libs_list(self, path: Path | str): - """Add library to list in SettingItems.LIBS_LIST.""" item_limit: int = 5 path = Path(path) - self.settings.beginGroup(SettingItems.LIBS_LIST) + all_libs = {datetime.datetime.fromtimestamp(time.time()).isoformat(): str(path)} - all_libs = {str(time.time()): str(path)} + for access_time in self.cache.library_history: + lib = self.cache.library_history[access_time] + if Path(lib) != path: + all_libs[access_time] = lib - for item_key in self.settings.allKeys(): - item_path = str(self.settings.value(item_key, type=str)) - if Path(item_path) != path: - all_libs[item_key] = item_path - - # sort items, most recent first all_libs_list = sorted(all_libs.items(), key=lambda item: item[0], reverse=True) - # remove previously saved items - self.settings.remove("") - + lib_hist = {} for item_key, item_value in all_libs_list[:item_limit]: - self.settings.setValue(item_key, item_value) + lib_hist[item_key] = item_value + + self.cache.library_history = lib_hist - self.settings.endGroup() - self.settings.sync() self.update_recent_lib_menu() def update_recent_lib_menu(self): @@ -1347,10 +1347,8 @@ def update_recent_lib_menu(self): actions: list[QAction] = [] lib_items: dict[str, tuple[str, str]] = {} - settings = self.settings - settings.beginGroup(SettingItems.LIBS_LIST) - for item_tstamp in settings.allKeys(): - val = str(settings.value(item_tstamp, type=str)) + for item_tstamp in self.cache.library_history: + val = str(self.cache.library_history[item_tstamp]) cut_val = val if len(val) > 45: cut_val = f"{val[0:10]} ... {val[-10:]}" @@ -1358,7 +1356,6 @@ def update_recent_lib_menu(self): # Sort lib_items by the key libs_sorted = sorted(lib_items.items(), key=lambda item: item[0], reverse=True) - settings.endGroup() # Create actions for each library for library_key in libs_sorted: @@ -1391,11 +1388,7 @@ def update_recent_lib_menu(self): def clear_recent_libs(self): """Clear the list of recent libraries from the settings file.""" - settings = self.settings - settings.beginGroup(SettingItems.LIBS_LIST) - self.settings.remove("") - self.settings.endGroup() - self.settings.sync() + self.cache.library_history = dict() self.update_recent_lib_menu() def open_library(self, path: Path) -> None: @@ -1436,7 +1429,7 @@ def init_library(self, path: Path, open_status: LibraryStatus): self.init_workers() - self.filter.page_size = self.lib.prefs(LibraryPrefs.PAGE_SIZE) + self.filter.page_size = self.lib.settings.page_size # TODO - make this call optional if self.lib.entries_count < 10000: diff --git a/tagstudio/src/qt/widgets/migration_modal.py b/tagstudio/src/qt/widgets/migration_modal.py index 954ad4722..16fbc1d68 100644 --- a/tagstudio/src/qt/widgets/migration_modal.py +++ b/tagstudio/src/qt/widgets/migration_modal.py @@ -20,7 +20,6 @@ from sqlalchemy import select from sqlalchemy.orm import Session from src.core.constants import LEGACY_TAG_FIELD_IDS, TS_FOLDER_NAME -from src.core.enums import LibraryPrefs from src.core.library.alchemy.enums import TagColor from src.core.library.alchemy.joins import TagParent from src.core.library.alchemy.library import TAG_ARCHIVED, TAG_FAVORITE, TAG_META @@ -442,12 +441,12 @@ def update_sql_value_ui(self, show_msg_box: bool = True): ) self.update_sql_value( self.ext_row, - len(self.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)), + len(self.sql_lib.settings.extension_list), self.old_ext_count, ) self.update_sql_value( self.ext_type_row, - self.sql_lib.prefs(LibraryPrefs.IS_EXCLUDE_LIST), + self.sql_lib.settings.is_exclude_list, self.old_ext_type, ) logger.info("Parity check complete!") @@ -649,7 +648,7 @@ def check_subtag_parity(self) -> bool: 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) + return self.json_lib.is_exclude_list == self.sql_lib.settings.is_exclude_list def check_alias_parity(self) -> bool: """Check if all JSON aliases match the new SQL aliases.""" diff --git a/tagstudio/src/qt/widgets/panel.py b/tagstudio/src/qt/widgets/panel.py index 573f6f48d..3a5ffc8ac 100755 --- a/tagstudio/src/qt/widgets/panel.py +++ b/tagstudio/src/qt/widgets/panel.py @@ -2,7 +2,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import logging -from typing import Callable +from typing import Any, Callable from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget @@ -111,7 +111,7 @@ class PanelWidget(QWidget): def __init__(self): super().__init__() - def get_content(self) -> str: + def get_content(self) -> Any: pass def reset(self): diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index 7e5e6ba15..cb42516c4 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -28,7 +28,6 @@ from PySide6.QtMultimediaWidgets import QGraphicsVideoItem from PySide6.QtSvgWidgets import QSvgWidget from PySide6.QtWidgets import QGraphicsScene, QGraphicsView -from src.core.enums import SettingItems from src.qt.helpers.file_opener import FileOpenerHelper from src.qt.platform_strings import PlatformStrings from src.qt.translations import Translations @@ -120,9 +119,7 @@ def __init__(self, driver: "QtDriver") -> None: Translations.translate_qobject(autoplay_action, "media_player.autoplay") autoplay_action.setCheckable(True) self.addAction(autoplay_action) - autoplay_action.setChecked( - self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) # type: ignore - ) + autoplay_action.setChecked(self.driver.settings.autoplay) autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action @@ -142,8 +139,8 @@ def close(self, *args, **kwargs) -> None: def toggle_autoplay(self) -> None: """Toggle the autoplay state of the video.""" - self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked()) - self.driver.settings.sync() + self.driver.settings.autoplay = self.autoplay.isChecked() + self.driver.settings.save() def check_media_status(self, media_status: QMediaPlayer.MediaStatus) -> None: if media_status == QMediaPlayer.MediaStatus.EndOfMedia: diff --git a/tagstudio/tests/macros/test_refresh_dir.py b/tagstudio/tests/macros/test_refresh_dir.py index a4d3e808b..4326d6f82 100644 --- a/tagstudio/tests/macros/test_refresh_dir.py +++ b/tagstudio/tests/macros/test_refresh_dir.py @@ -2,7 +2,6 @@ from tempfile import TemporaryDirectory import pytest -from src.core.enums import LibraryPrefs from src.core.utils.refresh_dir import RefreshDirTracker CWD = pathlib.Path(__file__).parent @@ -12,8 +11,8 @@ @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True) def test_refresh_new_files(library, exclude_mode): # Given - library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, exclude_mode) - library.set_prefs(LibraryPrefs.EXTENSION_LIST, [".md"]) + library.settings.is_exclude_list = exclude_mode + library.settings.extension_list = [".md"] registry = RefreshDirTracker(library=library) library.included_files.clear() (library.library_dir / "FOO.MD").touch() diff --git a/tagstudio/tests/test_driver.py b/tagstudio/tests/test_driver.py index 240406f93..a861c95cf 100644 --- a/tagstudio/tests/test_driver.py +++ b/tagstudio/tests/test_driver.py @@ -2,21 +2,23 @@ from pathlib import Path from tempfile import TemporaryDirectory -from PySide6.QtCore import QSettings from src.core.constants import TS_FOLDER_NAME from src.core.driver import DriverMixin -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 class TestDriver(DriverMixin): - def __init__(self, settings): + def __init__(self, settings, cache: TSCachedData | None = None): self.settings = settings + if cache: + self.cache = cache def test_evaluate_path_empty(): # Given - settings = QSettings() + settings = TSSettings(filename="") driver = TestDriver(settings) # When @@ -28,7 +30,7 @@ def test_evaluate_path_empty(): def test_evaluate_path_missing(): # Given - settings = QSettings() + settings = TSSettings(filename="") driver = TestDriver(settings) # When @@ -40,9 +42,10 @@ def test_evaluate_path_missing(): def test_evaluate_path_last_lib_not_exists(): # Given - settings = QSettings() - settings.setValue(SettingItems.LAST_LIBRARY, "/0/4/5/1/") - driver = TestDriver(settings) + settings = TSSettings(filename="") + cache = TSCachedData.open() + cache.last_library = "/0/4/5/1/" + driver = TestDriver(settings, cache) # When result = driver.evaluate_path(None) @@ -54,16 +57,15 @@ def test_evaluate_path_last_lib_not_exists(): def test_evaluate_path_last_lib_present(): # Given with TemporaryDirectory() as tmpdir: - settings_file = tmpdir + "/test_settings.ini" - settings = QSettings(settings_file, QSettings.Format.IniFormat) - settings.setValue(SettingItems.LAST_LIBRARY, tmpdir) - settings.sync() + settings_file = tmpdir + "/test_settings.toml" + cache = TSCachedData.open(settings_file) + cache.last_library = tmpdir + cache.save() makedirs(Path(tmpdir) / TS_FOLDER_NAME) - driver = TestDriver(settings) + driver = TestDriver(TSSettings(filename="", open_last_loaded_on_startup=True), cache) # When result = driver.evaluate_path(None) - # Then assert result == LibraryStatus(success=True, library_path=Path(tmpdir)) diff --git a/tagstudio/tests/test_json_migration.py b/tagstudio/tests/test_json_migration.py index c8ad58e6c..330b07742 100644 --- a/tagstudio/tests/test_json_migration.py +++ b/tagstudio/tests/test_json_migration.py @@ -5,7 +5,6 @@ import pathlib from time import time -from src.core.enums import LibraryPrefs from src.qt.widgets.migration_modal import JsonMigrationModal CWD = pathlib.Path(__file__) @@ -41,9 +40,9 @@ def test_json_migration(): # Extension Filter List ==================================================== # Count - assert len(modal.json_lib.ext_list) == len(modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST)) + assert len(modal.json_lib.ext_list) == len(modal.sql_lib.settings.extension_list) # List Type assert modal.check_ext_type() # No Leading Dot - for ext in modal.sql_lib.prefs(LibraryPrefs.EXTENSION_LIST): + for ext in modal.sql_lib.settings.extension_list: assert ext[0] != "." diff --git a/tagstudio/tests/test_library.py b/tagstudio/tests/test_library.py index ef88fe8bd..496ca13f8 100644 --- a/tagstudio/tests/test_library.py +++ b/tagstudio/tests/test_library.py @@ -2,7 +2,7 @@ from tempfile import TemporaryDirectory import pytest -from src.core.enums import DefaultEnum, LibraryPrefs +from src.core.enums import DefaultEnum from src.core.library.alchemy import Entry, Library from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import TextField, _FieldID @@ -175,8 +175,8 @@ def test_search_filter_extensions(library, is_exclude): entries = list(library.get_entries()) assert len(entries) == 2, entries - library.set_prefs(LibraryPrefs.IS_EXCLUDE_LIST, is_exclude) - library.set_prefs(LibraryPrefs.EXTENSION_LIST, ["md"]) + library.settings.is_exclude_list = is_exclude + library.settings.extension_list = ["md"] # When results = library.search_library( @@ -211,11 +211,6 @@ def test_search_library_case_insensitive(library): assert results[0].id == entry.id -def test_preferences(library): - for pref in LibraryPrefs: - assert library.prefs(pref) == pref.default - - def test_remove_entry_field(library, entry_full): title_field = entry_full.text_fields[0] @@ -362,9 +357,6 @@ def test_update_field_order(library, entry_full): 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 diff --git a/tagstudio/tests/test_settings.py b/tagstudio/tests/test_settings.py new file mode 100644 index 000000000..862bfa1c4 --- /dev/null +++ b/tagstudio/tests/test_settings.py @@ -0,0 +1,23 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from src.core.settings.tssettings import TSSettings + + +def test_read_settings(): + with TemporaryDirectory() as tmpdir: + settings_path = Path(tmpdir) / "example_settings.toml" + with open(settings_path, "a") as settings_file: + settings_file.write(""" + language = "en" + open_last_loaded_on_startup = false + show_filenames_in_grid = true + autoplay = false + filename = "" + """) + + settings = TSSettings.read_settings(settings_path) + assert settings.language == "en" + assert not settings.open_last_loaded_on_startup + assert settings.show_filenames_in_grid + assert not settings.autoplay