diff --git a/README.md b/README.md index d9d223bd2..a9d4776a2 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,14 @@ TagStudio is a photo & file organization application with an underlying system t - Linux/macOS: `source .venv/bin/activate` 3. Install the required packages: - `pip install -r requirements.txt` + +- required to run the app: `pip install -r requirements.txt` +- required to develop: `pip install -r requirements-dev.txt` _Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._ +To run all the tests use `python -m pytest tests/` from the `tagstudio` folder. + ### Launching > [!NOTE] diff --git a/requirements-dev.txt b/requirements-dev.txt index 4745ae0c1..3767113c6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ +pytest==8.2.0 ruff==0.4.2 pre-commit==3.7.0 diff --git a/requirements.txt b/requirements.txt index 0456b9207..853a1b814 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ PySide6>=6.5.1.1,<=6.6.3.1 PySide6_Addons>=6.5.1.1,<=6.6.3.1 PySide6_Essentials>=6.5.1.1,<=6.6.3.1 typing_extensions>=3.10.0.0,<=4.11.0 -ujson>=5.8.0,<=5.9.0 +ujson>=5.8.0,<=5.9.0 \ No newline at end of file diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py new file mode 100644 index 000000000..8b932c74d --- /dev/null +++ b/tagstudio/src/core/constants.py @@ -0,0 +1,41 @@ +VERSION: str = '9.2.0' # Major.Minor.Patch +VERSION_BRANCH: str = 'Alpha' # 'Alpha', 'Beta', or '' for Full Release + +# The folder & file names where TagStudio keeps its data relative to a library. +TS_FOLDER_NAME: str = '.TagStudio' +BACKUP_FOLDER_NAME: str = 'backups' +COLLAGE_FOLDER_NAME: str = 'collages' +LIBRARY_FILENAME: str = 'ts_library.json' + +# TODO: Turn this whitelist into a user-configurable blacklist. +IMAGE_TYPES: list[str] = ['png', 'jpg', 'jpeg', 'jpg_large', 'jpeg_large', + 'jfif', 'gif', 'tif', 'tiff', 'heic', 'heif', 'webp', + 'bmp', 'svg', 'avif', 'apng', 'jp2', 'j2k', 'jpg2'] +VIDEO_TYPES: list[str] = ['mp4', 'webm', 'mov', 'hevc', 'mkv', 'avi', 'wmv', + 'flv', 'gifv', 'm4p', 'm4v', '3gp'] +AUDIO_TYPES: list[str] = ['mp3', 'mp4', 'mpeg4', 'm4a', 'aac', 'wav', 'flac', + 'alac', 'wma', 'ogg', 'aiff'] +DOC_TYPES: list[str] = ['txt', 'rtf', 'md', + 'doc', 'docx', 'pdf', 'tex', 'odt', 'pages'] +PLAINTEXT_TYPES: list[str] = ['txt', 'md', 'css', 'html', 'xml', 'json', 'js', + 'ts', 'ini', 'htm', 'csv', 'php', 'sh', 'bat'] +SPREADSHEET_TYPES: list[str] = ['csv', 'xls', 'xlsx', 'numbers', 'ods'] +PRESENTATION_TYPES: list[str] = ['ppt', 'pptx', 'key', 'odp'] +ARCHIVE_TYPES: list[str] = ['zip', 'rar', 'tar', 'tar.gz', 'tgz', '7z'] +PROGRAM_TYPES: list[str] = ['exe', 'app'] +SHORTCUT_TYPES: list[str] = ['lnk', 'desktop', 'url'] + +ALL_FILE_TYPES: list[str] = IMAGE_TYPES + VIDEO_TYPES + AUDIO_TYPES + \ + DOC_TYPES + SPREADSHEET_TYPES + PRESENTATION_TYPES + \ + ARCHIVE_TYPES + PROGRAM_TYPES + SHORTCUT_TYPES + +BOX_FIELDS = ['tag_box', 'text_box'] +TEXT_FIELDS = ['text_line', 'text_box'] +DATE_FIELDS = ['datetime'] + +TAG_COLORS = ['', 'black', 'dark gray', 'gray', 'light gray', 'white', 'light pink', + 'pink', 'red', 'red orange', 'orange', 'yellow orange', 'yellow', + 'lime', 'light green', 'mint', 'green','teal', 'cyan', 'light blue', + 'blue', 'blue violet', 'violet', 'purple', 'lavender', 'berry', + 'magenta', 'salmon', 'auburn', 'dark brown', 'brown', 'light brown', + 'blonde', 'peach', 'warm gray', 'cool gray', 'olive'] \ No newline at end of file diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library.py index d463dc57a..b3517b12b 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library.py @@ -17,7 +17,7 @@ import ujson from src.core.json_typing import JsonCollation, JsonEntry, JsonLibary, JsonTag -from src.core import ts_core +from src.core.constants import TS_FOLDER_NAME, BACKUP_FOLDER_NAME, COLLAGE_FOLDER_NAME, VERSION, TEXT_FIELDS from src.core.utils.str import strip_punctuation from src.core.utils.web import strip_web_protocol @@ -154,8 +154,6 @@ def add_tag(self, library:'Library', tag_id:int, field_id:int, field_index:int=N self.fields[field_index][field_id] = sorted(tags, key=lambda t: library.get_tag(t).display_name(library)) # logging.info(f'Tags: {self.fields[field_index][field_id]}') - - class Tag: """A Library Tag Object. Referenced by ID.""" @@ -546,8 +544,8 @@ def create_library(self, path) -> int: path = os.path.normpath(path).rstrip('\\') # If '.TagStudio' is included in the path, trim the path up to it. - if ts_core.TS_FOLDER_NAME in path: - path = path.split(ts_core.TS_FOLDER_NAME)[0] + if TS_FOLDER_NAME in path: + path = path.split(TS_FOLDER_NAME)[0] try: self.clear_internal_vars() @@ -565,11 +563,11 @@ def verify_ts_folders(self) -> None: """Verifies/creates folders required by TagStudio.""" full_ts_path = os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}') + f'{self.library_dir}/{TS_FOLDER_NAME}') full_backup_path = os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}') + f'{self.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}') full_collage_path = os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.COLLAGE_FOLDER_NAME}') + f'{self.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}') if not os.path.isdir(full_ts_path): os.mkdir(full_ts_path) @@ -606,13 +604,13 @@ def open_library(self, path: str) -> int: path = os.path.normpath(path).rstrip('\\') # If '.TagStudio' is included in the path, trim the path up to it. - if ts_core.TS_FOLDER_NAME in path: - path = path.split(ts_core.TS_FOLDER_NAME)[0] + if TS_FOLDER_NAME in path: + path = path.split(TS_FOLDER_NAME)[0] - if os.path.exists(os.path.normpath(f'{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json')): + if os.path.exists(os.path.normpath(f'{path}/{TS_FOLDER_NAME}/ts_library.json')): try: - with open(os.path.normpath(f'{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json'), 'r', encoding='utf-8') as f: + with open(os.path.normpath(f'{path}/{TS_FOLDER_NAME}/ts_library.json'), 'r', encoding='utf-8') as f: json_dump: JsonLibary = ujson.load(f) self.library_dir = str(path) self.verify_ts_folders() @@ -788,9 +786,9 @@ def open_library(self, path: str) -> int: if return_code == 1: if not os.path.exists(os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}')): + f'{self.library_dir}/{TS_FOLDER_NAME}')): os.makedirs(os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}')) + f'{self.library_dir}/{TS_FOLDER_NAME}')) self._map_filenames_to_entry_ids() @@ -832,7 +830,7 @@ def to_json(self): Used in saving the library to disk. """ - file_to_save: JsonLibary = {"ts-version": ts_core.VERSION, + file_to_save: JsonLibary = {"ts-version": VERSION, "ignored_extensions": [], "tags": [], "collations": [], @@ -869,7 +867,7 @@ def save_library_to_disk(self): self.verify_ts_folders() - with open(os.path.normpath(f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{filename}'), 'w', encoding='utf-8') as outfile: + with open(os.path.normpath(f'{self.library_dir}/{TS_FOLDER_NAME}/{filename}'), 'w', encoding='utf-8') as outfile: outfile.flush() ujson.dump(self.to_json(), outfile, ensure_ascii=False, escape_forward_slashes=False) # , indent=4 <-- How to prettyprint dump @@ -886,7 +884,7 @@ def save_library_backup_to_disk(self) -> str: filename = f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json' self.verify_ts_folders() - with open(os.path.normpath(f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}/{filename}'), 'w', encoding='utf-8') as outfile: + with open(os.path.normpath(f'{self.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}/{filename}'), 'w', encoding='utf-8') as outfile: outfile.flush() ujson.dump(self.to_json(), outfile, ensure_ascii=False, escape_forward_slashes=False) end_time = time.time() @@ -932,11 +930,11 @@ def refresh_dir(self): # Scans the directory for files, keeping track of: # - Total file count # - Files without library entries - # for type in ts_core.TYPES: + # for type in TYPES: start_time = time.time() for f in glob.glob(self.library_dir + "/**/*", recursive=True): # p = Path(os.path.normpath(f)) - if ('$RECYCLE.BIN' not in f and ts_core.TS_FOLDER_NAME not in f + if ('$RECYCLE.BIN' not in f and TS_FOLDER_NAME not in f and 'tagstudio_thumbs' not in f and not os.path.isdir(f)): if os.path.splitext(f)[1][1:].lower() not in self.ignored_extensions: self.dir_file_count += 1 @@ -2038,7 +2036,7 @@ def add_field_to_entry(self, entry_id: int, field_id: int) -> None: # entry = self.entries[entry_index] entry = self.get_entry(entry_id) field_type = self.get_field_obj(field_id)['type'] - if field_type in ts_core.TEXT_FIELDS: + if field_type in TEXT_FIELDS: entry.fields.append({int(field_id): ''}) elif field_type == 'tag_box': entry.fields.append({int(field_id): []}) diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index f888a115f..a782077c1 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -7,51 +7,9 @@ import json import os +from src.core.constants import TEXT_FIELDS, TS_FOLDER_NAME from src.core.library import Entry, Library -VERSION: str = '9.2.0' # Major.Minor.Patch -VERSION_BRANCH: str = 'Alpha' # 'Alpha', 'Beta', or '' for Full Release - -# The folder & file names where TagStudio keeps its data relative to a library. -TS_FOLDER_NAME: str = '.TagStudio' -BACKUP_FOLDER_NAME: str = 'backups' -COLLAGE_FOLDER_NAME: str = 'collages' -LIBRARY_FILENAME: str = 'ts_library.json' - -# TODO: Turn this whitelist into a user-configurable blacklist. -IMAGE_TYPES: list[str] = ['png', 'jpg', 'jpeg', 'jpg_large', 'jpeg_large', - 'jfif', 'gif', 'tif', 'tiff', 'heic', 'heif', 'webp', - 'bmp', 'svg', 'avif', 'apng', 'jp2', 'j2k', 'jpg2'] -VIDEO_TYPES: list[str] = ['mp4', 'webm', 'mov', 'hevc', 'mkv', 'avi', 'wmv', - 'flv', 'gifv', 'm4p', 'm4v', '3gp'] -AUDIO_TYPES: list[str] = ['mp3', 'mp4', 'mpeg4', 'm4a', 'aac', 'wav', 'flac', - 'alac', 'wma', 'ogg', 'aiff'] -DOC_TYPES: list[str] = ['txt', 'rtf', 'md', - 'doc', 'docx', 'pdf', 'tex', 'odt', 'pages'] -PLAINTEXT_TYPES: list[str] = ['txt', 'md', 'css', 'html', 'xml', 'json', 'js', - 'ts', 'ini', 'htm', 'csv', 'php', 'sh', 'bat'] -SPREADSHEET_TYPES: list[str] = ['csv', 'xls', 'xlsx', 'numbers', 'ods'] -PRESENTATION_TYPES: list[str] = ['ppt', 'pptx', 'key', 'odp'] -ARCHIVE_TYPES: list[str] = ['zip', 'rar', 'tar', 'tar.gz', 'tgz', '7z'] -PROGRAM_TYPES: list[str] = ['exe', 'app'] -SHORTCUT_TYPES: list[str] = ['lnk', 'desktop', 'url'] - -ALL_FILE_TYPES: list[str] = IMAGE_TYPES + VIDEO_TYPES + AUDIO_TYPES + \ - DOC_TYPES + SPREADSHEET_TYPES + PRESENTATION_TYPES + \ - ARCHIVE_TYPES + PROGRAM_TYPES + SHORTCUT_TYPES - -BOX_FIELDS = ['tag_box', 'text_box'] -TEXT_FIELDS = ['text_line', 'text_box'] -DATE_FIELDS = ['datetime'] - -TAG_COLORS = ['', 'black', 'dark gray', 'gray', 'light gray', 'white', 'light pink', - 'pink', 'red', 'red orange', 'orange', 'yellow orange', 'yellow', - 'lime', 'light green', 'mint', 'green','teal', 'cyan', 'light blue', - 'blue', 'blue violet', 'violet', 'purple', 'lavender', 'berry', - 'magenta', 'salmon', 'auburn', 'dark brown', 'brown', 'light brown', - 'blonde', 'peach', 'warm gray', 'cool gray', 'olive'] - - class TagStudioCore: """ Instantiate this to establish a TagStudio session. diff --git a/tagstudio/src/core/utils/str.py b/tagstudio/src/core/utils/str.py index 338ee9f3e..0ed0a661a 100644 --- a/tagstudio/src/core/utils/str.py +++ b/tagstudio/src/core/utils/str.py @@ -2,10 +2,13 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -def strip_punctuation(string: str) -> str: +def strip_punctuation(text: str) -> str: """Returns a given string stripped of all punctuation characters.""" - return string.replace('(', '').replace(')', '').replace('[', '') \ - .replace(']', '').replace('{', '').replace('}', '').replace("'", '') \ - .replace('`', '').replace('’', '').replace('‘', '').replace('"', '') \ - .replace('“', '').replace('”', '').replace('_', '').replace('-', '') \ - .replace(' ', '').replace(' ', '') + punctuation = '{}[]()\'"`‘’“”-_  ' + result = text + + for p in punctuation: + result = result.replace(p, '') + + return result + diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index b5c43c313..7f23c3229 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -10,7 +10,7 @@ from src.core.library import Library, Tag from src.core.palette import ColorType, get_tag_color -from src.core.ts_core import TAG_COLORS +from src.core.constants import TAG_COLORS from src.qt.widgets import PanelWidget, PanelModal, TagWidget from src.qt.modals import TagSearchPanel diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 88a01e4be..1db25e808 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -27,9 +27,9 @@ from PySide6.QtWidgets import (QApplication, QWidget, QHBoxLayout, QPushButton, QLineEdit, QScrollArea, QFileDialog, QSplashScreen, QMenu) from humanfriendly import format_timespan - from src.core.library import ItemType -from src.core.ts_core import (PLAINTEXT_TYPES, TagStudioCore, TAG_COLORS, DATE_FIELDS, TEXT_FIELDS, BOX_FIELDS, ALL_FILE_TYPES, +from src.core.ts_core import TagStudioCore +from src.core.constants import (PLAINTEXT_TYPES, TAG_COLORS, DATE_FIELDS, TEXT_FIELDS, BOX_FIELDS, ALL_FILE_TYPES, SHORTCUT_TYPES, PROGRAM_TYPES, ARCHIVE_TYPES, PRESENTATION_TYPES, SPREADSHEET_TYPES, DOC_TYPES, AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES, LIBRARY_FILENAME, COLLAGE_FOLDER_NAME, BACKUP_FOLDER_NAME, TS_FOLDER_NAME, diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index ee43521c8..b4ad7da84 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -12,7 +12,7 @@ from PySide6.QtCore import QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QTimer, QSettings from src.core.library import Library -from src.core.ts_core import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES ERROR = f'[ERROR]' diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 9aff37024..2aa24d4fa 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -17,7 +17,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QBoxLayout, QCheckBox from src.core.library import ItemType, Library -from src.core.ts_core import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES from src.qt.flowlayout import FlowWidget from src.qt.helpers import FileOpenerHelper from src.qt.widgets import ThumbRenderer, ThumbButton diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 52dcaf364..ec796e1b7 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -18,7 +18,7 @@ from humanfriendly import format_size from src.core.library import Entry, ItemType, Library -from src.core.ts_core import VIDEO_TYPES, IMAGE_TYPES +from src.core.constants import VIDEO_TYPES, IMAGE_TYPES from src.qt.helpers import FileOpenerLabel, FileOpenerHelper, open_file from src.qt.modals import AddFieldModal from src.qt.widgets import (ThumbRenderer, FieldContainer, TagBoxWidget, TextWidget, PanelModal, EditTextBox, diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index be600304e..e4ef3be94 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -13,7 +13,7 @@ from PIL import Image, ImageChops, UnidentifiedImageError, ImageQt, ImageDraw, ImageFont, ImageEnhance, ImageOps from PySide6.QtCore import QObject, Signal, QSize from PySide6.QtGui import QPixmap -from src.core.ts_core import PLAINTEXT_TYPES, VIDEO_TYPES, IMAGE_TYPES +from src.core.constants import PLAINTEXT_TYPES, VIDEO_TYPES, IMAGE_TYPES ERROR = f'[ERROR]' diff --git a/tagstudio/tests/core/test_tags.py b/tagstudio/tests/core/test_tags.py index 33c8e895c..caba192c8 100644 --- a/tagstudio/tests/core/test_tags.py +++ b/tagstudio/tests/core/test_tags.py @@ -1,12 +1,11 @@ -from src.core.library import Tag +from tagstudio.src.core.library import Tag -class TestTags: - def test_construction(self): - tag = Tag(id=1, name='Tag Name', shorthand='TN', aliases=[ - 'First A', 'Second A'], subtags_ids=[2, 3, 4], color='') - assert (tag) +def test_construction(): + tag = Tag(id=1, name='Tag Name', shorthand='TN', aliases=[ + 'First A', 'Second A'], subtags_ids=[2, 3, 4], color='') + assert tag - def test_empty_construction(self): - tag = Tag(id=1, name='', shorthand='', aliases=[], subtags_ids=[], color='') - assert (tag) \ No newline at end of file +def test_empty_construction(): + tag = Tag(id=1, name='', shorthand='', aliases=[], subtags_ids=[], color='') + assert tag \ No newline at end of file diff --git a/tagstudio/tests/utils/__init__.py b/tagstudio/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tagstudio/tests/utils/test_str.py b/tagstudio/tests/utils/test_str.py new file mode 100644 index 000000000..a8aa36166 --- /dev/null +++ b/tagstudio/tests/utils/test_str.py @@ -0,0 +1,12 @@ +import pytest + +from src.core.utils.str import strip_punctuation + +@pytest.mark.parametrize("text, expected", [ + ('{[(parenthesis)]}', 'parenthesis'), + ('‘“`"\'quotes\'"`”’', 'quotes'), + ('_-  spacers', 'spacers'), + ('{}[]()\'"`‘’“”-  ', '') +]) +def test_strip_punctuation(text, expected): + assert strip_punctuation(text) == expected