diff --git a/.gitignore b/.gitignore index c5b97173..0a5d8271 100644 --- a/.gitignore +++ b/.gitignore @@ -249,5 +249,6 @@ compile_commands.json # TagStudio .TagStudio +TagStudio.ini # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt diff --git a/.vscode/launch.json b/.vscode/launch.json index e9505d33..8838fbb3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "TagStudio", "type": "python", "request": "launch", - "program": "${workspaceRoot}\\TagStudio\\tagstudio.py", + "program": "${workspaceRoot}/tagstudio/tag_studio.py", "console": "integratedTerminal", "justMyCode": true, "args": [] diff --git a/README.md b/README.md index 2afe1c0b..dd78737e 100644 --- a/README.md +++ b/README.md @@ -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/doc/documentation.md b/doc/documentation.md index 608b421f..5718e6b6 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -33,7 +33,7 @@ The Library is how TagStudio represents your chosen directory. In this Library o ## Fields -Fields are the the building blocks of metadata stored in Entires. Fields have several base types for representing different types of information, including: +Fields are the building blocks of metadata stored in Entires. Fields have several base types for representing different types of information, including: - `text_line` - A string of text, displayed as a single line. 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/start_win.bat b/start_win.bat index 51a5ddf8..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 %* \ 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/cli/ts_cli.py b/tagstudio/src/cli/ts_cli.py index 560d22b2..6605d7c2 100644 --- a/tagstudio/src/cli/ts_cli.py +++ b/tagstudio/src/cli/ts_cli.py @@ -493,7 +493,7 @@ class CliDriver: # print(f'Char Limit: {char_limit}, Len: {len(text)}') return char_limit - def truncate_text(self, text: str) -> int: + def truncate_text(self, text: str) -> str: """Returns a truncated string for displaying, calculated with `get_char_limit()`.""" if len(text) > self.get_char_limit(text): # print(f'Char Limit: {self.get_char_limit(text)}, Len: {len(text)}') @@ -761,7 +761,7 @@ class CliDriver: offset = 1 if (index >= row_count) and ( row_number != row_count) else 0 elif displayable % table_size != 0: - if col_num > 1 and col_num <= displayable % table_size: + if 1 < col_num <= displayable % table_size: offset += col_num - 1 elif col_num > 1 and col_num > displayable % table_size: offset = displayable % table_size @@ -1022,30 +1022,31 @@ class CliDriver: """ was_executed:bool = False message:str = '' + com_name = com[0].lower() # Backup Library ======================================================= - if (com[0].lower() == 'backup'): + if com_name == 'backup': self.backup_library(display_message=False) was_executed = True message=f'{INFO} Backed up Library to disk.' # Create Collage ======================================================= - elif (com[0].lower() == 'collage'): + elif com_name == 'collage': filename = self.create_collage() if filename: was_executed = True message = f'{INFO} Saved collage to \"{filename}\".' # Save Library ========================================================= - elif (com[0].lower() == 'save' or com[0].lower() == 'write' or com[0].lower() == 'w'): + elif com_name in ('save', 'write', 'w'): self.save_library(display_message=False) was_executed = True message=f'{INFO} Library saved to disk.' # Toggle Debug ========================================================= - elif (com[0].lower() == 'toggle-debug'): + elif com_name == 'toggle-debug': self.args.debug = not self.args.debug was_executed = True message=f'{INFO} Debug Mode Active.' if self.args.debug else f'{INFO} Debug Mode Deactivated.' # Toggle External Preview ============================================== - elif (com[0].lower() == 'toggle-external-preview'): + elif com_name == 'toggle-external-preview': self.args.external_preview = not self.args.external_preview if self.args.external_preview: self.init_external_preview() @@ -1054,11 +1055,11 @@ class CliDriver: was_executed = True message=f'{INFO} External Preview Enabled.' if self.args.external_preview else f'{INFO} External Preview Disabled.' # Quit ================================================================= - elif com[0].lower() == 'quit' or com[0].lower() == 'q': + elif com_name in ('quit', 'q'): self.exit(save=True, backup=False) was_executed = True # Quit without Saving ================================================== - elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + elif com_name in ('quit!', 'q!'): self.exit(save=False, backup=False) was_executed = True @@ -1345,7 +1346,7 @@ class CliDriver: # self.scr_library_home(clear_scr=False) # Add New Entries ================================================== elif ' '.join(com) == 'add new': - if self.is_new_file_count_init == False: + if not self.is_new_file_count_init: print( f'{INFO} Scanning for files in \'{self.lib.library_dir}\' (This may take a while)...') # if not self.lib.files_not_in_library: @@ -1390,7 +1391,7 @@ class CliDriver: for unresolved in self.lib.missing_matches: res = self.scr_choose_missing_match( self.lib.get_entry_id_from_filepath(unresolved), clear_scr=False) - if res != None and int(res) >= 0: + if res is not None and int(res) >= 0: clear() print( f'{INFO} Updated {self.lib.entries[self.lib.get_entry_id_from_filepath(unresolved)].path} -> {self.lib.missing_matches[unresolved][res]}') @@ -1555,7 +1556,7 @@ class CliDriver: print(self.format_title(title)) - if len(self.filtered_entries) > 0: + if self.filtered_entries: # entry = self.lib.get_entry_from_index( # self.filtered_entries[index]) entry = self.lib.get_entry(self.filtered_entries[index][1]) @@ -1580,7 +1581,7 @@ class CliDriver: self.print_fields(self.filtered_entries[index][1]) else: - if len(self.lib.entries) > 0: + if self.lib.entries: print(self.format_h1('No Entry Results for Query', color=BRIGHT_RED_FG)) self.set_external_preview_default() else: @@ -2049,7 +2050,7 @@ class CliDriver: '<#> Quit', BRIGHT_CYAN_FG)) print('> ', end='') - com: list[str] = input().lstrip().rstrip().split(' ') + com: list[str] = input().strip().split(' ') gc, message = self.global_commands(com) if gc: if message: @@ -2057,6 +2058,7 @@ class CliDriver: print(message) clear_scr=False else: + com_name = com[0].lower() try: # # Quit ========================================================= @@ -2069,13 +2071,13 @@ class CliDriver: # # self.cleanup() # sys.exit() # Cancel ======================================================= - if (com[0].lower() == 'cancel' or com[0].lower() == 'c' or com[0] == '0') and required==False: + if com_name in ('cancel', 'c', '0') and not required: clear() return -1 # Selection ==================================================== - elif int(com[0]) > 0 and int(com[0]) <= len(choices): + elif com_name.isdigit() and 0 < int(com_name) <= len(choices): clear() - return int(com[0]) - 1 + return int(com_name) - 1 else: # invalid_input = True # print(self.format_h1(str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) @@ -2554,7 +2556,7 @@ class CliDriver: f'Enter #{plural} Cancel', fg_color)) print('> ', end='') - com: list[int] = input().split(' ') + com: list[str] = input().split(' ') selected_ids: list[int] = [] try: for c in com: @@ -2625,14 +2627,14 @@ class CliDriver: self.lib.update_entry_field( entry_index, field_index, new_content.rstrip('\n').rstrip('\r'), 'replace') - def scr_list_tags(self, query: str = '', tag_ids: list[int] = [], clear_scr=True) -> None: + def scr_list_tags(self, query: str = '', tag_ids: list[int] = None, clear_scr=True) -> None: """A screen for listing out and performing CRUD operations on Library Tags.""" # NOTE: While a screen that just displays the first 40 or so random tags on your screen # isn't really that useful, this is just a temporary measure to provide a launchpad # screen for necessary commands such as adding and editing tags. # A more useful screen presentation might look like a list of ranked occurrences, but # that can be figured out and implemented later. - + tag_ids = tag_ids or [] title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' @@ -2673,7 +2675,7 @@ class CliDriver: 'Create Edit <#> Delete <#> Search Close/Done', BRIGHT_MAGENTA_FG)) print('> ', end='') - com: list[str] = input().lstrip().rstrip().split(' ') + com: list[str] = input().strip().split(' ') gc, message = self.global_commands(com) if gc: if message: @@ -2681,9 +2683,9 @@ class CliDriver: print(message) clear_scr=False else: - + com_name = com[0].lower() # Search Tags ========================================================== - if (com[0].lower() == 'search' or com[0].lower() == 's'): + if com_name in ('search', 's'): if len(com) > 1: new_query: str = ' '.join(com[1:]) # self.scr_list_tags(prev_scr, query=new_query, @@ -2696,7 +2698,7 @@ class CliDriver: tag_ids=self.lib.search_tags('') # return # Edit Tag =========================================================== - elif com[0].lower() == 'edit' or com[0].lower() == 'e': + elif com_name in ('edit', 'e'): if len(com) > 1: try: index = int(com[1]) - 1 @@ -2720,7 +2722,7 @@ class CliDriver: # return # Create Tag ============================================================ - elif com[0].lower() == 'create' or com[0].lower() == 'mk': + elif com_name in ('create', 'mk'): tag = Tag(id=0, name='New Tag', shorthand='', aliases=[], subtags_ids=[], color='') self.scr_manage_tag( @@ -2731,7 +2733,7 @@ class CliDriver: # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) # return # Delete Tag =========================================================== - elif com[0].lower() == 'delete' or com[0].lower() == 'del': + elif com_name in ('delete', 'del'): if len(com) > 1: if len(com) > 1: try: @@ -2757,7 +2759,7 @@ class CliDriver: # tag_ids=tag_ids, clear_scr=False) # return # Close View =========================================================== - elif (com[0].lower() == 'close' or com[0].lower() == 'c' or com[0].lower() == 'done'): + elif com_name in ('close', 'c', 'done'): # prev_scr() return # # Quit ================================================================= @@ -3192,7 +3194,7 @@ class CliDriver: selected: str = input() try: - if int(selected) > 0 and int(selected) <= len(colors): + if selected.isdigit() and 0 < int(selected) <= len(colors): selected = colors[int(selected)-1] return selected # except SystemExit: diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library.py index 2bc39d0d..5e208563 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library.py @@ -5,25 +5,21 @@ """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 tagstudio.src.core.json_typing import Json_Collation, Json_Entry, Json_Libary, Json_Tag +from src.core.json_typing import Json_Collation, Json_Entry, Json_Libary, Json_Tag +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']) @@ -136,7 +132,7 @@ class Entry: # if self.fields: # if field_index != -1: # logging.info(f'[LIBRARY] ADD TAG to E:{self.id}, F-DI:{field_id}, F-INDEX:{field_index}') - field_index = -1 if field_index == None else field_index + field_index = -1 if field_index is None else field_index for i, f in enumerate(self.fields): if library.get_field_attr(f, 'id') == field_id: field_index = i @@ -330,6 +326,9 @@ class Library: # That filename can then be used to provide quick lookup to image metadata entries in the Library. # NOTE: On Windows, these strings are always lowercase. self.filename_to_entry_id_map: dict[str, int] = {} + # A list of file extensions to be ignored by TagStudio. + self.default_ext_blacklist: list = ['json', 'xmp', 'aae'] + self.ignored_extensions: list = self.default_ext_blacklist # Tags ================================================================= # List of every Tag object (ts-v8). @@ -619,6 +618,10 @@ class Library: self.verify_ts_folders() major, minor, patch = json_dump['ts-version'].split('.') + # Load Extension Blacklist --------------------------------- + if 'ignored_extensions' in json_dump.keys(): + self.ignored_extensions = json_dump['ignored_extensions'] + # Parse Tags --------------------------------------------------- if 'tags' in json_dump.keys(): start_time = time.time() @@ -631,45 +634,37 @@ class Library: # Step 2: Create a Tag object and append it to the internal Tags list, # then map that Tag's ID to its index in the Tags list. - id = 0 - if 'id' in tag.keys(): - id = tag['id'] + id = int(tag.get('id', 0)) - 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 id >= self._next_tag_id: + self._next_tag_id = 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 = tag.get('name', '') + shorthand = tag.get('shorthand', '') + aliases = tag.get('aliases', []) + subtag_ids = tag.get('subtag_ids', []) + color = tag.get('color', '') - t = Tag( - id=int(id), - name=name, - shorthand=shorthand, - aliases=aliases, - subtags_ids=subtag_ids, - color=color - ) + t = Tag( + id=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: @@ -679,12 +674,11 @@ class Library: logging.info(f'[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds') # Parse Entries ------------------------------------------------ - if 'entries' in json_dump.keys(): + if entries := json_dump.get('entries'): start_time = time.time() - for entry in json_dump['entries']: + for entry in entries: - id = 0 - if 'id' in entry.keys(): + if 'id' in entry: id = int(entry['id']) if id >= self._next_entry_id: self._next_entry_id = id + 1 @@ -693,16 +687,12 @@ class Library: id = self._next_entry_id self._next_entry_id += 1 - filename = '' - if 'filename' in entry.keys(): - filename = entry['filename'] - e_path = '' - if 'path' in entry.keys(): - e_path = entry['path'] + filename = entry.get('filename', '') + e_path = entry.get('path', '') fields = [] - if 'fields' in entry.keys(): + if 'fields' in entry: # Cast JSON str keys to ints - for f in entry['fields']: + for f in fields: f[int(list(f.keys())[0]) ] = f[list(f.keys())[0]] del f[list(f.keys())[0]] @@ -764,28 +754,17 @@ class Library: # the internal Collations list, then map that # Collation's ID to its index in the Collations list. - id = 0 - if 'id' in collation.keys(): - id = collation['id'] + id = int(collation.get('id', 0)) + if id >= self._next_collation_id: + self._next_collation_id = id + 1 - if int(id) >= self._next_collation_id: - self._next_collation_id = int(id) + 1 - - title = '' - if 'title' in collation.keys(): - title = collation['title'] - e_ids_and_pages = '' - if 'e_ids_and_pages' in collation.keys(): - e_ids_and_pages = collation['e_ids_and_pages'] - sort_order = [] - if 'sort_order' in collation.keys(): - sort_order = collation['sort_order'] - cover_id = [] - if 'cover_id' in collation.keys(): - cover_id = collation['cover_id'] + title = collation.get('title', '') + e_ids_and_pages = collation.get('e_ids_and_pages', '') + sort_order = collation.get('sort_order', []) + cover_id = collation.get('cover_id', []) c = Collation( - id=int(id), + id=id, title=title, e_ids_and_pages=e_ids_and_pages, sort_order=sort_order, @@ -852,7 +831,9 @@ class Library: Creates a JSON serialized string from the Library object. Used in saving the library to disk. """ + file_to_save: Json_Libary = {"ts-version": ts_core.VERSION, + "ignored_extensions": [], "tags": [], "collations": [], "fields": [], @@ -861,6 +842,9 @@ class Library: } print('[LIBRARY] Formatting Tags to JSON...') + + file_to_save['ignored_extensions'] = [i for i in self.ignored_extensions if i is not ''] + for tag in self.tags: file_to_save["tags"].append(tag.compressed_dict()) @@ -928,6 +912,7 @@ class Library: self.missing_files.clear() self.fixed_files.clear() self.filename_to_entry_id_map: dict[str, int] = {} + self.ignored_extensions = self.default_ext_blacklist self.tags.clear() self._next_tag_id: int = 1000 @@ -953,7 +938,7 @@ class Library: # p = Path(os.path.normpath(f)) if ('$RECYCLE.BIN' not in f and ts_core.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() in ts_core.ALL_FILE_TYPES: + if os.path.splitext(f)[1][1:].lower() not in self.ignored_extensions: self.dir_file_count += 1 file = str(os.path.relpath(f, self.library_dir)) @@ -1386,20 +1371,20 @@ class Library: query: str = query.strip().lower() query_words: list[str] = query.split(' ') all_tag_terms: list[str] = [] - only_untagged: bool = True if 'untagged' in query or 'no tags' in query else False - only_empty: bool = True if 'empty' in query or 'no fields' in query else False - only_missing: bool = True if 'missing' in query or 'no file' in query else False - allow_adv: bool = True if 'filename:' in query_words else False - tag_only: bool = True if 'tag_id:' in query_words else False + only_untagged: bool = ('untagged' in query or 'no tags' in query) + only_empty: bool = ('empty' in query or 'no fields' in query) + only_missing: bool = ('missing' in query or 'no file' in query) + allow_adv: bool = 'filename:' in query_words + tag_only: bool = 'tag_id:' in query_words if allow_adv: query_words.remove('filename:') if tag_only: query_words.remove('tag_id:') # TODO: Expand this to allow for dynamic fields to work. - only_no_author: bool = True if 'no author' in query or 'no artist' in query else False + only_no_author: bool = ('no author' in query or 'no artist' in query) # Preprocess the Tag terms. - if len(query_words) > 0: + if query_words: for i, term in enumerate(query_words): for j, term in enumerate(query_words): if query_words[i:j+1] and " ".join(query_words[i:j+1]) in self._tag_strings_to_id_map: @@ -1419,101 +1404,103 @@ class Library: # non_entry_count = 0 # Iterate over all Entries ============================================================= for entry in self.entries: + allowed_ext: bool = os.path.splitext(entry.filename)[1][1:].lower() not in self.ignored_extensions # try: # entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]] # print(f'{entry}') - # If the entry has tags of any kind, append them to this main tag list. - entry_tags: list[int] = [] - entry_authors: list[str] = [] - if entry.fields: - for field in entry.fields: - field_id = list(field.keys())[0] - if self.get_field_obj(field_id)['type'] == 'tag_box': - entry_tags.extend(field[field_id]) - if self.get_field_obj(field_id)['name'] == 'Author': - entry_authors.extend(field[field_id]) - if self.get_field_obj(field_id)['name'] == 'Artist': - entry_authors.extend(field[field_id]) + if allowed_ext: + # If the entry has tags of any kind, append them to this main tag list. + entry_tags: list[int] = [] + entry_authors: list[str] = [] + if entry.fields: + for field in entry.fields: + field_id = list(field.keys())[0] + if self.get_field_obj(field_id)['type'] == 'tag_box': + entry_tags.extend(field[field_id]) + if self.get_field_obj(field_id)['name'] == 'Author': + entry_authors.extend(field[field_id]) + if self.get_field_obj(field_id)['name'] == 'Artist': + entry_authors.extend(field[field_id]) - # print(f'Entry Tags: {entry_tags}') + # print(f'Entry Tags: {entry_tags}') - # Add Entries from special flags ------------------------------- - # TODO: Come up with a more user-resistent way to 'archived' and 'favorite' tags. - if only_untagged: - if not entry_tags: - results.append((ItemType.ENTRY, entry.id)) - elif only_no_author: - if not entry_authors: - results.append((ItemType.ENTRY, entry.id)) - elif only_empty: - if not entry.fields: - results.append((ItemType.ENTRY, entry.id)) - elif only_missing: - if os.path.normpath(f'{self.library_dir}/{entry.path}/{entry.filename}') in self.missing_files: - results.append((ItemType.ENTRY, entry.id)) - - # elif query == "archived": - # if entry.tags and self._tag_names_to_tag_id_map[self.archived_word.lower()][0] in entry.tags: - # self.filtered_file_list.append(file) - # pb.value = len(self.filtered_file_list) - # elif query in entry.path.lower(): - - # NOTE: This searches path and filenames. - if allow_adv: - if [q for q in query_words if (q in entry.path.lower())]: - results.append((ItemType.ENTRY, entry.id)) - elif [q for q in query_words if (q in entry.filename.lower())]: - results.append((ItemType.ENTRY, entry.id)) - elif tag_only: - if entry.has_tag(self, int(query_words[0])): - results.append((ItemType.ENTRY, entry.id)) - - # elif query in entry.filename.lower(): - # self.filtered_entries.append(index) - elif entry_tags: - # For each verified, extracted Tag term. - failure_to_union_terms = False - for term in all_tag_terms: - # If the term from the previous loop was already verified: - if not failure_to_union_terms: - cluster: set = set() - # Add the immediate associated Tags to the set (ex. Name, Alias hits) - # Since this term could technically map to multiple IDs, iterate over it - # (You're 99.9999999% likely to just get 1 item) - for id in self._tag_strings_to_id_map[term]: - cluster.add(id) - cluster = cluster.union( - set(self.get_tag_cluster(id))) - # print(f'Full Cluster: {cluster}') - # For each of the Tag IDs in the term's ID cluster: - for t in cluster: - # Assume that this ID from the cluster is not in the Entry. - # Wait to see if proven wrong. - failure_to_union_terms = True - # If the ID actually is in the Entry, - if t in entry_tags: - # There wasn't a failure to find one of the term's cluster IDs in the Entry. - # There is also no more need to keep checking the rest of the terms in the cluster. - failure_to_union_terms = False - # print(f'FOUND MATCH: {t}') - break - # print(f'\tFailure to Match: {t}') - # If there even were tag terms to search through AND they all match an entry - if all_tag_terms and not failure_to_union_terms: - # self.filter_entries.append() - # self.filtered_file_list.append(file) - # results.append((SearchItemType.ENTRY, entry.id)) - added = False - for f in entry.fields: - if self.get_field_attr(f, 'type') == 'collation': - if (self.get_field_attr(f, 'content') not in collations_added): - results.append((ItemType.COLLATION, self.get_field_attr(f, 'content'))) - collations_added.append(self.get_field_attr(f, 'content')) - added = True - - if not added: + # Add Entries from special flags ------------------------------- + # TODO: Come up with a more user-resistent way to 'archived' and 'favorite' tags. + if only_untagged: + if not entry_tags: results.append((ItemType.ENTRY, entry.id)) + elif only_no_author: + if not entry_authors: + results.append((ItemType.ENTRY, entry.id)) + elif only_empty: + if not entry.fields: + results.append((ItemType.ENTRY, entry.id)) + elif only_missing: + if os.path.normpath(f'{self.library_dir}/{entry.path}/{entry.filename}') in self.missing_files: + results.append((ItemType.ENTRY, entry.id)) + + # elif query == "archived": + # if entry.tags and self._tag_names_to_tag_id_map[self.archived_word.lower()][0] in entry.tags: + # self.filtered_file_list.append(file) + # pb.value = len(self.filtered_file_list) + # elif query in entry.path.lower(): + + # NOTE: This searches path and filenames. + if allow_adv: + if [q for q in query_words if (q in entry.path.lower())]: + results.append((ItemType.ENTRY, entry.id)) + elif [q for q in query_words if (q in entry.filename.lower())]: + results.append((ItemType.ENTRY, entry.id)) + elif tag_only: + if entry.has_tag(self, int(query_words[0])): + results.append((ItemType.ENTRY, entry.id)) + + # elif query in entry.filename.lower(): + # self.filtered_entries.append(index) + elif entry_tags: + # For each verified, extracted Tag term. + failure_to_union_terms = False + for term in all_tag_terms: + # If the term from the previous loop was already verified: + if not failure_to_union_terms: + cluster: set = set() + # Add the immediate associated Tags to the set (ex. Name, Alias hits) + # Since this term could technically map to multiple IDs, iterate over it + # (You're 99.9999999% likely to just get 1 item) + for id in self._tag_strings_to_id_map[term]: + cluster.add(id) + cluster = cluster.union( + set(self.get_tag_cluster(id))) + # print(f'Full Cluster: {cluster}') + # For each of the Tag IDs in the term's ID cluster: + for t in cluster: + # Assume that this ID from the cluster is not in the Entry. + # Wait to see if proven wrong. + failure_to_union_terms = True + # If the ID actually is in the Entry, + if t in entry_tags: + # There wasn't a failure to find one of the term's cluster IDs in the Entry. + # There is also no more need to keep checking the rest of the terms in the cluster. + failure_to_union_terms = False + # print(f'FOUND MATCH: {t}') + break + # print(f'\tFailure to Match: {t}') + # If there even were tag terms to search through AND they all match an entry + if all_tag_terms and not failure_to_union_terms: + # self.filter_entries.append() + # self.filtered_file_list.append(file) + # results.append((SearchItemType.ENTRY, entry.id)) + added = False + for f in entry.fields: + if self.get_field_attr(f, 'type') == 'collation': + if (self.get_field_attr(f, 'content') not in collations_added): + results.append((ItemType.COLLATION, self.get_field_attr(f, 'content'))) + collations_added.append(self.get_field_attr(f, 'content')) + added = True + + if not added: + results.append((ItemType.ENTRY, entry.id)) # sys.stdout.write( # f'\r[INFO][FILTER]: {len(self.filtered_file_list)} matches found') @@ -1539,21 +1526,23 @@ class Library: for entry in self.entries: added = False - for f in entry.fields: - if self.get_field_attr(f, 'type') == 'collation': - if (self.get_field_attr(f, 'content') not in collations_added): - results.append((ItemType.COLLATION, self.get_field_attr(f, 'content'))) - collations_added.append(self.get_field_attr(f, 'content')) - added = True + allowed_ext: bool = os.path.splitext(entry.filename)[1][1:].lower() not in self.ignored_extensions + if allowed_ext: + for f in entry.fields: + if self.get_field_attr(f, 'type') == 'collation': + if (self.get_field_attr(f, 'content') not in collations_added): + results.append((ItemType.COLLATION, self.get_field_attr(f, 'content'))) + collations_added.append(self.get_field_attr(f, 'content')) + added = True - if not added: - results.append((ItemType.ENTRY, entry.id)) + if not added: + results.append((ItemType.ENTRY, entry.id)) # for file in self._source_filenames: # self.filtered_file_list.append(file) results.reverse() return results - def search_tags(self, query: str, include_cluster=False, ignore_builtin=False, threshold: int = 1, context: list[str] = []) -> list[int]: + def search_tags(self, query: str, include_cluster=False, ignore_builtin=False, threshold: int = 1, context: list[str] = None) -> list[int]: """Returns a list of Tag IDs returned from a string query.""" # tag_ids: list[int] = [] # if query: @@ -1646,7 +1635,6 @@ class Library: # Contextual Weighing if context and ((len(id_weights) > 1 and len(priority_ids) > 1) or (len(priority_ids) > 1)): - context_ids: list[int] = [] context_strings: list[str] = [s.replace(' ', '').replace('_', '').replace('-', '').replace( "'", '').replace('(', '').replace(')', '').replace('[', '').replace(']', '').lower() for s in context] for term in context: @@ -1820,16 +1808,16 @@ class Library: # Step [3/7]: # Remove ID -> cluster reference. - if tag_id in self._tag_id_to_cluster_map.keys(): + if tag_id in self._tag_id_to_cluster_map: del self._tag_id_to_cluster_map[tag.id] # Remove mentions of this ID in all clusters. - for key in self._tag_id_to_cluster_map.keys(): - if tag_id in self._tag_id_to_cluster_map[key]: - self._tag_id_to_cluster_map[key].remove(tag.id) + for key, values in self._tag_id_to_cluster_map.items(): + if tag_id in values: + values.remove(tag.id) # Step [4/7]: # Remove mapping of this ID to its index in the tags list. - if tag.id in self._tag_id_to_index_map.keys(): + if tag.id in self._tag_id_to_index_map: del self._tag_id_to_index_map[tag.id] # Step [5/7]: @@ -1908,7 +1896,7 @@ class Library: if data: # Add a Title Field if the data doesn't already exist. - if "title" in data.keys() and data["title"]: + if data.get("title"): field_id = 0 # Title Field ID if not self.does_field_content_exist(entry_id, field_id, data['title']): self.add_field_to_entry(entry_id, field_id) @@ -1916,7 +1904,7 @@ class Library: entry_id, -1, data["title"], 'replace') # Add an Author Field if the data doesn't already exist. - if "author" in data.keys() and data["author"]: + if data.get("author"): field_id = 1 # Author Field ID if not self.does_field_content_exist(entry_id, field_id, data['author']): self.add_field_to_entry(entry_id, field_id) @@ -1924,7 +1912,7 @@ class Library: entry_id, -1, data["author"], 'replace') # Add an Artist Field if the data doesn't already exist. - if "artist" in data.keys() and data["artist"]: + if data.get("artist"): field_id = 2 # Artist Field ID if not self.does_field_content_exist(entry_id, field_id, data['artist']): self.add_field_to_entry(entry_id, field_id) @@ -1932,7 +1920,7 @@ class Library: entry_id, -1, data["artist"], 'replace') # Add a Date Published Field if the data doesn't already exist. - if "date_published" in data.keys() and data["date_published"]: + if data.get("date_published"): field_id = 14 # Date Published Field ID date = str(datetime.datetime.strptime( data["date_published"], '%Y-%m-%d %H:%M:%S')) @@ -1942,7 +1930,7 @@ class Library: self.update_entry_field(entry_id, -1, date, 'replace') # Process String Tags if the data doesn't already exist. - if "tags" in data.keys() and data["tags"]: + if data.get("tags"): tags_field_id = 6 # Tags Field ID content_tags_field_id = 7 # Content Tags Field ID meta_tags_field_id = 8 # Meta Tags Field ID @@ -1979,7 +1967,7 @@ class Library: matching: list[int] = self.search_tags( tag.replace('_', ' ').replace('-', ' '), include_cluster=False, ignore_builtin=True, threshold=2, context=tags) priority_field_index = -1 - if len(matching) > 0: + if matching: # NOTE: The following commented-out code enables the ability # to prefer an existing built-in tag_box field to add to diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index 1ef7f028..0f5457f0 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -4,18 +4,12 @@ """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 -VERSION: str = '9.1.0' # Major.Minor.Patch +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. @@ -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'] @@ -143,12 +137,11 @@ class TagStudioCore: # # # print("Could not resolve URL.") # # pass - def match_conditions(self, entry_id: int) -> str: + def match_conditions(self, entry_id: int) -> None: """Matches defined conditions against a file to add Entry data.""" cond_file = os.path.normpath(f'{self.lib.library_dir}/{TS_FOLDER_NAME}/conditions.json') # TODO: Make this stored somewhere better instead of temporarily in this JSON file. - json_dump = {} entry: Entry = self.lib.get_entry(entry_id) try: if os.path.isfile(cond_file): @@ -161,8 +154,8 @@ class TagStudioCore: match = True break if match: - if 'fields' in c.keys() and c['fields']: - for field in c['fields']: + if fields := c.get('fields'): + for field in fields: field_id = self.lib.get_field_attr( field, 'id') 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/resources_rc.py b/tagstudio/src/qt/resources_rc.py index 0a3759ac..aca38cd1 100644 --- a/tagstudio/src/qt/resources_rc.py +++ b/tagstudio/src/qt/resources_rc.py @@ -1,6 +1,6 @@ # Resource object code (Python 3) # Created by: object code -# Created by: The Resource Compiler for Qt version 6.5.1 +# Created by: The Resource Compiler for Qt version 6.6.3 # WARNING! All changes made in this file will be lost! from PySide6 import QtCore diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 45ebd0d8..e49c617c 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, QSettings +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, QTableWidget, QTableWidgetItem) +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": @@ -55,12 +61,26 @@ INFO = f'[INFO]' logging.basicConfig(format="%(message)s", level=logging.INFO) +# Keep settings in ini format in the current working directory. +QSettings.setPath(QSettings.IniFormat, QSettings.UserScope, os.getcwd()) -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": + # Windows needs special attention to handle spaces in the file + # first parameter is for title, NOT filepath + subprocess.Popen(["start", "", os.path.normpath(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() @@ -284,15 +304,17 @@ class FieldContainer(QWidget): class FieldWidget(QWidget): + field = dict def __init__(self, title) -> None: super().__init__() # self.item = item self.title = title + class TagBoxWidget(FieldWidget): updated = Signal() - + def __init__(self, item, title, field_index, library:Library, tags:list[int], driver:'QtDriver') -> None: super().__init__(title) # QObject.__init__(self) @@ -360,7 +382,7 @@ class TagBoxWidget(FieldWidget): # ) tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True) tw.on_click.connect(lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q))) - tw.on_remove.connect(lambda checked=False, t=tag: (self.lib.get_entry(self.item.id).remove_tag(self.lib, t, self.field_index), self.updated.emit())) + tw.on_remove.connect(lambda checked=False, t=tag: (self.remove_tag(t))) tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t))) self.base_layout.addWidget(tw) self.tags = tags @@ -375,7 +397,8 @@ class TagBoxWidget(FieldWidget): # doesn't move all the way to the left. if self.base_layout.itemAt(0) and not self.base_layout.itemAt(1): self.base_layout.update() - + + def edit_tag(self, tag_id:int): btp = BuildTagPanel(self.lib, tag_id) # btp.on_edit.connect(lambda x: self.edit_tag_callback(x)) @@ -395,28 +418,43 @@ class TagBoxWidget(FieldWidget): # self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True)) # self.tags.append(tag) logging.info(f'[TAG BOX WIDGET] ADD TAG CALLBACK: T:{tag_id} to E:{self.item.id}') - if type(self.item) == Entry: - self.item.add_tag(self.lib, tag_id, field_id=-1, field_index=self.field_index) - logging.info(f'[TAG BOX WIDGET] UPDATED EMITTED: {tag_id}') - self.updated.emit() + logging.info(f'[TAG BOX WIDGET] SELECTED T:{self.driver.selected}') + id = list(self.field.keys())[0] + for x in self.driver.selected: + self.driver.lib.get_entry(x[1]).add_tag(self.driver.lib, tag_id, field_id=id, field_index=-1) + self.updated.emit() + if tag_id == 0 or tag_id == 1: + self.driver.update_badges() + + # if type((x[0]) == ThumbButton): + # # TODO: Remove space from the special search here (tag_id:x) once that system is finalized. # logging.info(f'I want to add tag ID {tag_id} to entry {self.item.filename}') - # self.updated.emit() + # self.updated.emit() # if tag_id not in self.tags: # self.tags.append(tag_id) # self.set_tags(self.tags) + # elif type((x[0]) == ThumbButton): + def edit_tag_callback(self, tag:Tag): self.lib.update_tag(tag) - + + def remove_tag(self, tag_id): + logging.info(f'[TAG BOX WIDGET] SELECTED T:{self.driver.selected}') + id = list(self.field.keys())[0] + for x in self.driver.selected: + index = self.driver.lib.get_field_index_in_entry(self.driver.lib.get_entry(x[1]),id) + self.driver.lib.get_entry(x[1]).remove_tag(self.driver.lib, tag_id,field_index=index[0]) + self.updated.emit() + if tag_id == 0 or tag_id == 1: + self.driver.update_badges() - def remove_tag(self): - # NOTE: You'll need to account for the add button at the end. - pass # def show_add_button(self, value:bool): # self.add_button.setHidden(not value) class TextWidget(FieldWidget): + def __init__(self, title, text:str) -> None: super().__init__(title) # self.item = item @@ -1111,6 +1149,131 @@ class BuildTagPanel(PanelWidget): # self.search_field.setFocus() # self.parentWidget().hide() +class TagDatabasePanel(PanelWidget): + tag_chosen = Signal(int) + def __init__(self, library): + super().__init__() + self.lib: Library = library + # self.callback = callback + self.first_tag_id = -1 + self.tag_limit = 30 + # self.selected_tag: int = 0 + + self.setMinimumSize(300, 400) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6,0,6,0) + + self.search_field = QLineEdit() + self.search_field.setObjectName('searchField') + self.search_field.setMinimumSize(QSize(0, 32)) + self.search_field.setPlaceholderText('Search Tags') + self.search_field.textEdited.connect(lambda x=self.search_field.text(): self.update_tags(x)) + self.search_field.returnPressed.connect(lambda checked=False: self.on_return(self.search_field.text())) + + # self.content_container = QWidget() + # self.content_layout = QHBoxLayout(self.content_container) + + self.scroll_contents = QWidget() + self.scroll_layout = QVBoxLayout(self.scroll_contents) + self.scroll_layout.setContentsMargins(6,0,6,0) + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.scroll_area = QScrollArea() + # self.scroll_area.setStyleSheet('background: #000000;') + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + # self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + # sa.setMaximumWidth(self.preview_size[0]) + self.scroll_area.setWidget(self.scroll_contents) + + # self.add_button = QPushButton() + # self.root_layout.addWidget(self.add_button) + # self.add_button.setText('Add Tag') + # # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide())) + # self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x)) + # # self.setLayout(self.root_layout) + + self.root_layout.addWidget(self.search_field) + self.root_layout.addWidget(self.scroll_area) + self.update_tags('') + + # def reset(self): + # self.search_field.setText('') + # self.update_tags('') + # self.search_field.setFocus() + + def on_return(self, text:str): + if text and self.first_tag_id >= 0: + # callback(self.first_tag_id) + self.search_field.setText('') + self.update_tags('') + else: + self.search_field.setFocus() + self.parentWidget().hide() + + def update_tags(self, query:str): + # for c in self.scroll_layout.children(): + # c.widget().deleteLater() + while self.scroll_layout.itemAt(0): + # logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}") + self.scroll_layout.takeAt(0).widget().deleteLater() + + if query: + first_id_set = False + for tag_id in self.lib.search_tags(query, include_cluster=True)[:self.tag_limit-1]: + if not first_id_set: + self.first_tag_id = tag_id + first_id_set = True + c = QWidget() + l = QHBoxLayout(c) + l.setContentsMargins(0,0,0,0) + l.setSpacing(3) + tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False) + tw.on_edit.connect(lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))) + l.addWidget(tw) + self.scroll_layout.addWidget(c) + else: + first_id_set = False + for tag in self.lib.tags: + if not first_id_set: + self.first_tag_id = tag.id + first_id_set = True + c = QWidget() + l = QHBoxLayout(c) + l.setContentsMargins(0,0,0,0) + l.setSpacing(3) + tw = TagWidget(self.lib, tag, True, False) + tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id))) + l.addWidget(tw) + self.scroll_layout.addWidget(c) + + self.search_field.setFocus() + + def edit_tag(self, tag_id:int): + btp = BuildTagPanel(self.lib, tag_id) + # btp.on_edit.connect(lambda x: self.edit_tag_callback(x)) + self.edit_modal = PanelModal(btp, + self.lib.get_tag(tag_id).display_name(self.lib), + 'Edit Tag', + done_callback=(self.update_tags(self.search_field.text())), + has_save=True) + # self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t)) + panel: BuildTagPanel = self.edit_modal.widget + self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp)) + # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) + self.edit_modal.show() + + def edit_tag_callback(self, btp:BuildTagPanel): + self.lib.update_tag(btp.build_tag()) + self.update_tags(self.search_field.text()) + + # def enterEvent(self, event: QEnterEvent) -> None: + # self.search_field.setFocus() + # return super().enterEvent(event) + # self.focusOutEvent + class FunctionIterator(QObject): """Iterates over a yielding function and emits progress as the 'value' signal.\n\nThread-Safe Guarantee™""" @@ -1258,10 +1421,9 @@ class FixDupeFilesModal(QWidget): os.path.normpath(self.lib.library_dir)) qfd.setFileMode(QFileDialog.FileMode.ExistingFile) qfd.setNameFilter("DupeGuru Files (*.dupeguru)") - filename = [] if qfd.exec_(): filename = qfd.selectedFiles() - if len(filename) > 0: + if filename: self.set_filename(filename[0]) def set_filename(self, filename:str): @@ -1806,6 +1968,86 @@ class AddFieldModal(QWidget): self.root_layout.addStretch(1) self.root_layout.addWidget(self.button_container) +class FileExtensionModal(PanelWidget): + done = Signal() + def __init__(self, library:'Library'): + super().__init__() + self.lib = library + self.setWindowTitle(f'File Extensions') + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(200, 400) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6,6,6,6) + + self.table = QTableWidget(len(self.lib.ignored_extensions), 1) + self.table.horizontalHeader().setVisible(False) + self.table.verticalHeader().setVisible(False) + self.table.horizontalHeader().setStretchLastSection(True) + + self.add_button = QPushButton() + self.add_button.setText('&Add Extension') + self.add_button.clicked.connect(self.add_item) + self.add_button.setDefault(True) + self.add_button.setMinimumWidth(100) + + self.root_layout.addWidget(self.table) + self.root_layout.addWidget(self.add_button, alignment=Qt.AlignmentFlag.AlignCenter) + self.refresh_list() + + def refresh_list(self): + for i, ext in enumerate(self.lib.ignored_extensions): + self.table.setItem(i, 0, QTableWidgetItem(ext)) + + def add_item(self): + self.table.insertRow(self.table.rowCount()) + + def save(self): + self.lib.ignored_extensions.clear() + for i in range(self.table.rowCount()): + ext = self.table.item(i, 0) + if ext and ext.text(): + self.lib.ignored_extensions.append(ext.text()) + +class FileOpenerHelper(): + def __init__(self, filepath:str): + self.filepath = filepath + + def set_filepath(self, filepath:str): + self.filepath = filepath + + def open_file(self): + if os.path.exists(self.filepath): + os.startfile(self.filepath) + logging.info(f'Opening file: {self.filepath}') + else: + logging.error(f'File not found: {self.filepath}') + + def open_explorer(self): + if os.path.exists(self.filepath): + logging.info(f'Opening file: {self.filepath}') + if os.name == 'nt': # Windows + command = f'explorer /select,"{self.filepath}"' + subprocess.run(command, shell=True) + else: # macOS and Linux + command = f'nautilus --select "{self.filepath}"' # Adjust for your Linux file manager if different + if subprocess.run(command, shell=True).returncode == 0: + file_loc = os.path.dirname(self.filepath) + file_loc = os.path.normpath(file_loc) + os.startfile(file_loc) + else: + logging.error(f'File not found: {self.filepath}') +class FileOpenerLabel(QLabel): + def __init__(self, text, parent=None): + super().__init__(text, parent) + + def setFilePath(self, filepath): + self.filepath = filepath + + def mousePressEvent(self, event): + super().mousePressEvent(event) + opener = FileOpenerHelper(self.filepath) + opener.open_explorer() + class PreviewPanel(QWidget): """The Preview Panel Widget.""" tags_updated = Signal() @@ -1841,6 +2083,14 @@ class PreviewPanel(QWidget): self.preview_img = QPushButton() self.preview_img.setMinimumSize(*self.img_button_size) self.preview_img.setFlat(True) + + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.opener = FileOpenerHelper('') + self.open_file_action = QAction('Open file', self) + self.open_explorer_action = QAction('Open file in explorer', self) + + self.preview_img.addAction(self.open_file_action) + self.preview_img.addAction(self.open_explorer_action) self.tr = ThumbRenderer() self.tr.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i))) self.tr.updated_ratio.connect(lambda ratio: (self.set_image_ratio(ratio), @@ -1852,7 +2102,7 @@ class PreviewPanel(QWidget): image_layout.addWidget(self.preview_img) image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) - self.file_label = QLabel('Filename') + self.file_label = FileOpenerLabel('Filename') self.file_label.setWordWrap(True) self.file_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse) @@ -2059,7 +2309,9 @@ class PreviewPanel(QWidget): if len(self.driver.selected) == 0: if len(self.selected) != 0 or not self.initialized: self.file_label.setText(f"No Items Selected") + self.file_label.setFilePath('') self.dimensions_label.setText("") + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) ratio: float = self.devicePixelRatio() self.tr.render_big(time.time(), '', (512, 512), ratio, True) try: @@ -2082,11 +2334,17 @@ class PreviewPanel(QWidget): if (len(self.selected) == 0 or self.selected != self.driver.selected): filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}') + self.file_label.setFilePath(filepath) window_title = filepath ratio: float = self.devicePixelRatio() self.tr.render_big(time.time(), filepath, (512, 512), ratio) self.file_label.setText("\u200b".join(filepath)) + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.opener = FileOpenerHelper(filepath) + self.open_file_action.triggered.connect(self.opener.open_file) + self.open_explorer_action.triggered.connect(self.opener.open_explorer) + # TODO: Do this somewhere else, this is just here temporarily. extension = os.path.splitext(filepath)[1][1:].lower() try: @@ -2115,12 +2373,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): @@ -2159,7 +2417,9 @@ class PreviewPanel(QWidget): elif len(self.driver.selected) > 1: if self.selected != self.driver.selected: self.file_label.setText(f"{len(self.driver.selected)} Items Selected") + self.file_label.setFilePath('') self.dimensions_label.setText("") + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) ratio: float = self.devicePixelRatio() self.tr.render_big(time.time(), '', (512, 512), ratio, True) try: @@ -2311,7 +2571,6 @@ class PreviewPanel(QWidget): container = self.containers[index] # container.inner_layout.removeItem(container.inner_layout.itemAt(1)) # container.setHidden(False) - if self.lib.get_field_attr(field, 'type') == 'tag_box': # logging.info(f'WRITING TAGBOX FOR ITEM {item.id}') container.set_title(self.lib.get_field_attr(field, 'name')) @@ -2333,21 +2592,19 @@ class PreviewPanel(QWidget): inner_container = TagBoxWidget(item, title, index, self.lib, self.lib.get_field_attr(field, 'content'), self.driver) container.set_inner_widget(inner_container) - + inner_container.field = field inner_container.updated.connect(lambda: (self.write_container(index, field), self.tags_updated.emit())) # if type(item) == Entry: # NOTE: Tag Boxes have no Edit Button (But will when you can convert field types) # f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' - callback = lambda: (self.remove_field(item.fields[index]), self.update_widgets()) + callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback(lambda: self.remove_message_box( prompt=prompt, callback=callback)) container.set_copy_callback(None) container.set_edit_callback(None) - # logging.info(self.common_fields) - # logging.info(f'index:{index}') else: text = 'Mixed Data' title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Tag Box)" @@ -2377,15 +2634,14 @@ class PreviewPanel(QWidget): container.set_inner_widget(inner_container) # if type(item) == Entry: if not mixed: - item = self.lib.get_entry(self.selected[0][1]) # TODO TODO TODO: TEMPORARY modal = PanelModal(EditTextLine(self.lib.get_field_attr(field, 'content')), title=title, window_title=f'Edit {self.lib.get_field_attr(field, "name")}', - save_callback=(lambda content: (self.update_field(item.fields[index], content), self.update_widgets())) + save_callback=(lambda content: (self.update_field(field, content), self.update_widgets())) ) container.set_edit_callback(modal.show) prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' - callback = lambda: (self.remove_field(item.fields[index]), self.update_widgets()) + callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback(lambda: self.remove_message_box( prompt=prompt, callback=callback)) @@ -2413,17 +2669,15 @@ class PreviewPanel(QWidget): container.set_inner_widget(inner_container) # if type(item) == Entry: if not mixed: - item = self.lib.get_entry(self.selected[0][1]) # TODO TODO TODO: TEMPORARY container.set_copy_callback(None) modal = PanelModal(EditTextBox(self.lib.get_field_attr(field, 'content')), title=title, window_title=f'Edit {self.lib.get_field_attr(field, "name")}', - save_callback=(lambda content: (self.update_field(item.fields[index], content), self.update_widgets())) + save_callback=(lambda content: (self.update_field(field, content), self.update_widgets())) ) container.set_edit_callback(modal.show) - # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' - callback = lambda: (self.remove_field(item.fields[index]), self.update_widgets()) + callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback(lambda: self.remove_message_box( prompt=prompt, callback=callback)) @@ -2448,7 +2702,7 @@ class PreviewPanel(QWidget): # container.set_edit_callback(None) # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' - callback = lambda: (self.remove_field(item.fields[index]), self.update_widgets()) + callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback(lambda: self.remove_message_box( prompt=prompt, callback=callback)) @@ -2476,7 +2730,7 @@ class PreviewPanel(QWidget): container.set_edit_callback(None) # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' - callback = lambda: (self.remove_field(item.fields[index]), self.update_widgets()) + callback = lambda: (self.remove_field(field), self.update_widgets()) container.set_remove_callback(lambda: self.remove_message_box( prompt=prompt, callback=callback)) @@ -2501,7 +2755,7 @@ class PreviewPanel(QWidget): container.set_edit_callback(None) # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?' - callback = lambda: (self.remove_field(item.fields[index]), self.update_widgets()) + callback = lambda: (self.remove_field(field), self.update_widgets()) # callback = lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets()) container.set_remove_callback(lambda: self.remove_message_box( prompt=prompt, @@ -2516,8 +2770,13 @@ class PreviewPanel(QWidget): entry = self.lib.get_entry(item_pair[1]) try: index = entry.fields.index(field) + updated_badges = False + if 8 in entry.fields[index].keys() and (1 in entry.fields[index][8] or 0 in entry.fields[index][8]): + updated_badges = True # TODO: Create a proper Library/Entry method to manage fields. entry.fields.pop(index) + if updated_badges: + self.driver.update_badges() except ValueError: logging.info(f'[PREVIEW PANEL][ERROR?] Tried to remove field from Entry ({entry.id}) that never had it') pass @@ -2555,7 +2814,6 @@ class ItemThumb(FlowWidget): """ The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.). """ - update_cutoff: float = time.time() collation_icon_128: Image.Image = Image.open(os.path.normpath( @@ -2684,6 +2942,15 @@ class ItemThumb(FlowWidget): # self.bg_button.setMinimumSize(*thumb_size) # self.bg_button.setMaximumSize(*thumb_size) + self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.opener = FileOpenerHelper('') + open_file_action = QAction('Open file', self) + open_file_action.triggered.connect(self.opener.open_file) + open_explorer_action = QAction('Open file in explorer', self) + open_explorer_action.triggered.connect(self.opener.open_explorer) + self.thumb_button.addAction(open_file_action) + self.thumb_button.addAction(open_explorer_action) + # Static Badges ======================================================== # Item Type Badge ------------------------------------------------------ @@ -2878,7 +3145,15 @@ class ItemThumb(FlowWidget): def set_item_id(self, id: int): + ''' + also sets the filepath for the file opener + ''' self.item_id = id + if(id == -1): + return + entry = self.lib.get_entry(self.item_id) + filepath = os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}') + self.opener.set_filepath(filepath) def assign_favorite(self, value: bool): # Switching mode to None to bypass mode-specific operations when the @@ -2921,15 +3196,29 @@ class ItemThumb(FlowWidget): # logging.info(f'Archived Check: {value}, Mode: {self.mode}') if self.mode == ItemType.ENTRY: self.isArchived = value - e = self.lib.get_entry(self.item_id) - if value: - self.archived_badge.setHidden(False) - DEFAULT_META_TAG_FIELD = 8 - e.add_tag(self.lib, 0, DEFAULT_META_TAG_FIELD) + DEFAULT_META_TAG_FIELD = 8 + temp = (ItemType.ENTRY,self.item_id) + if list(self.panel.driver.selected).count(temp) > 0: # Is the archived badge apart of the selection? + # Yes, then add archived tag to all selected. + for x in self.panel.driver.selected: + e = self.lib.get_entry(x[1]) + if value: + self.archived_badge.setHidden(False) + e.add_tag(self.panel.driver.lib, 0, field_id=DEFAULT_META_TAG_FIELD, field_index=-1) + else: + e.remove_tag(self.panel.driver.lib, 0) else: - e.remove_tag(self.lib, 0) + # No, then add archived tag to the entry this badge is on. + e = self.lib.get_entry(self.item_id) + if value: + self.favorite_badge.setHidden(False) + e.add_tag(self.panel.driver.lib, 0, field_id=DEFAULT_META_TAG_FIELD, field_index=-1) + else: + e.remove_tag(self.panel.driver.lib, 0) if self.panel.isOpen: self.panel.update_widgets() + self.panel.driver.update_badges() + # def on_archived_uncheck(self): # if self.mode == SearchItemType.ENTRY: @@ -2940,15 +3229,29 @@ class ItemThumb(FlowWidget): # logging.info(f'Favorite Check: {value}, Mode: {self.mode}') if self.mode == ItemType.ENTRY: self.isFavorite = value - e = self.lib.get_entry(self.item_id) - if value: - self.favorite_badge.setHidden(False) - DEFAULT_META_TAG_FIELD = 8 - e.add_tag(self.lib, 1, DEFAULT_META_TAG_FIELD) + DEFAULT_META_TAG_FIELD = 8 + temp = (ItemType.ENTRY,self.item_id) + if list(self.panel.driver.selected).count(temp) > 0: # Is the favorite badge apart of the selection? + # Yes, then add favorite tag to all selected. + for x in self.panel.driver.selected: + e = self.lib.get_entry(x[1]) + if value: + self.favorite_badge.setHidden(False) + e.add_tag(self.panel.driver.lib, 1, field_id=DEFAULT_META_TAG_FIELD, field_index=-1) + else: + e.remove_tag(self.panel.driver.lib, 1) else: - e.remove_tag(self.lib, 1) + # No, then add favorite tag to the entry this badge is on. + e = self.lib.get_entry(self.item_id) + if value: + self.favorite_badge.setHidden(False) + e.add_tag(self.panel.driver.lib, 1, field_id=DEFAULT_META_TAG_FIELD, field_index=-1) + else: + e.remove_tag(self.panel.driver.lib, 1) if self.panel.isOpen: self.panel.update_widgets() + self.panel.driver.update_badges() + # def on_favorite_uncheck(self): # if self.mode == SearchItemType.ENTRY: @@ -3558,6 +3861,8 @@ class QtDriver(QObject): self.SIGTERM.connect(self.handleSIGTERM) + self.settings = QSettings(QSettings.IniFormat, QSettings.UserScope, 'tagstudio', 'TagStudio') + max_threads = os.cpu_count() for i in range(max_threads): @@ -3573,7 +3878,7 @@ class QtDriver(QObject): 'Open/Create Library', '/', QFileDialog.ShowDirsOnly) - if dir != None and dir != '': + if dir not in (None, ''): self.open_library(dir) def signal_handler(self, sig, frame): @@ -3647,14 +3952,20 @@ class QtDriver(QObject): open_library_action = QAction('&Open/Create Library', menu_bar) open_library_action.triggered.connect(lambda: self.open_library_from_dialog()) + open_library_action.setShortcut(QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_O)) + open_library_action.setToolTip("Ctrl+O") file_menu.addAction(open_library_action) save_library_action = QAction('&Save Library', menu_bar) save_library_action.triggered.connect(lambda: self.callback_library_needed_check(self.save_library)) + save_library_action.setShortcut(QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_S)) + save_library_action.setStatusTip("Ctrl+S") file_menu.addAction(save_library_action) - save_library_backup_action = QAction('Save Library &Backup', menu_bar) + save_library_backup_action = QAction('&Save Library Backup', menu_bar) save_library_backup_action.triggered.connect(lambda: self.callback_library_needed_check(self.backup_library)) + save_library_backup_action.setShortcut(QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier), QtCore.Qt.Key.Key_S)) + save_library_backup_action.setStatusTip("Ctrl+Shift+S") file_menu.addAction(save_library_backup_action) file_menu.addSeparator() @@ -3663,6 +3974,8 @@ class QtDriver(QObject): # refresh_lib_action.triggered.connect(lambda: self.lib.refresh_dir()) add_new_files_action = QAction('&Refresh Directories', menu_bar) add_new_files_action.triggered.connect(lambda: self.callback_library_needed_check(self.add_new_files_callback)) + add_new_files_action.setShortcut(QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_R)) + add_new_files_action.setStatusTip("Ctrl+R") # file_menu.addAction(refresh_lib_action) file_menu.addAction(add_new_files_action) @@ -3671,10 +3984,22 @@ class QtDriver(QObject): file_menu.addAction(QAction('&Close Library', menu_bar)) # Edit Menu ============================================================ - new_tag_action = QAction('New Tag', menu_bar) + new_tag_action = QAction('New &Tag', menu_bar) new_tag_action.triggered.connect(lambda: self.add_tag_action_callback()) + new_tag_action.setShortcut(QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier), QtCore.Qt.Key.Key_T)) + new_tag_action.setToolTip('Ctrl+T') edit_menu.addAction(new_tag_action) + edit_menu.addSeparator() + + manage_file_extensions_action = QAction('Ignore File Extensions', menu_bar) + manage_file_extensions_action.triggered.connect(lambda: self.show_file_extension_modal()) + edit_menu.addAction(manage_file_extensions_action) + + tag_database_action = QAction('Tag Database', menu_bar) + tag_database_action.triggered.connect(lambda: self.show_tag_database()) + edit_menu.addAction(tag_database_action) + # Tools Menu =========================================================== fix_unlinked_entries_action = QAction('Fix &Unlinked Entries', menu_bar) fue_modal = FixUnlinkedEntriesModal(self.lib, self) @@ -3695,10 +4020,17 @@ class QtDriver(QObject): self.autofill_action.triggered.connect(lambda: (self.run_macros('autofill', [x[1] for x in self.selected if x[0] == ItemType.ENTRY]), self.preview_panel.update_widgets())) macros_menu.addAction(self.autofill_action) - self.sort_fields_action = QAction('Sort Fields', menu_bar) + self.sort_fields_action = QAction('&Sort Fields', menu_bar) self.sort_fields_action.triggered.connect(lambda: (self.run_macros('sort-fields', [x[1] for x in self.selected if x[0] == ItemType.ENTRY]), self.preview_panel.update_widgets())) + self.sort_fields_action.setShortcut(QtCore.QKeyCombination(QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.AltModifier), QtCore.Qt.Key.Key_S)) + self.sort_fields_action.setToolTip('Alt+S') macros_menu.addAction(self.sort_fields_action) + folders_to_tags_action = QAction('Folders to Tags', menu_bar) + ftt_modal = FoldersToTagsModal(self.lib, self) + folders_to_tags_action.triggered.connect(lambda:ftt_modal.show()) + macros_menu.addAction(folders_to_tags_action) + self.set_macro_menu_viability() menu_bar.addMenu(file_menu) @@ -3781,10 +4113,15 @@ class QtDriver(QObject): self.splash.finish(self.main_window) self.preview_panel.update_widgets() - - if self.args.open: - self.splash.showMessage(f'Opening Library "{self.args.open}"...', int(Qt.AlignmentFlag.AlignBottom|Qt.AlignmentFlag.AlignHCenter), QColor('#9782ff')) - self.open_library(self.args.open) + # Check if a library should be opened on startup, args should override last_library + # TODO: check for behavior (open last, open default, start empty) + if self.args.open or self.settings.contains("last_library"): + if self.args.open: + lib = self.args.open + elif self.settings.value("last_library"): + lib = self.settings.value("last_library") + self.splash.showMessage(f'Opening Library "{lib}"...', int(Qt.AlignmentFlag.AlignBottom|Qt.AlignmentFlag.AlignHCenter), QColor('#9782ff')) + self.open_library(lib) app.exec_() @@ -3804,6 +4141,8 @@ class QtDriver(QObject): # Save Library on Application Exit if self.lib.library_dir: self.save_library() + self.settings.setValue("last_library", self.lib.library_dir) + self.settings.sync() QApplication.quit() @@ -3834,6 +4173,17 @@ class QtDriver(QObject): # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) self.modal.show() + def show_tag_database(self): + self.modal = PanelModal(TagDatabasePanel(self.lib),'Tag Database', 'Tag Database', has_save=False) + self.modal.show() + + def show_file_extension_modal(self): + # self.modal = FileExtensionModal(self.lib) + panel = FileExtensionModal(self.lib) + self.modal = PanelModal(panel, 'Ignored File Extensions', 'Ignored File Extensions', has_save=True) + self.modal.saved.connect(lambda: (panel.save(), self.filter_items(''))) + self.modal.show() + def add_new_files_callback(self): """Runs when user initiates adding new files to the Library.""" # # if self.lib.files_not_in_library: @@ -3956,7 +4306,7 @@ class QtDriver(QObject): # sleep(5) # pb.deleteLater() - def run_macros(self, name: str, entry_ids: int): + def run_macros(self, name: str, entry_ids: list[int]): """Runs a specific Macro on a group of given entry_ids.""" for id in entry_ids: self.run_macro(name, id) @@ -4032,7 +4382,7 @@ class QtDriver(QObject): trimmed = False if len(self.nav_frames) > self.cur_frame_idx + 1: - if (frame_content != None): + if frame_content is not None: # Trim the nav stack if user is taking a new route. self.nav_frames = self.nav_frames[:self.cur_frame_idx+1] if self.nav_frames and not self.nav_frames[self.cur_frame_idx].contents: @@ -4044,7 +4394,7 @@ class QtDriver(QObject): self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos self.cur_frame_idx += 1 if not trimmed else 0 # Moving forward at the end of the stack with new content - elif (frame_content != None): + elif frame_content is not None: # If the current page is empty, don't include it in the new stack. if self.nav_frames and not self.nav_frames[self.cur_frame_idx].contents: self.nav_frames.pop() @@ -4055,7 +4405,7 @@ class QtDriver(QObject): self.cur_frame_idx += 1 if not trimmed else 0 # if self.nav_stack[self.cur_page_idx].contents: - if (self.cur_frame_idx != original_pos) or (frame_content != None): + if (self.cur_frame_idx != original_pos) or (frame_content is not None): self.update_thumbs() sb.verticalScrollBar().setValue( self.nav_frames[self.cur_frame_idx].scrollbar_pos) @@ -4320,12 +4670,16 @@ class QtDriver(QObject): # logging.info( # f'[MAIN] Elements thumbs updated in {(end_time - start_time):.3f} seconds') + def update_badges(self): + for i, item_thumb in enumerate(self.item_thumbs, start=0): + item_thumb.update_badges() + def expand_collation(self, collation_entries: list[tuple[int, int]]): self.nav_forward([(ItemType.ENTRY, x[0]) for x in collation_entries]) # self.update_thumbs() - def get_frame_contents(self, index=0, query=str): + def get_frame_contents(self, index=0, query: str = None): return ([] if not self.frame_dict[query] else self.frame_dict[query][index], index, len(self.frame_dict[query])) def filter_items(self, query=''): @@ -4406,6 +4760,7 @@ class QtDriver(QObject): self.preview_panel.update_widgets() self.filter_items() + def create_collage(self) -> None: """Generates and saves an image collage based on Library Entries.""" @@ -4486,7 +4841,7 @@ class QtDriver(QObject): if not data_only_mode: time.sleep(5) - + self.collage = Image.new('RGB', (img_size,img_size)) i = 0 self.completed = 0 @@ -4514,10 +4869,265 @@ 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 end_time = time.time() self.main_window.statusbar.showMessage(f'Collage Saved at "{filename}" ({format_timespan(end_time - self.collage_start_time)})') logging.info(f'Collage Saved at "{filename}" ({format_timespan(end_time - self.collage_start_time)})') + +class FoldersToTagsModal(QWidget): + # done = Signal(int) + def __init__(self, library:'Library', driver:'QtDriver'): + super().__init__() + self.library = library + self.driver:QtDriver = driver + self.count = -1 + self.filename = '' + + self.setWindowTitle(f'Folders To Tags') + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(500, 800) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6,6,6,6) + + self.desc_widget = QLabel() + self.desc_widget.setObjectName('descriptionLabel') + self.desc_widget.setWordWrap(True) + self.desc_widget.setStyleSheet( + # 'background:blue;' + 'text-align:left;' + # 'font-weight:bold;' + 'font-size:18px;' + # 'padding-top: 6px' + '') + self.desc_widget.setText('''Creates tags based on the folder structure and applies them to entries.\n The Structure below shows all the tags that would be added and to which files they would be added. It being empty means that there are no Tag to be created or assigned''') + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.scroll_contents = QWidget() + self.scroll_layout = QVBoxLayout(self.scroll_contents) + self.scroll_layout.setContentsMargins(6,0,6,0) + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.scroll_area = QScrollArea() + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.scroll_area.setWidget(self.scroll_contents) + + self.Apply_button = QPushButton() + self.Apply_button.setText('&Apply') + self.Apply_button.clicked.connect(lambda: self.folders_to_tags(self.library)) + + self.showEvent = self.on_open + + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.scroll_area) + self.root_layout.addWidget(self.Apply_button) + + def on_open(self,event): + for i in reversed(range(self.scroll_layout.count())): + self.scroll_layout.itemAt(i).widget().setParent(None) + + data = self.generate_preview_data(self.library) + + for folder in data["dirs"].values(): + test = self.TreeItemTest(folder,None) + self.scroll_layout.addWidget(test) + + + def generate_preview_data(self,library:Library): + tree = dict(dirs={},files=[]) + + def add_tag_to_tree(list:list[Tag]): + branch = tree + for tag in list: + if tag.name not in branch["dirs"]: + branch["dirs"][tag.name] = dict(dirs={},tag=tag,files=[]) + branch = branch["dirs"][tag.name] + + def add_folders_to_tree(list:list[str])->Tag: + branch = tree + for folder in list: + if folder not in branch["dirs"]: + new_tag = Tag(-1, folder,"",[],[],"green") + branch["dirs"][folder] = dict(dirs={},tag=new_tag,files=[]) + branch = branch["dirs"][folder] + return branch + + for tag in library.tags: + reversed_tag = self.reverse_tag(tag,None) + logging.info(set(map(lambda tag:tag.name ,reversed_tag))) + add_tag_to_tree(reversed_tag) + + for entry in library.entries: + folders = entry.path.split("\\") + if len(folders) == 1 and folders[0] == "": continue + branch = add_folders_to_tree(folders) + if branch: + field_indexes = library.get_field_index_in_entry(entry,6) + has_tag=False + for index in field_indexes: + content = library.get_field_attr(entry.fields[index],"content") + for tag_id in content: + tag = library.get_tag(tag_id) + if tag.name == branch["tag"].name: + has_tag=True + break + if not has_tag: + branch["files"].append(entry.filename) + + def cut_branches_adding_nothing(branch:dict): + folders = set(branch["dirs"].keys()) + for folder in folders: + logging.info(folder) + cut = cut_branches_adding_nothing(branch["dirs"][folder]) + if cut: + branch['dirs'].pop(folder) + + if not "tag" in branch: return + if branch["tag"].id == -1:#Needs to be first + return False + if len(branch["dirs"].keys()) == 0: + return True + + + cut_branches_adding_nothing(tree) + + return tree + + def folders_to_tags(self,library:Library): + logging.info("Converting folders to Tags") + tree = dict(dirs={}) + def add_tag_to_tree(list:list[Tag]): + branch = tree + for tag in list: + if tag.name not in branch["dirs"]: + branch["dirs"][tag.name] = dict(dirs={},tag=tag) + branch = branch["dirs"][tag.name] + + def add_folders_to_tree(list:list[str])->Tag: + branch = tree + for folder in list: + if folder not in branch["dirs"]: + new_tag = Tag(-1, folder,"",[],([branch["tag"].id] if "tag" in branch else []),"") + library.add_tag_to_library(new_tag) + branch["dirs"][folder] = dict(dirs={},tag=new_tag) + branch = branch["dirs"][folder] + return branch["tag"] + + + for tag in library.tags: + reversed_tag = self.reverse_tag(tag,None) + add_tag_to_tree(reversed_tag) + + for entry in library.entries: + folders = entry.path.split("\\") + if len(folders)== 1 and folders[0]=="": continue + tag = add_folders_to_tree(folders) + if tag: + if not entry.has_tag(library,tag.id): + entry.add_tag(library,tag.id,6) + + self.close() + + logging.info("Done") + + def reverse_tag(self,tag:Tag,list:list[Tag]) -> list[Tag]: + if list != None: + list.append(tag) + else: + list = [tag] + + if len(tag.subtag_ids) == 0: + list.reverse() + return list + else: + for subtag_id in tag.subtag_ids: + subtag = self.library.get_tag(subtag_id) + return self.reverse_tag(subtag,list) + + class ModifiedTagWidget(QWidget): # Needed to be modified because the original searched the display name in the library where it wasn't added yet + def __init__(self, tag:Tag,parentTag:Tag) -> None: + super().__init__() + self.tag = tag + + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.base_layout = QVBoxLayout(self) + self.base_layout.setObjectName('baseLayout') + self.base_layout.setContentsMargins(0, 0, 0, 0) + + self.bg_button = QPushButton(self) + self.bg_button.setFlat(True) + if parentTag != None: + text = f"{tag.name} ({parentTag.name})".replace('&', '&&') + else: + text = tag.name.replace('&', '&&') + self.bg_button.setText(text) + self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + + self.inner_layout = QHBoxLayout() + self.inner_layout.setObjectName('innerLayout') + self.inner_layout.setContentsMargins(2, 2, 2, 2) + self.bg_button.setLayout(self.inner_layout) + self.bg_button.setMinimumSize(math.ceil(22*1.5), 22) + + self.bg_button.setStyleSheet( + f'QPushButton{{' + f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};' + f"color: {get_tag_color(ColorType.TEXT, tag.color)};" + f'font-weight: 600;' + f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};" + f'border-radius: 6px;' + f'border-style:inset;' + f'border-width: {math.ceil(1*self.devicePixelRatio())}px;' + f'padding-right: 4px;' + f'padding-bottom: 1px;' + f'padding-left: 4px;' + f'font-size: 13px' + f'}}' + f'QPushButton::hover{{' + f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};" + f'}}') + + self.base_layout.addWidget(self.bg_button) + self.setMinimumSize(50,20) + class TreeItemTest(QWidget): + def __init__(self,data:dict,parentTag:Tag): + super().__init__() + + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(20,0,0,0) + self.root_layout.setSpacing(1) + + self.test = QWidget() + self.root_layout.addWidget(self.test) + + self.tag_layout = FlowLayout(self.test) + + self.tag_widget = FoldersToTagsModal.ModifiedTagWidget(data["tag"],parentTag) + self.tag_widget.bg_button.clicked.connect(lambda:self.hide_show()) + self.tag_layout.addWidget(self.tag_widget) + + self.children_widget = QWidget() + self.children_layout = QVBoxLayout(self.children_widget) + self.root_layout.addWidget(self.children_widget) + + self.populate(data) + + def hide_show(self): + self.children_widget.setHidden(not self.children_widget.isHidden()) + + def populate(self,data:dict): + for folder in data["dirs"].values(): + item = FoldersToTagsModal.TreeItemTest(folder,data["tag"]) + self.children_layout.addWidget(item) + for file in data["files"]: + label = QLabel() + label.setText(file) + self.children_layout.addWidget(label) + + if len(data["files"]) == 0 and len(data["dirs"].values()) == 0: + self.hide_show() \ No newline at end of file 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():