diff --git a/.vscode/launch.json b/.vscode/launch.json index 10f880f3..a6c172fe 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "TagStudio", "type": "debugpy", "request": "launch", - "program": "${workspaceRoot}\\TagStudio\\tagstudio.py", + "program": "${workspaceRoot}/tagstudio/tag_studio.py", "console": "integratedTerminal", "justMyCode": true, "args": ["--debug"] diff --git a/README.md b/README.md index a701e635..dd78737e 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ TagStudio is a photo & file organization application with an underlying system t ### Prerequisites -- Python 3.9.6 - ~3.10 *(Not working on 3.12)* +- Python 3.12 ### Creating the Virtual Environment @@ -90,19 +90,23 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired. -Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tagstudio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tagstudio.py`. +Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tag_studio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`. > [!CAUTION] > TagStudio on Linux & macOS likely won't function correctly at this time. If you're trying to run this in order to help test, debug, and improve compatibility, then charge on ahead! #### macOS -With the virtual environment loaded, run the python file at "tagstudio/tagstudio.py" from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tagstudio.py`. When launching the program in the future, remember to activate the virtual environment each time before launching *(an easier method is currently being worked on).* +With the virtual environment loaded, run the python file at "tagstudio/tag_studio.py" from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`. When launching the program in the future, remember to activate the virtual environment each time before launching *(an easier method is currently being worked on).* #### Linux Run the "TagStudio.sh" script, and the program should launch! (Make sure that the script is marked as executable). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `sh TagStudio.sh`. +##### NixOS + +Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the above `TagStudio.sh` script. + ## Usage ### Creating/Opening a Library diff --git a/TagStudio.sh b/TagStudio.sh index 82036413..762f752f 100755 --- a/TagStudio.sh +++ b/TagStudio.sh @@ -1,5 +1,5 @@ -#! /bin/bash +#! /usr/bin/env bash python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -python tagstudio/tagstudio.py \ No newline at end of file +python tagstudio/tag_studio.py diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..5dafd370 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1712473363, + "narHash": "sha256-TIScFAVdI2yuybMxxNjC4YZ/j++c64wwuKbpnZnGiyU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "e89cf1c932006531f454de7d652163a9a5c86668", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..f827ba07 --- /dev/null +++ b/flake.nix @@ -0,0 +1,70 @@ +{ + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs, }: + let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + in { + devShells.x86_64-linux.default = pkgs.mkShell { + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.gcc-unwrapped + pkgs.zlib + pkgs.libglvnd + pkgs.glib + pkgs.stdenv.cc.cc + pkgs.fontconfig + pkgs.libxkbcommon + pkgs.xorg.libxcb + pkgs.freetype + pkgs.dbus + pkgs.qt6.qtwayland + pkgs.qt6.full + pkgs.qt6.qtbase + pkgs.zstd + ]; + buildInputs = with pkgs; [ + cmake + gdb + zstd + qt6.qtbase + qt6.full + qt6.qtwayland + qtcreator + python312Packages.pip + python312Full + python312Packages.virtualenv # run virtualenv . + python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip + + libgcc + makeWrapper + bashInteractive + glib + libxkbcommon + freetype + binutils + dbus + coreutils + libGL + libGLU + fontconfig + xorg.libxcb + + + # this is for the shellhook portion + qt6.wrapQtAppsHook + makeWrapper + bashInteractive + ]; + # set the environment variables that Qt apps expect + shellHook = '' + export QT_QPA_PLATFORM=wayland + export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH + # export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/ + export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix} + bashdir=$(mktemp -d) + makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}" + exec "$bashdir/bash" + ''; + }; + }; +} diff --git a/requirements.txt b/requirements.txt index 0ce28a21..0456b920 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ humanfriendly==10.0 -opencv_python==4.8.0.74 +opencv_python>=4.8.0.74,<=4.9.0.80 Pillow==10.3.0 -pillow_avif_plugin==1.3.1 -PySide6==6.5.1.1 -PySide6_Addons==6.5.1.1 -PySide6_Essentials==6.5.1.1 -typing_extensions==3.10.0.0 -ujson==5.8.0 +pillow_avif_plugin>=1.3.1,<=1.4.3 +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 diff --git a/start_win.bat b/start_win.bat index 9258657f..73dcfb0f 100644 --- a/start_win.bat +++ b/start_win.bat @@ -1,2 +1,2 @@ @echo off -.venv\Scripts\python.exe .\TagStudio\tagstudio.py --ui qt %* --debug \ No newline at end of file +.venv\Scripts\python.exe .\TagStudio\tag_studio.py --ui qt %* \ No newline at end of file diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library.py index 06caf78b..97f2e871 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library.py @@ -5,24 +5,22 @@ """The Library object and related methods for TagStudio.""" import datetime -from enum import Enum -import os -import traceback -from typing import Optional -import json import glob -from pathlib import Path -# from typing_extensions import deprecated -import src.core.ts_core as ts_core -from src.core.utils.web import * -from src.core.utils.str import * -from src.core.utils.fs import * -import xml.etree.ElementTree as ET +import json +import logging +import os import sys import time -import logging +import traceback +import xml.etree.ElementTree as ET +from enum import Enum + import ujson +from src.core import ts_core +from src.core.utils.str import strip_punctuation +from src.core.utils.web import strip_web_protocol + TYPE = ['file', 'meta', 'alt', 'mask'] # RESULT_TYPE = Enum('Result', ['ENTRY', 'COLLATION', 'TAG_GROUP']) class ItemType(Enum): @@ -576,7 +574,7 @@ class Library: if not os.path.isdir(full_collage_path): os.mkdir(full_collage_path) - def verify_default_tags(self, tag_list: list) -> dict: + def verify_default_tags(self, tag_list: list) -> list: """ Ensures that the default builtin tags are present in the Library's save file. Takes in and returns the tag dictionary from the JSON file. @@ -630,41 +628,45 @@ class Library: if 'id' in tag.keys(): id = tag['id'] - if int(id) >= self._next_tag_id: - self._next_tag_id = int(id) + 1 + # Don't load tags with duplicate IDs + if id not in [t.id for t in self.tags]: + if int(id) >= self._next_tag_id: + self._next_tag_id = int(id) + 1 - name = '' - if 'name' in tag.keys(): - name = tag['name'] - shorthand = '' - if 'shorthand' in tag.keys(): - shorthand = tag['shorthand'] - aliases = [] - if 'aliases' in tag.keys(): - aliases = tag['aliases'] - subtag_ids = [] - if 'subtag_ids' in tag.keys(): - subtag_ids = tag['subtag_ids'] - color = '' - if 'color' in tag.keys(): - color = tag['color'] + name = '' + if 'name' in tag.keys(): + name = tag['name'] + shorthand = '' + if 'shorthand' in tag.keys(): + shorthand = tag['shorthand'] + aliases = [] + if 'aliases' in tag.keys(): + aliases = tag['aliases'] + subtag_ids = [] + if 'subtag_ids' in tag.keys(): + subtag_ids = tag['subtag_ids'] + color = '' + if 'color' in tag.keys(): + color = tag['color'] - t = Tag( - id=int(id), - name=name, - shorthand=shorthand, - aliases=aliases, - subtags_ids=subtag_ids, - color=color - ) + t = Tag( + id=int(id), + name=name, + shorthand=shorthand, + aliases=aliases, + subtags_ids=subtag_ids, + color=color + ) - # NOTE: This does NOT use the add_tag_to_library() method! - # That method is only used for Tags added at runtime. - # This process uses the same inner methods, but waits until all of the - # Tags are registered in the Tags list before creating the Tag clusters. - self.tags.append(t) - self._map_tag_id_to_index(t, -1) - self._map_tag_strings_to_tag_id(t) + # NOTE: This does NOT use the add_tag_to_library() method! + # That method is only used for Tags added at runtime. + # This process uses the same inner methods, but waits until all of the + # Tags are registered in the Tags list before creating the Tag clusters. + self.tags.append(t) + self._map_tag_id_to_index(t, -1) + self._map_tag_strings_to_tag_id(t) + else: + logging.info(f'[LIBRARY]Skipping Tag with duplicate ID: {tag}') # Step 3: Map each Tag's subtags together now that all Tag objects in it. for t in self.tags: @@ -856,10 +858,10 @@ class Library: } print('[LIBRARY] Formatting Tags to JSON...') - file_to_save['tags'] = self.verify_default_tags(file_to_save['tags']) for tag in self.tags: file_to_save["tags"].append(tag.compressed_dict()) - + + file_to_save['tags'] = self.verify_default_tags(file_to_save['tags']) print('[LIBRARY] Formatting Entries to JSON...') for entry in self.entries: file_to_save["entries"].append(entry.compressed_dict()) diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index 1ef7f028..0ea35039 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -4,16 +4,10 @@ """The core classes and methods of TagStudio.""" -import os -from types import FunctionType -# from typing import Dict, Optional, TypedDict, List import json -from pathlib import Path -import traceback -# import requests -# from bs4 import BeautifulSoup as bs -from src.core.library import * -from src.core.field_template import FieldTemplate +import os + +from src.core.library import Entry, Library VERSION: str = '9.1.0' # Major.Minor.Patch VERSION_BRANCH: str = 'Alpha' # 'Alpha', 'Beta', or '' for Full Release @@ -37,11 +31,11 @@ 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'] +SHORTCUT_TYPES: list[str] = ['lnk', 'desktop', 'url'] ALL_FILE_TYPES: list[str] = IMAGE_TYPES + VIDEO_TYPES + AUDIO_TYPES + \ TEXT_TYPES + SPREADSHEET_TYPES + PRESENTATION_TYPES + \ - ARCHIVE_TYPES + PROGRAM_TYPES + ARCHIVE_TYPES + PROGRAM_TYPES + SHORTCUT_TYPES BOX_FIELDS = ['tag_box', 'text_box'] TEXT_FIELDS = ['text_line', 'text_box'] diff --git a/tagstudio/src/core/utils/fs.py b/tagstudio/src/core/utils/fs.py index 1307b0e8..7c1052a6 100644 --- a/tagstudio/src/core/utils/fs.py +++ b/tagstudio/src/core/utils/fs.py @@ -2,9 +2,6 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import os - - def clean_folder_name(folder_name: str) -> str: cleaned_name = folder_name invalid_chars = "<>:\"/\\|?*." diff --git a/tagstudio/src/qt/flowlayout.py b/tagstudio/src/qt/flowlayout.py index 1e3c5075..b71c962d 100644 --- a/tagstudio/src/qt/flowlayout.py +++ b/tagstudio/src/qt/flowlayout.py @@ -4,9 +4,8 @@ """PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x""" -import sys from PySide6.QtCore import Qt, QMargins, QPoint, QRect, QSize -from PySide6.QtWidgets import QApplication, QLayout, QPushButton, QSizePolicy, QWidget +from PySide6.QtWidgets import QLayout, QSizePolicy, QWidget # class Window(QWidget): diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index 38c1c099..8055d930 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -12,23 +12,14 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from re import S -import time -from typing import Optional -from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, - QMetaObject, QObject, QPoint, QRect, - QSize, QTime, QUrl, Qt) -from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, - QFont, QFontDatabase, QGradient, QIcon, - QImage, QKeySequence, QLinearGradient, QPainter, - QPalette, QPixmap, QRadialGradient, QTransform, QAction) -from PySide6.QtWidgets import (QApplication, QComboBox, QFrame, QGridLayout, +from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect, + QSize, Qt) +from PySide6.QtGui import (QFont, QAction) +from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout, QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow, QMenuBar, QPushButton, QScrollArea, QSizePolicy, QStatusBar, QWidget, QSplitter, QMenu) from src.qt.pagination import Pagination -# from src.qt.qtacrylic.qtacrylic import WindowEffect -# from qframelesswindow import FramelessMainWindow, StandardTitleBar class Ui_MainWindow(QMainWindow): diff --git a/tagstudio/src/qt/pagination.py b/tagstudio/src/qt/pagination.py index 642cd103..074bf869 100644 --- a/tagstudio/src/qt/pagination.py +++ b/tagstudio/src/qt/pagination.py @@ -5,10 +5,10 @@ """A pagination widget created for TagStudio.""" # I never want to see this code again. -from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtWidgets import * -from PySide6.QtCore import QFile, QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QMimeData +from PySide6.QtCore import QObject, Signal, QSize +from PySide6.QtGui import QIntValidator +from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QLabel, QLineEdit, QSizePolicy + # class NumberEdit(QLineEdit): # def __init__(self, parent=None) -> None: diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 7ddb03bd..5a8122ec 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -7,40 +7,46 @@ """A Qt driver for TagStudio.""" -from copy import copy, deepcopy import ctypes -import math -from os import times -import sys import logging -import threading -from time import sleep -from queue import Empty, Queue +import math +import os +import sys import time -from typing import Optional, Union -from PySide6 import QtCore -import PySide6 -from PySide6.QtGui import * -from PySide6.QtWidgets import * -from PySide6.QtCore import QFile, QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QMimeData, QTimer -from PySide6.QtUiTools import QUiLoader -from PIL import Image, ImageOps, ImageChops, UnidentifiedImageError, ImageQt, ImageDraw, ImageFont, ImageEnhance -import PySide6.QtWidgets -import humanfriendly -import pillow_avif -import cv2 +import traceback +import shutil +import subprocess +from types import FunctionType from datetime import datetime as dt -from src.core.ts_core import * -# from src.core.utils.web import * -# from src.core.utils.fs import * -from src.core.library import * +from pathlib import Path +from queue import Empty, Queue +from time import sleep +from typing import Optional + +import cv2 +from PIL import Image, ImageChops, UnidentifiedImageError, ImageQt, ImageDraw, ImageFont, ImageEnhance +from PySide6 import QtCore +from PySide6.QtCore import QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QTimer +from PySide6.QtGui import (QGuiApplication, QPixmap, QEnterEvent, QMouseEvent, QResizeEvent, QPainter, QColor, QPen, + QAction, QStandardItemModel, QStandardItem, QPainterPath, QFontDatabase, QIcon) +from PySide6.QtUiTools import QUiLoader +from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QPlainTextEdit, + QLineEdit, QScrollArea, QFrame, QTextEdit, QComboBox, QProgressDialog, QFileDialog, + QListView, QSplitter, QSizePolicy, QMessageBox, QBoxLayout, QCheckBox, QSplashScreen, + QMenu) +from humanfriendly import format_timespan, format_size + +from src.core.library import Collation, Entry, ItemType, Library, Tag from src.core.palette import ColorType, get_tag_color +from src.core.ts_core import (TagStudioCore, TAG_COLORS, DATE_FIELDS, TEXT_FIELDS, BOX_FIELDS, ALL_FILE_TYPES, + SHORTCUT_TYPES, PROGRAM_TYPES, ARCHIVE_TYPES, PRESENTATION_TYPES, + SPREADSHEET_TYPES, TEXT_TYPES, AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES, + LIBRARY_FILENAME, COLLAGE_FOLDER_NAME, BACKUP_FOLDER_NAME, TS_FOLDER_NAME, + VERSION_BRANCH, VERSION) +from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout, FlowWidget from src.qt.main_window import Ui_MainWindow import src.qt.resources_rc -# from typing_extensions import deprecated -from humanfriendly import format_timespan -# from src.qt.qtacrylic.qtacrylic import WindowEffect # SIGQUIT is not defined on Windows if sys.platform == "win32": @@ -56,11 +62,20 @@ INFO = f'[INFO]' logging.basicConfig(format="%(message)s", level=logging.INFO) -def open_file(path): +def open_file(path: str): try: - os.startfile(path) - except FileNotFoundError: - logging.info('File Not Found! (Imagine this as a popup)') + if sys.platform == "win32": + subprocess.Popen(["start", path], shell=True, close_fds=True, creationflags=subprocess.DETACHED_PROCESS) + else: + if sys.platform == "darwin": + command_name = "open" + else: + command_name = "xdg-open" + command = shutil.which(command_name) + if command is not None: + subprocess.Popen([command, path], close_fds=True) + else: + logging.info(f"Could not find {command_name} on system PATH") except: traceback.print_exc() @@ -2131,12 +2146,12 @@ class PreviewPanel(QWidget): # Stats for specific file types are displayed here. if extension in (IMAGE_TYPES + VIDEO_TYPES): - self.dimensions_label.setText(f"{extension.upper()} • {humanfriendly.format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px") + self.dimensions_label.setText(f"{extension.upper()} • {format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px") else: self.dimensions_label.setText(f"{extension.upper()}") if not image: - self.dimensions_label.setText(f"{extension.upper()} • {humanfriendly.format_size(os.stat(filepath).st_size)}") + self.dimensions_label.setText(f"{extension.upper()} • {format_size(os.stat(filepath).st_size)}") raise UnidentifiedImageError except (UnidentifiedImageError, FileNotFoundError, cv2.error): @@ -4534,7 +4549,7 @@ class QtDriver(QObject): self.completed += 1 # logging.info(f'threshold:{len(self.lib.entries}, completed:{self.completed}') if self.completed == len(self.lib.entries): - filename = os.path.normpath(f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png') + filename = os.path.normpath(f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{dt.utcnow().strftime("%F_%T").replace(":", "")}.png') self.collage.save(filename) self.collage = None diff --git a/tagstudio/tagstudio.py b/tagstudio/tag_studio.py similarity index 99% rename from tagstudio/tagstudio.py rename to tagstudio/tag_studio.py index 50f48d6d..54039eb7 100644 --- a/tagstudio/tagstudio.py +++ b/tagstudio/tag_studio.py @@ -9,7 +9,6 @@ from src.cli.ts_cli import CliDriver from src.qt.ts_qt import QtDriver import argparse import traceback -# import ctypes def main():