diff --git a/pyproject.toml b/pyproject.toml index 74f9591..5435d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,2 @@ [tool.ruff] -exclude = ["main_window.py", "home_ui.py"] +exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"] diff --git a/tagstudio/src/cli/ts_cli.py b/tagstudio/src/cli/ts_cli.py index 723e8ef..2497a9f 100644 --- a/tagstudio/src/cli/ts_cli.py +++ b/tagstudio/src/cli/ts_cli.py @@ -16,6 +16,7 @@ import pillow_avif from pathlib import Path import traceback import cv2 + # import climage # import click from datetime import datetime as dt @@ -25,3276 +26,3790 @@ from src.core.utils.fs import * from src.core.library import * from src.qt.helpers.file_opener import open_file -WHITE_FG = '\033[37m' -WHITE_BG = '\033[47m' -BRIGHT_WHITE_FG = '\033[97m' -BRIGHT_WHITE_BG = '\033[107m' -BLACK_FG = '\033[30m' -BRIGHT_CYAN_FG = '\033[96m' -BRIGHT_CYAN_BG = '\033[106m' -BRIGHT_MAGENTA_FG = '\033[95m' -BRIGHT_MAGENTA_BG = '\033[105m' -BRIGHT_GREEN_FG = '\033[92m' -BRIGHT_GREEN_BG = '\033[102m' -YELLOW_FG = '\033[33m' -YELLOW_BG = '\033[43m' -BRIGHT_YELLOW_FG = '\033[93m' -BRIGHT_YELLOW_BG = '\033[103m' -RED_BG = '\033[41m' -BRIGHT_RED_FG = '\033[91m' -BRIGHT_RED_BG = '\033[101m' -MAGENTA_FG = '\033[35m' -MAGENTA_BG = '\033[45m' -RESET = '\033[0m' -SAVE_SCREEN = '\033[?1049h\033[?47h\033[H' -RESTORE_SCREEN = '\033[?47l\033[?1049l' +WHITE_FG = "\033[37m" +WHITE_BG = "\033[47m" +BRIGHT_WHITE_FG = "\033[97m" +BRIGHT_WHITE_BG = "\033[107m" +BLACK_FG = "\033[30m" +BRIGHT_CYAN_FG = "\033[96m" +BRIGHT_CYAN_BG = "\033[106m" +BRIGHT_MAGENTA_FG = "\033[95m" +BRIGHT_MAGENTA_BG = "\033[105m" +BRIGHT_GREEN_FG = "\033[92m" +BRIGHT_GREEN_BG = "\033[102m" +YELLOW_FG = "\033[33m" +YELLOW_BG = "\033[43m" +BRIGHT_YELLOW_FG = "\033[93m" +BRIGHT_YELLOW_BG = "\033[103m" +RED_BG = "\033[41m" +BRIGHT_RED_FG = "\033[91m" +BRIGHT_RED_BG = "\033[101m" +MAGENTA_FG = "\033[35m" +MAGENTA_BG = "\033[45m" +RESET = "\033[0m" +SAVE_SCREEN = "\033[?1049h\033[?47h\033[H" +RESTORE_SCREEN = "\033[?47l\033[?1049l" -ERROR = f'{RED_BG}{BRIGHT_WHITE_FG}[ERROR]{RESET}' -WARNING = f'{RED_BG}{BRIGHT_WHITE_FG}[WARNING]{RESET}' -INFO = f'{BRIGHT_CYAN_BG}{BLACK_FG}[INFO]{RESET}' +ERROR = f"{RED_BG}{BRIGHT_WHITE_FG}[ERROR]{RESET}" +WARNING = f"{RED_BG}{BRIGHT_WHITE_FG}[WARNING]{RESET}" +INFO = f"{BRIGHT_CYAN_BG}{BLACK_FG}[INFO]{RESET}" def clear(): - """Clears the terminal screen.""" + """Clears the terminal screen.""" - # Windows - if os.name == 'nt': - _ = os.system('cls') + # Windows + if os.name == "nt": + _ = os.system("cls") - # Unix - else: - _ = os.system('clear') + # Unix + else: + _ = os.system("clear") class CliDriver: - """A basic CLI driver for TagStudio.""" - - def __init__(self, core, args): - self.core: TagStudioCore = core - self.lib = self.core.lib - self.filtered_entries: list[tuple[ItemType, int]] = [] - self.args = args - self.first_open: bool = True - self.first_browse: bool = True - self.is_missing_count_init: bool = False - self.is_new_file_count_init: bool = False - self.is_dupe_entry_count_init: bool = False - self.is_dupe_file_count_init: bool = False - - self.external_preview_size: tuple[int, int] = (960, 960) - epd_path = os.path.normpath( - f'{Path(__file__).parent.parent.parent}/resources/cli/images/external_preview.png') - self.external_preview_default: Image = Image.open(epd_path) if os.path.exists( - epd_path) else Image.new(mode='RGB', size=(self.external_preview_size)) - self.external_preview_default.thumbnail(self.external_preview_size) - epb_path = os.path.normpath( - f'{Path(__file__).parent.parent.parent}/resources/cli/images/no_preview.png') - self.external_preview_broken: Image = Image.open(epb_path) if os.path.exists( - epb_path) else Image.new(mode='RGB', size=(self.external_preview_size)) - self.external_preview_broken.thumbnail(self.external_preview_size) - - self.branch: str = (' ('+VERSION_BRANCH + - ')') if VERSION_BRANCH else '' - self.base_title: str = f'TagStudio {VERSION}{self.branch} - CLI Mode' - self.title_text: str = self.base_title - self.buffer = {} - - def start(self): - """Enters the CLI.""" - print(SAVE_SCREEN, end='') - try: - self.scr_main_menu() - except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - self.exit(save=False, backup=False) - except KeyboardInterrupt: - # traceback.print_exc() - print('\nForce Quitting TagStudio...') - # if self.lib and self.lib.library_dir: - # self.backup_library() - # self.cleanup_before_exit() - # sys.exit() - self.exit(save=False, backup=False) - except: - traceback.print_exc() - print('\nPress Enter to Continue...') - input() - # if self.lib and self.lib.library_dir: - # self.backup_library() - # self.cleanup_before_exit() - # sys.exit() - self.exit(save=False, backup=True) - # except: - # print( - # '\nAn Unknown Exception in TagStudio has Occurred. Press Enter to Continue...') - # input() - # # if self.lib and self.lib.library_dir: - # # self.backup_library() - # # self.cleanup_before_exit() - # # sys.exit() - # self.quit(save=False, backup=True) - - def cleanup_before_exit(self, restore_screen=True): - """Things do be done on application exit.""" - try: - if self.args.external_preview: - self.close_external_preview() - except Exception: - traceback.print_exc() - print('\nCrashed on Cleanup! This is unusual... Press Enter to Continue...') - input() - self.backup_library() - - if restore_screen: - print(f'{RESET}{RESTORE_SCREEN}', end='') - - def exit(self, save:bool, backup:bool): - """Exists TagStudio, and optionally saves and/or backs up data.""" - - if save: - print(f'{INFO} Saving Library to disk...') - self.save_library(display_message=False) - if backup: - print(f'{INFO} Saving Library changes to Backups folder...') - self.backup_library(display_message=False) - - self.cleanup_before_exit() - - try: - sys.exit() - except SystemExit: - sys.exit() - - def format_title(self, str, color=f'{BRIGHT_WHITE_FG}{MAGENTA_BG}') -> str: - """Formats a string with title formatting.""" - # Floating Pill (Requires NerdFont) - # return f'◀ {str} ▶'.center(os.get_terminal_size()[0], " ").replace('◀', '\033[96m\033[0m\033[30m\033[106m').replace('▶', '\033[0m\033[96m\033[0m') - # Solid Background - return f'{color}{str.center(os.get_terminal_size()[0], " ")[:os.get_terminal_size()[0]]}{RESET}' - - def format_subtitle(self, str, color=BRIGHT_CYAN_FG) -> str: - """Formats a string with subtitle formatting.""" - return f'{color}{(" "+str+" ").center(os.get_terminal_size()[0], "═")[:os.get_terminal_size()[0]]}{RESET}' - - def format_h1(self, str, color=BRIGHT_MAGENTA_FG) -> str: - """Formats a string with h1 formatting.""" - return f'{color}{("┫ "+str+" ┣").center(os.get_terminal_size()[0], "━")[:os.get_terminal_size()[0]]}{RESET}' - - def format_h2(self, str, color=BRIGHT_GREEN_FG) -> str: - """Formats a string with h2 formatting.""" - return f'{color}{(" "+str+" ").center(os.get_terminal_size()[0], "·")[:os.get_terminal_size()[0]]}{RESET}' - - def get_file_color(self, ext: str): - if ext.lower().replace('.','',1) == 'gif': - return BRIGHT_YELLOW_FG - if ext.lower().replace('.','',1) in IMAGE_TYPES: - return WHITE_FG - elif ext.lower().replace('.','',1) in VIDEO_TYPES: - return BRIGHT_CYAN_FG - elif ext.lower().replace('.','',1) in DOC_TYPES: - return BRIGHT_GREEN_FG - else: - return BRIGHT_WHITE_FG - - def get_tag_color(self, color: str) -> str: - if color.lower() == 'black': - return '\033[48;2;17;16;24m' + '\033[38;2;183;182;190m' - # return '\033[48;5;233m' + BRIGHT_WHITE_FG - elif color.lower() == 'dark gray': - return '\033[48;2;36;35;42m' + '\033[38;2;189;189;191m' - # return '\033[48;5;233m' + BRIGHT_WHITE_FG - elif color.lower() == 'gray': - return '\033[48;2;83;82;90m' + '\033[38;2;203;202;210m' - # return '\033[48;5;246m' + BRIGHT_WHITE_FG - elif color.lower() == 'light gray': - return '\033[48;2;170;169;176m' + '\033[38;2;34;33;40m' - # return '\033[48;5;250m' + BLACK_FG - elif color.lower() == 'white': - return '\033[48;2;242;241;248m' + '\033[38;2;48;47;54m' - # return '\033[48;5;231m' + '\033[38;5;244m' - elif color.lower() == 'light pink': - return '\033[48;2;255;143;190m' + '\033[38;2;108;43;57m' - # return '\033[48;5;212m' + '\033[38;5;88m' - elif color.lower() == 'pink': - return '\033[48;2;250;74;117m' + '\033[38;2;91;23;35m' - # return '\033[48;5;204m' + '\033[38;5;224m' - elif color.lower() == 'magenta': - return '\033[48;2;224;43;132m' + '\033[38;2;91;13;54m' - # return '\033[48;5;197m' + '\033[38;5;224m' - elif color.lower() == 'red': - return '\033[48;2;226;44;60m' + '\033[38;2;68;13;18m' - # return '\033[48;5;196m' + '\033[38;5;224m' - elif color.lower() == 'red orange': - return '\033[48;2;232;55;38m' + '\033[38;2;97;18;11m' - # return '\033[48;5;202m' + '\033[38;5;221m' - elif color.lower() == 'salmon': - return '\033[48;2;246;88;72m' + '\033[38;2;111;27;22m' - # return '\033[48;5;203m' + '\033[38;5;88m' - elif color.lower() == 'orange': - return '\033[48;2;237;96;34m' + '\033[38;2;85;30;10m' - # return '\033[48;5;208m' + '\033[38;5;229m' - elif color.lower() == 'yellow orange': - return '\033[48;2;250;154;44m' + '\033[38;2;102;51;13m' - # return '\033[48;5;214m' + '\033[38;5;88m' - elif color.lower() == 'yellow': - return '\033[48;2;255;214;61m' + '\033[38;2;117;67;18m' - # return '\033[48;5;220m' + '\033[38;5;88m' - elif color.lower() == 'mint': - return '\033[48;2;74;237;144m' + '\033[38;2;22;79;62m' - # return '\033[48;5;84m' + '\033[38;5;17m' - elif color.lower() == 'lime': - return '\033[48;2;149;227;69m' + '\033[38;2;65;84;21m' - # return '\033[48;5;154m' + '\033[38;5;17m' - elif color.lower() == 'light green': - return '\033[48;2;138;236;125m' + '\033[38;2;44;85;38m' - # return '\033[48;5;40m' + '\033[38;5;17m' - elif color.lower() == 'green': - return '\033[48;2;40;187;72m' + '\033[38;2;13;56;40m' - # return '\033[48;5;28m' + '\033[38;5;191m' - elif color.lower() == 'teal': - return '\033[48;2;23;191;157m' + '\033[38;2;7;58;68m' - # return '\033[48;5;36m' + '\033[38;5;17m' - elif color.lower() == 'cyan': - return '\033[48;2;60;222;196m' + '\033[38;2;12;64;66m' - # return '\033[48;5;50m' + '\033[38;5;17m' - elif color.lower() == 'light blue': - return '\033[48;2;85;187;246m' + '\033[38;2;18;37;65m' - # return '\033[48;5;75m' + '\033[38;5;17m' - elif color.lower() == 'blue': - return '\033[48;2;59;99;240m' + '\033[38;2;158;192;249m' - # return '\033[48;5;27m' + BRIGHT_WHITE_FG - elif color.lower() == 'blue violet': - return '\033[48;2;93;88;241m' + '\033[38;2;149;176;249m' - # return '\033[48;5;63m' + BRIGHT_WHITE_FG - elif color.lower() == 'violet': - return '\033[48;2;120;60;239m' + '\033[38;2;187;157;247m' - # return '\033[48;5;57m' + BRIGHT_WHITE_FG - elif color.lower() == 'purple': - return '\033[48;2;155;79;240m' + '\033[38;2;73;24;98m' - # return '\033[48;5;135m' + BRIGHT_WHITE_FG - elif color.lower() == 'peach': - return '\033[48;2;241;198;156m' + '\033[38;2;97;63;47m' - # return '\033[48;5;223m' + '\033[38;5;88m' - elif color.lower() == 'brown': - return '\033[48;2;130;50;22m' + '\033[38;2;205;157;131m' - # return '\033[48;5;130m' + BRIGHT_WHITE_FG - elif color.lower() == 'lavender': - return '\033[48;2;173;142;239m' + '\033[38;2;73;43;101m' - # return '\033[48;5;141m' + '\033[38;5;17m' - elif color.lower() == 'blonde': - return '\033[48;2;239;198;100m' + '\033[38;2;109;70;30m' - # return '\033[48;5;221m' + '\033[38;5;88m' - elif color.lower() == 'auburn': - return '\033[48;2;161;50;32m' + '\033[38;2;217;138;127m' - # return '\033[48;5;88m' + '\033[38;5;216m' - elif color.lower() == 'light brown': - return '\033[48;2;190;91;45m' + '\033[38;2;76;41;14m' - elif color.lower() == 'dark brown': - return '\033[48;2;76;35;21m' + '\033[38;2;183;129;113m' - # return '\033[48;5;172m' + BRIGHT_WHITE_FG - elif color.lower() == 'cool gray': - return '\033[48;2;81;87;104m' + '\033[38;2;158;161;195m' - # return '\033[48;5;102m' + BRIGHT_WHITE_FG - elif color.lower() == 'warm gray': - return '\033[48;2;98;88;80m' + '\033[38;2;192;171;146m' - # return '\033[48;5;59m' + BRIGHT_WHITE_FG - elif color.lower() == 'olive': - return '\033[48;2;76;101;46m' + '\033[38;2;180;193;122m' - # return '\033[48;5;58m' + '\033[38;5;193m' - elif color.lower() == 'berry': - return '\033[48;2;159;42;167m' + '\033[38;2;204;143;220m' - else: - return '' - - def copy_field_to_buffer(self, entry_field) -> None: - """Copies an Entry Field object into the internal buffer.""" - self.buffer = dict(entry_field) - - def paste_field_from_buffer(self, entry_id) -> None: - """Merges or adds the Entry Field object in the internal buffer to the Entry.""" - if self.buffer: - # entry: Entry = self.lib.entries[entry_index] - # entry = self.lib.get_entry(entry_id) - field_id: int = self.lib.get_field_attr(self.buffer, 'id') - content = self.lib.get_field_attr(self.buffer, 'content') - - # NOTE: This code is pretty much identical to the match_conditions code - # found in the core. Could this be made generic? Especially for merging Entries. - if self.lib.get_field_obj(int(field_id))['type'] == 'tag_box': - existing_fields: list[int] = self.lib.get_field_index_in_entry( - entry_id, field_id) - if existing_fields: - self.lib.update_entry_field( - entry_id, existing_fields[0], content, 'append') - else: - self.lib.add_field_to_entry(entry_id, field_id) - self.lib.update_entry_field( - entry_id, -1, content, 'append') - - if self.lib.get_field_obj(int(field_id))['type'] in TEXT_FIELDS: - if not self.lib.does_field_content_exist(entry_id, field_id, content): - self.lib.add_field_to_entry(entry_id, field_id) - self.lib.update_entry_field( - entry_id, -1, content, 'replace') - - # existing_fields: list[int] = self.lib.get_field_index_in_entry(entry_index, field_id) - # if existing_fields: - # self.lib.update_entry_field(entry_index, existing_fields[0], content, 'append') - # else: - # self.lib.add_field_to_entry(entry_index, field_id) - # self.lib.update_entry_field(entry_index, -1, content, 'replace') - - def init_external_preview(self) -> None: - """Initialized the external preview image file.""" - if self.lib and self.lib.library_dir: - external_preview_path: str = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg') - if not os.path.isfile(external_preview_path): - temp = self.external_preview_default - temp.save(external_preview_path) - - open_file(external_preview_path) - - def set_external_preview_default(self) -> None: - """Sets the external preview to its default image.""" - if self.lib and self.lib.library_dir: - external_preview_path: str = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg') - if os.path.isfile(external_preview_path): - temp = self.external_preview_default - temp.save(external_preview_path) - - def set_external_preview_broken(self) -> None: - """Sets the external preview image file to the 'broken' placeholder.""" - if self.lib and self.lib.library_dir: - external_preview_path: str = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg') - if os.path.isfile(external_preview_path): - temp = self.external_preview_broken - temp.save(external_preview_path) - - def close_external_preview(self) -> None: - """Destroys and closes the external preview image file.""" - if self.lib and self.lib.library_dir: - external_preview_path: str = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg') - if os.path.isfile(external_preview_path): - os.remove(external_preview_path) - - def scr_create_library(self, path=''): - """Screen for creating a new TagStudio library.""" - - subtitle = 'Create Library' - - clear() - print(f'{self.format_title(self.title_text)}') - print(self.format_subtitle(subtitle)) - print('') - - if not path: - print('Enter Library Folder Path: \n> ', end='') - path = input() - if os.path.exists(path): - print('') - print(f'{INFO} Are you sure you want to create a new Library at \"{path}\"? (Y/N)\n> ', end='') - con = input().lower() - if con == 'y' or con == 'yes': - result = self.lib.create_library(path) - if result == 0: - print(f'{INFO} Created new TagStudio Library at: \"{path}\"\nPress Enter to Return to Main Menu...') - input() - # self.open_library(path) - elif result == 1: - print(f'{ERROR} Could not create Library. Path: \"{path}\" is pointing inside an existing TagStudio Folder.\nPress Enter to Return to Main Menu...') - input() - elif result == 2: - print(f'{ERROR} Could not write inside path: \"{path}\"\nPress Enter to Return to Main Menu...') - input() - else: - print(f'{ERROR} Invalid Path: \"{path}\"\nPress Enter to Return to Main Menu...') - input() - # if Core.open_library(path) == 1: - # self.library_name = path - # self.scr_library_home() - # else: - # print(f'[ERROR]: No existing TagStudio library found at \'{path}\'') - # self.scr_main_menu() - - def open_library(self, path): - """Opens a TagStudio library.""" - - return_code = self.lib.open_library(path) - if return_code == 1: - # self.lib = self.core.library - if self.args.external_preview: - self.init_external_preview() - - if len(self.lib.entries) <= 1000: - print( - f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...') - self.lib.refresh_missing_files() - # else: - # print( - # f'{INFO} Automatic missing file refreshing is turned off for large libraries (1,000+ Entries)') - self.title_text: str = self.base_title + '' - self.scr_library_home() - else: - clear() - print( - f'{ERROR} No existing TagStudio library found at \'{path}\'') - self.scr_main_menu(clear_scr=False) - - def close_library(self, save=True): - """ - Saves (by default) and clears the current Library as well as related operations. - Does *not* direct the navigation back to the main menu, that's not my job. - """ - if save: - self.lib.save_library_to_disk() - if self.args.external_preview: - self.close_external_preview() - self.lib.clear_internal_vars() - - def backup_library(self, display_message:bool = True) -> bool: - """Saves a backup copy of the Library file to disk. Returns True if successful.""" - if self.lib and self.lib.library_dir: - filename = self.lib.save_library_backup_to_disk() - location = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/backups/{filename}') - if display_message: - print(f'{INFO} Backup of Library saved at \"{location}\".') - return True - return False - - def save_library(self, display_message:bool = True) -> bool: - """Saves the Library file to disk. Returns True if successful.""" - if self.lib and self.lib.library_dir: - self.lib.save_library_to_disk() - if display_message: - print(f'{INFO} Library saved to disk.') - return True - return False - - def get_char_limit(self, text: str) -> int: - """ - Returns an estimated value for how many characters of a block of text should be allowed to display before being truncated. - """ - # char_limit: int = ( - # (os.get_terminal_size()[0] * os.get_terminal_size()[1]) // 6) - # char_limit -= (text.count('\n') + text.count('\r') * (os.get_terminal_size()[0] // 1.0)) - # char_limit = char_limit if char_limit > 0 else min(40, len(text)) - - char_limit: int = os.get_terminal_size( - )[0] * (os.get_terminal_size()[1] // 5) - char_limit -= ((text.count('\n') + text.count('\r')) - * (os.get_terminal_size()[0] // 2)) - char_limit = char_limit if char_limit > 0 else min((64), len(text)) - - # print(f'Char Limit: {char_limit}, Len: {len(text)}') - return char_limit - - 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)}') - return (f'{text[:int(self.get_char_limit(text) - 1)]} {WHITE_FG}[...]{RESET}') - else: - return (text) - - def print_fields(self, index) -> None: - """Prints an Entry's formatted fields to the screen.""" - entry = self.lib.entries[index] - - if entry and self.args.debug: - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} ID: {RESET} ', end='') - print(entry.id_) - - if entry and entry.fields: - - for i, field in enumerate(entry.fields): - # Buffer between box fields below other fields if this isn't the first field - if i != 0 and self.lib.get_field_attr(field, 'type') in BOX_FIELDS and self.lib.get_field_attr(entry.fields[i-1], 'type') not in BOX_FIELDS: - print('') - # Format the field title differently for box fields. - if self.lib.get_field_attr(field, 'type') in BOX_FIELDS: - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG} {self.lib.get_field_attr(field, "name")}: {RESET} ', end='\n') - else: - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG} {self.lib.get_field_attr(field, "name")}: {RESET} ', end='') - if self.lib.get_field_attr(field, 'type') == 'tag_box': - char_count: int = 0 - for tag_id in self.lib.get_field_attr(field, 'content'): - tag = self.lib.get_tag(tag_id) - # Properly wrap Tags on screen - char_count += len(f' {tag.display_name(self.lib)} ') + 1 - if char_count > os.get_terminal_size()[0]: - print('') - char_count = len( - f' {tag.display_name(self.lib)} ') + 1 - print( - f'{self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}', end='') - # If the tag isn't the last one, print a space for the next one. - if tag_id != self.lib.get_field_attr(field, 'content')[-1]: - print(' ', end='') - else: - print('') - elif self.lib.get_field_attr(field, 'type') in TEXT_FIELDS: - # Normalize line endings in any text content. - text: str = self.lib.get_field_attr( - field, 'content').replace('\r', '\n') - print(self.truncate_text(text)) - elif self.lib.get_field_attr(field, 'type') == 'datetime': - try: - # TODO: Localize this and/or add preferences. - date = dt.strptime(self.lib.get_field_attr( - field, 'content'), '%Y-%m-%d %H:%M:%S') - print(date.strftime('%D - %r')) - except: - print(self.lib.get_field_attr(field, 'content')) - else: - print(self.lib.get_field_attr(field, 'content')) - - # Buffer between box fields above other fields if this isn't the last field - if (entry.fields[i] != entry.fields[-1] - and self.lib.get_field_attr(field, 'type') in BOX_FIELDS): - print('') - else: - # print(f'{MAGENTA_BG}{BRIGHT_WHITE_FG}[No Fields]{RESET}{WHITE_FG} (Run \'edit\', then \'add \' to add some!){RESET}') - print(f'{MAGENTA_BG}{BRIGHT_WHITE_FG}[No Fields]{RESET}{WHITE_FG}') - - def print_thumbnail(self, index, filepath='', ignore_fields=False, - max_width=-1) -> None: - """ - Prints an Entry's formatted thumbnail to the screen. - Takes in either an Entry index or a direct filename. - """ - entry = None if index < 0 else self.lib.entries[index] - if entry: - filepath = os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}') - external_preview_path: str = '' - if self.args.external_preview: - external_preview_path = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg') - # thumb_width = min( - # os.get_terminal_size()[0]//2, - # math.floor(os.get_terminal_size()[1]*0.5)) - # thumb_width = math.floor(os.get_terminal_size()[1]*0.5) - - # if entry: - file_type = os.path.splitext(filepath)[1].lower()[1:] - if file_type in (IMAGE_TYPES + VIDEO_TYPES): - # TODO: Make the image-grabbing part try to get thumbnails. - - # Lots of calculations to determine an image width that works well. - w, h = (1, 1) - final_img_path = filepath - if file_type in IMAGE_TYPES: - try: - raw = Image.open(filepath) - w, h = raw.size - # NOTE: Temporary way to hack a non-terminal preview. - if self.args.external_preview: - raw = raw.convert('RGB') - # raw.thumbnail((512, 512)) - raw.thumbnail(self.external_preview_size) - raw.save(external_preview_path) - except: - print(f'{ERROR} Could not load image \"{filepath}\"') - if self.args.external_preview: - self.set_external_preview_broken() - elif file_type in VIDEO_TYPES: - try: - video = cv2.VideoCapture(filepath) - video.set(cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2)) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - final_frame = Image.fromarray(frame) - w, h = final_frame.size - final_frame.save(os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg'), quality=50) - final_img_path = os.path.normpath( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg') - # NOTE: Temporary way to hack a non-terminal preview. - if self.args.external_preview and entry: - final_frame.thumbnail(self.external_preview_size) - final_frame.save(external_preview_path) - except SystemExit: - sys.exit() - except: - print( - f'{ERROR} Could not load video thumbnail for \"{filepath}\"') - if self.args.external_preview and entry: - self.set_external_preview_broken() - pass - - img_ratio: float = w / h - term_ratio_norm: float = (os.get_terminal_size()[ - 1] / os.get_terminal_size()[0]) * 2 - base_mod: float = 0.7 - field_cnt_mod: float = 0 - desc_len_mod: float = 0 - tag_cnt_mod: float = 0 - if entry and entry.fields and not ignore_fields: - field_cnt_mod = 1.5 * len(entry.fields) - for f in entry.fields: - if self.lib.get_field_attr(f, 'type') == 'tag_box': - tag_cnt_mod += (0.5 * - len(self.lib.get_field_attr(f, 'content'))) - elif self.lib.get_field_attr(f, 'type') == 'text_box': - desc_len_mod += (0.07 * - len(self.truncate_text(self.lib.get_field_attr(f, 'content')))) - desc_len_mod += (1.7 * self.truncate_text(self.lib.get_field_attr( - f, 'content')).count('\n')) - desc_len_mod += (1.7 * self.truncate_text(self.lib.get_field_attr( - f, 'content')).count('\r')) - try: - thumb_width = min( - math.floor((os.get_terminal_size()[0] - * img_ratio * term_ratio_norm * base_mod) - - ((field_cnt_mod + desc_len_mod + tag_cnt_mod)*(img_ratio*0.7))), - os.get_terminal_size()[0]) - if max_width > 0: - thumb_width = max_width if thumb_width > max_width else thumb_width - # image = climage.convert(final_img_path, is_truecolor=True, is_256color=False, - # is_16color=False, is_8color=False, width=thumb_width) - # Center Alignment Hack - spacing = (os.get_terminal_size()[0] - thumb_width) // 2 - if not self.args.external_preview or not entry: - print(' ' * spacing, end='') - print(image.replace('\n', ('\n' + ' ' * spacing))) - - if file_type in VIDEO_TYPES: - os.remove( - f'{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg') - except: - if not self.args.external_preview or not entry: - print( - f'{ERROR} Could not display preview. Is there enough screen space?') - - def print_columns(self, content: list[object], add_enum: False) -> None: - """ - Prints content in a column format. - Content: A list of tuples list[(element, formatting)] - """ - try: - if content: - # This is an estimate based on the existing screen formatting. - margin: int = 7 - enum_padding: int = 0 - term_width: int = os.get_terminal_size()[0] - - num_width: int = len(str(len(content) + 1)) - if add_enum: - enum_padding = num_width + 2 - - longest_width: int = len( - max(content, key=lambda x: len(x[0]))[0]) + 1 # + Padding - column_count: int = term_width // ( - longest_width + enum_padding + 3) - column_count: int = column_count if column_count > 0 else 1 - max_display: int = column_count * \ - (os.get_terminal_size()[1]-margin) - displayable: int = min(max_display, len(content)) - - # Recalculate based on displayable items - num_width = len(str(len(content[:max_display]) + 1)) - if add_enum: - enum_padding = num_width + 2 - longest_width = len( - max(content[:max_display], key=lambda x: len(x[0]))[0]) + 1 - column_count = term_width // (longest_width + enum_padding + 3) - column_count = column_count if column_count > 0 else 1 - max_display = column_count * (os.get_terminal_size()[1]-margin) - # displayable: int = min(max_display, len(content)) - - num_width = len(str(len(content[:max_display]) + 1)) - if add_enum: - enum_padding = num_width + 2 - # longest_width = len(max(content[:max_display], key=lambda x: len(x[0]))[0]) + 1 - # column_count = term_width // (longest_width + enum_padding + 3) - # column_count = column_count if column_count > 0 else 1 - # max_display = column_count * (os.get_terminal_size()[1]-margin) - - # print(num_width) - # print(term_width) - # print(longest_width) - # print(columns) - # print(max(content, key = lambda x : len(x[0]))) - # print(len(max(content, key = lambda x : len(x[0]))[0])) - - # # Prints out the list in a left-to-right tabular column form with color formatting. - # for i, element in enumerate(content): - # if i != 0 and i % (columns-1) == 0: - # print('') - # if add_enum: - # print(f'{element[1]}[{str(i+1).zfill(num_width)}] {element[0]} {RESET}', end='') - # else: - # print(f'{element[1]} {element[0]} {RESET}', end='') - # print(' ' * (longest_width - len(element[0])), end='') - - # Prints out the list in a top-down tabular column form with color formatting. - # This is my greatest achievement. - row_count: int = math.floor(len(content) / column_count) - table_size: int = row_count * column_count - table_size = table_size if table_size > 0 else 1 - # print(f'Rows:{max_rows}, Cols:{max_columns}') - row_num = 1 - col_num = 1 - for i, element in enumerate(content): - if i < max_display: - if row_count > 1: - row_number = i // column_count - index = (i * row_count) - \ - (row_number * (table_size-1)) - # col_number = index // math.ceil(len(content) / max_columns) - offset: int = 0 - if displayable % table_size == 1: - offset = 1 if (index >= row_count) and ( - row_number != row_count) else 0 - elif displayable % table_size != 0: - 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 - - if col_num > 1 and (os.get_terminal_size()[1]-margin) < row_count: - offset -= (row_count - (os.get_terminal_size() - [1]-margin)) * (col_num-1) + (col_num-1) - - # print(f'{row_count}/{(os.get_terminal_size()[1]-margin)}', end='') - - index += offset - # print(offset, end='') - # print(f'{row_num}-{col_num}', end='') - else: - index = i - if i != 0 and i % column_count == 0: - row_num += 1 - col_num = 1 - print('') - if index < len(content): - col_num += 1 - col_num = col_num if col_num <= column_count else 1 - if add_enum: - print( - f'{content[index][1]}[{str(index+1).zfill(num_width)}] {content[index][0]} {RESET}', end='') - else: - print( - f'{content[index][1]} {content[index][0]} {RESET}', end='') - if row_count > 0: - print(' ' * (longest_width - - len(content[index][0])), end='') - else: - print(' ', end='') - else: - print( - '\n' + self.format_h2(f'[{len(content) - max_display} More...]'), end='') - # print(WHITE_FG + '\n' + f'[{len(content) - max_display} More...]'.center(os.get_terminal_size()[0], " ")[:os.get_terminal_size()[0]]+RESET) - # print(f'\n{WHITE_FG}[{{RESET}', end='') - break - # print(f'Rows:{row_count}, Cols:{column_count}') - print('') - - except Exception: - traceback.print_exc() - print('\nPress Enter to Continue...') - input() - pass - - def run_macro(self, name: str, entry_id: int): - """Runs a specific Macro on an Entry given a Macro name.""" - # entry: Entry = self.lib.get_entry_from_index(entry_id) - entry = self.lib.get_entry(entry_id) - path = os.path.normpath( - f'{self.lib.library_dir}/{entry.path}/{entry.filename}') - source = path.split(os.sep)[1].lower() - if name == 'sidecar': - self.lib.add_generic_data_to_entry( - self.core.get_gdl_sidecar(path, source), entry_id) - elif name == 'autofill': - self.run_macro('sidecar', entry_id) - self.run_macro('build-url', entry_id) - self.run_macro('match', entry_id) - self.run_macro('clean-url', entry_id) - self.run_macro('sort-fields', entry_id) - elif name == 'build-url': - data = {'source': self.core.build_url(entry_id, source)} - self.lib.add_generic_data_to_entry(data, entry_id) - elif name == 'sort-fields': - order: list[int] = [0] + [1, 2] + [9, 17, 18, 19, 20] + \ - [10, 14, 11, 12, 13, 22] + [4, 5] + [8, 7, 6] + [3, 21] - self.lib.sort_fields(entry_id, order) - elif name == 'match': - self.core.match_conditions(entry_id) - elif name == 'scrape': - self.core.scrape(entry_id) - elif name == 'clean-url': - # entry = self.lib.get_entry_from_index(entry_id) - if entry.fields: - for i, field in enumerate(entry.fields, start=0): - if self.lib.get_field_attr(field, 'type') == 'text_line': - self.lib.update_entry_field( - entry_id=entry_id, - field_index=i, - content=strip_web_protocol( - self.lib.get_field_attr(field, 'content')), - mode='replace') - - def create_collage(self) -> str: - """Generates and saves an image collage based on Library Entries.""" - - run: bool = True - keep_aspect: bool = False - data_only_mode: bool = False - data_tint_mode: bool = False - - mode:int = self.scr_choose_option(subtitle='Choose Collage Mode(s)', - choices=[ - ('Normal','Creates a standard square image collage made up of Library media files.'), - ('Data Tint','Tints the collage with a color representing data about the Library Entries/files.'), - ('Data Only','Ignores media files entirely and only outputs a collage of Library Entry/file data.'), - ('Normal & Data Only','Creates both Normal and Data Only collages.'), - ], prompt='', required=True) - - if mode == 1: - data_tint_mode = True - - if mode == 2: - data_only_mode = True - - if mode in [0, 1, 3]: - keep_aspect = self.scr_choose_option( - subtitle='Choose Aspect Ratio Option', - choices=[ - ('Stretch to Fill','Stretches the media file to fill the entire collage square.'), - ('Keep Aspect Ratio','Keeps the original media file\'s aspect ratio, filling the rest of the square with black bars.') - ], prompt='', required=True) - - if mode in [1, 2, 3]: - # TODO: Choose data visualization options here. - pass - - full_thumb_size: int = 1 - - if mode in [0, 1, 3]: - full_thumb_size = self.scr_choose_option( - subtitle='Choose Thumbnail Size', - choices=[ - ('Tiny (32px)',''), - ('Small (64px)',''), - ('Medium (128px)',''), - ('Large (256px)',''), - ('Extra Large (512px)','') - ], prompt='', required=True) - - thumb_size: int = (32 if (full_thumb_size == 0) - else 64 if (full_thumb_size == 1) - else 128 if (full_thumb_size == 2) - else 256 if (full_thumb_size == 3) - else 512 if (full_thumb_size == 4) - else 32) - - - # if len(com) > 1 and com[1] == 'keep-aspect': - # keep_aspect = True - # elif len(com) > 1 and com[1] == 'data-only': - # data_only_mode = True - # elif len(com) > 1 and com[1] == 'data-tint': - # data_tint_mode = True - grid_size = math.ceil(math.sqrt(len(self.lib.entries)))**2 - grid_len = math.floor(math.sqrt(grid_size)) - thumb_size = thumb_size if not data_only_mode else 1 - img_size = thumb_size * grid_len - - print(f'Creating collage for {len(self.lib.entries)} Entries.\nGrid Size: {grid_size} ({grid_len}x{grid_len})\nIndividual Picture Size: ({thumb_size}x{thumb_size})') - if keep_aspect: - print('Keeping original aspect ratios.') - if data_only_mode: - print('Visualizing Entry Data') - - if not data_only_mode: - time.sleep(5) - - collage = Image.new('RGB', (img_size,img_size)) - filename = os.path.normpath(f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png') - - i = 0 - for x in range(0, grid_len): - for y in range(0, grid_len): - try: - if i < len(self.lib.entries) and run: - # entry: Entry = self.lib.get_entry_from_index(i) - entry = self.lib.entries[i] - filepath = os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}') - file_type = os.path.splitext(filepath)[1].lower()[1:] - color: str = '' - - if data_tint_mode or data_only_mode: - color = '#000000' # Black (Default) - - if entry.fields: - has_any_tags:bool = False - has_content_tags:bool = False - has_meta_tags:bool = False - for field in entry.fields: - if self.lib.get_field_attr(field, 'type') == 'tag_box': - if self.lib.get_field_attr(field, 'content'): - has_any_tags = True - if self.lib.get_field_attr(field, 'id') == 7: - has_content_tags = True - elif self.lib.get_field_attr(field, 'id') == 8: - has_meta_tags = True - if has_content_tags and has_meta_tags: - color = '#28bb48' # Green - elif has_any_tags: - color = '#ffd63d' # Yellow - # color = '#95e345' # Yellow-Green - else: - # color = '#fa9a2c' # Yellow-Orange - color = '#ed8022' # Orange - else: - color = '#e22c3c' # Red - - if data_only_mode: - pic: Image = Image.new('RGB', (thumb_size,thumb_size), color) - collage.paste(pic, (y*thumb_size, x*thumb_size)) - if not data_only_mode: - print(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.flush() - if file_type in IMAGE_TYPES: - with Image.open(os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}')) as pic: - if keep_aspect: - pic.thumbnail((thumb_size,thumb_size)) - else: - pic = pic.resize((thumb_size,thumb_size)) - if data_tint_mode and color: - pic = pic.convert(mode='RGB') - pic = ImageChops.hard_light(pic, Image.new('RGB', (thumb_size,thumb_size), color)) - collage.paste(pic, (y*thumb_size, x*thumb_size)) - elif file_type in VIDEO_TYPES: - video = cv2.VideoCapture(filepath) - video.set(cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2)) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - with Image.fromarray(frame, mode='RGB') as pic: - if keep_aspect: - pic.thumbnail((thumb_size,thumb_size)) - else: - pic = pic.resize((thumb_size,thumb_size)) - if data_tint_mode and color: - pic = ImageChops.hard_light(pic, Image.new('RGB', (thumb_size,thumb_size), color)) - collage.paste(pic, (y*thumb_size, x*thumb_size)) - except UnidentifiedImageError: - print(f'\n{ERROR} Couldn\'t read {entry.path}{os.sep}{entry.filename}') - except KeyboardInterrupt: - # self.quit(save=False, backup=True) - run = False - clear() - print(f'{INFO} Collage operation cancelled.') - clear_scr=False - except: - print(f'{ERROR} {entry.path}{os.sep}{entry.filename}') - traceback.print_exc() - print('Continuing...') - i = i+1 - - if run: - self.lib.verify_ts_folders() - collage.save(filename) - return filename - return '' - - def global_commands(self, com:list[str]) -> tuple[bool,str]: - """ - Executes from a set of global commands.\n - Returns a (bool,str) tuple containing (was command executed?, optional command message) - """ - was_executed:bool = False - message:str = '' - com_name = com[0].lower() - - # Backup Library ======================================================= - 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_name == 'collage': - filename = self.create_collage() - if filename: - was_executed = True - message = f'{INFO} Saved collage to \"{filename}\".' - # Save Library ========================================================= - 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_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_name == 'toggle-external-preview': - self.args.external_preview = not self.args.external_preview - if self.args.external_preview: - self.init_external_preview() - else: - self.close_external_preview() - was_executed = True - message=f'{INFO} External Preview Enabled.' if self.args.external_preview else f'{INFO} External Preview Disabled.' - # Quit ================================================================= - elif com_name in ('quit', 'q'): - self.exit(save=True, backup=False) - was_executed = True - # Quit without Saving ================================================== - elif com_name in ('quit!', 'q!'): - self.exit(save=False, backup=False) - was_executed = True - - return (was_executed, message) - - def scr_browse_help(self, prev) -> None: - """A Help screen for commands available during Library Browsing.""" - pass - - def scr_main_menu(self, clear_scr=True): - """The CLI main menu.""" - - while True: - if self.args.open and self.first_open: - self.first_open = False - self.open_library(self.args.open) - - if clear_scr: - clear() - clear_scr = True - print(f'{self.format_title(self.title_text)}') - print('') - print(f'\t{BRIGHT_WHITE_FG}{MAGENTA_BG} - Basic Commands - {RESET}') - print(f'\t\tOpen Library: {WHITE_FG}open | o {RESET}') - print( - f'\t\tCreate New Library: {WHITE_FG}new | n {RESET}') - # print(f'\t\tHelp: {WHITE_FG}help | h{RESET}') - print('') - print(f'\t\tQuit TagStudio: {WHITE_FG}quit | q{RESET}') - print('') - print(f'\t💡TIP: {WHITE_FG}TagStudio can be launched with the --open (or -o) option followed\n\t\tby to immediately open a library!{RESET}') - print('') - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - if com[0].lower() == 'open' or com[0].lower() == 'o': - if len(com) > 1: - self.open_library(com[1]) - elif com[0].lower() == 'new' or com[0].lower() == 'n': - if len(com) > 1: - self.scr_create_library(com[1]) - # elif (com[0].lower() == 'toggle-debug'): - # self.args.debug = not self.args.debug - # elif com[0].lower() in ['quit', 'q', 'close', 'c']: - # sys.exit() - # elif com[0].lower() in ['quit!', 'q!']: - # sys.exit() - else: - clear() - print( - f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - - def scr_library_home(self, clear_scr=True): - """Home screen for an opened Library.""" - - while True: - subtitle = f'Library \'{self.lib.library_dir}\'' - if self.lib.is_legacy_library: - subtitle += ' (Legacy Format)' - if self.args.debug: - subtitle += ' (Debug Mode Active)' - # Directory Info ------------------------------------------------------- - file_count: str = f'{BRIGHT_YELLOW_FG}N/A (Run \'refresh dir\' to update){RESET}' if self.lib.dir_file_count == - \ - 1 else f'{WHITE_FG}{self.lib.dir_file_count}{RESET}' - - new_file_count: str = f'{BRIGHT_YELLOW_FG}N/A (Run \'refresh dir\' to update){RESET}' if (self.lib.files_not_in_library == [ - ] and not self.is_new_file_count_init) else f'{WHITE_FG}{len(self.lib.files_not_in_library)}{RESET}' - - # Issues --------------------------------------------------------------- - missing_file_count: str = f'{BRIGHT_YELLOW_FG}N/A (Run \'refresh missing\' to update){RESET}' if ( - self.lib.missing_files == [] and not self.is_missing_count_init) else f'{BRIGHT_RED_FG}{len(self.lib.missing_files)}{RESET}' - missing_file_count = f'{BRIGHT_GREEN_FG}0{RESET}' if ( - self.is_missing_count_init and len(self.lib.missing_files) == 0) else missing_file_count - - dupe_entry_count: str = f'{BRIGHT_YELLOW_FG}N/A (Run \'refresh dupe entries\' to update){RESET}' if ( - self.lib.dupe_entries == [] and not self.is_dupe_entry_count_init) else f'{BRIGHT_RED_FG}{len(self.lib.dupe_entries)}{RESET}' - dupe_entry_count = f'{BRIGHT_GREEN_FG}0{RESET}' if ( - self.is_dupe_entry_count_init and len(self.lib.dupe_entries) == 0) else dupe_entry_count - - dupe_file_count: str = f'{BRIGHT_YELLOW_FG}N/A (Run \'refresh dupe files\' to update){RESET}' if ( - self.lib.dupe_files == [] and not self.is_dupe_file_count_init) else f'{BRIGHT_RED_FG}{len(self.lib.dupe_files)}{RESET}' - dupe_file_count = f'{BRIGHT_GREEN_FG}0{RESET}' if ( - self.is_dupe_file_count_init and len(self.lib.dupe_files) == 0) else dupe_file_count - # fixed_file_count: str = 'N/A (Run \'fix missing\' to refresh)' if self.lib.fixed_files == [ - # ] else len(self.lib.fixed_files) - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(self.base_title)) - print(self.format_subtitle(subtitle)) - print('') - - if self.args.browse and self.first_browse: - self.first_browse = False - self.filtered_entries = self.lib.search_library() - self.scr_browse_entries_gallery(0) - else: - print(f'\t{BRIGHT_CYAN_BG}{BLACK_FG} - Library Info - {RESET}') - print(f'\t Entries: {WHITE_FG}{len(self.lib.entries)}{RESET}') - # print(f'\tCollations: {WHITE_FG}0{RESET}') - print(f'\t Tags: {WHITE_FG}{len(self.lib.tags)}{RESET}') - print(f'\t Fields: {WHITE_FG}{len(self.lib.default_fields)}{RESET}') - # print(f'\t Macros: {WHITE_FG}0{RESET}') - print('') - print(f'\t{BRIGHT_CYAN_BG}{BLACK_FG} - Directory Info - {RESET}') - print(f'\t Media Files: {file_count} (0 KB)') - print(f'\tNot in Library: {new_file_count} (0 KB)') - # print(f'\t Sidecar Files: 0 (0 KB)') - # print(f'\t Total Files: 0 (0 KB)') - print('') - print(f'\t{BRIGHT_CYAN_BG}{BLACK_FG} - Issues - {RESET}') - print(f'\t Missing Files: {missing_file_count}') - print(f'\tDuplicate Entries: {dupe_entry_count}') - print(f'\t Duplicate Files: {dupe_file_count}') - # print(f' Fixed Files: {WHITE_FG}{fixed_file_count}{RESET}') - print('') - print( - f'\t{BRIGHT_WHITE_FG}{MAGENTA_BG} - Basic Commands - {RESET}') - - print( - f'\tBrowse Library: {WHITE_FG}browse | b{RESET}') - print( - f'\tSearch Library: {WHITE_FG}search | s < query >{RESET}') - print( - f'\tList Info: {WHITE_FG}list | ls < dir | entires | tags | fields | macros | new | missing >{RESET}') - print( - f'\tAdd New Files to Library: {WHITE_FG}add new{RESET}') - print( - f'\tRefresh Info: {WHITE_FG}refresh | r < dir | missing | dupe entries | dupe files >{RESET}') - print( - f'\tFix Issues: {WHITE_FG}fix < missing | dupe entries | dupe files > {RESET}') - # print(f'\tHelp: {WHITE_FG}help | h{RESET}') - - print('') - print(f'\tSave Library: {WHITE_FG}save | backup{RESET}') - print(f'\tClose Library: {WHITE_FG}close | c{RESET}') - print(f'\tQuit TagStudio: {WHITE_FG}quit | q{RESET}') - # print(f'Quit Without Saving: {WHITE_FG}quit! | q!{RESET}') - print('') - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - # Refresh ============================================================== - if (com[0].lower() == 'refresh' or com[0].lower() == 'r') and len(com) > 1: - if com[1].lower() == 'files' or com[1].lower() == 'dir': - print( - f'{INFO} Scanning for files in \'{self.lib.library_dir}\'...') - self.lib.refresh_dir() - self.is_new_file_count_init = True - elif com[1].lower() == 'missing': - print( - f'{INFO} Checking for missing files in \'{self.lib.library_dir}\'...') - self.lib.refresh_missing_files() - self.is_missing_count_init = True - elif com[1].lower() == 'duplicate' or com[1].lower() == 'dupe': - if len(com) > 2: - if com[2].lower() == 'entries' or com[2].lower() == 'e': - print( - f'{INFO} Checking for duplicate entries in Library \'{self.lib.library_dir}\'...') - self.lib.refresh_dupe_entries() - self.is_dupe_entry_count_init = True - elif com[2].lower() == 'files' or com[2].lower() == 'f': - print( - f'{WHITE_FG}Enter the filename for your DupeGuru results file:\n> {RESET}', end='') - dg_results_file = os.path.normpath(input()) - print( - f'{INFO} Checking for duplicate files in Library \'{self.lib.library_dir}\'...') - self.lib.refresh_dupe_files(dg_results_file) - self.is_dupe_file_count_init = True - else: - clear() - print( - f'{ERROR} Specify which duplicates to refresh (files, entries, all) \'{" ".join(com)}\'') - clear_scr = False - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # List ================================================================= - elif (com[0].lower() == 'list' or com[0].lower() == 'ls') and len(com) > 1: - if com[1].lower() == 'entries': - for i, e in enumerate(self.lib.entries, start=0): - title = f'[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path}{os.path.sep}{self.lib.entries[i].filename}' - print(self.format_subtitle(title, color=self.get_file_color( - os.path.splitext(self.lib.entries[i].filename)[1]))) - self.print_fields(i) - print('') - time.sleep(0.05) - print('Press Enter to Continue...') - input() - elif com[1].lower() == 'new': - for i in self.lib.files_not_in_library: - print(i) - time.sleep(0.1) - print('Press Enter to Continue...') - input() - elif com[1].lower() == 'missing': - for i in self.lib.missing_files: - print(i) - time.sleep(0.1) - print('Press Enter to Continue...') - input() - elif com[1].lower() == 'fixed': - for i in self.lib.fixed_files: - print(i) - time.sleep(0.1) - print('Press Enter to Continue...') - input() - elif com[1].lower() == 'files' or com[1].lower() == 'dir': - # NOTE: This doesn't actually print the directory files, it just prints - # files that are attached to Entries. Should be made consistent. - # print(self.lib.file_to_entry_index_map.keys()) - for key in self.lib.filename_to_entry_id_map.keys(): - print(key) - time.sleep(0.05) - print('Press Enter to Continue...') - input() - elif com[1].lower() == 'duplicate' or com[1].lower() == 'dupe': - if len(com) > 2: - if com[2].lower() == 'entries' or com[2].lower() == 'e': - for dupe in self.lib.dupe_entries: - print( - self.lib.entries[dupe[0]].path + os.path.sep + self.lib.entries[dupe[0]].filename) - for d in dupe[1]: - print( - f'\t-> {(self.lib.entries[d].path + os.path.sep + self.lib.entries[d].filename)}') - time.sleep(0.1) - print('Press Enter to Continue...') - input() - elif com[2].lower() == 'files' or com[2].lower() == 'f': - for dupe in self.lib.dupe_files: - print(dupe) - time.sleep(0.1) - print('Press Enter to Continue...') - input() - elif com[1].lower() == 'tags': - self.scr_list_tags(tag_ids=self.lib.search_tags('')) - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # Top ====================================================== - # Tags ----------------------------------------------------- - elif com[0].lower() == 'top': - if len(com) > 1 and com[1].lower() == 'tags': - self.lib.count_tag_entry_refs() - self.scr_top_tags() - # Browse =========================================================== - elif (com[0].lower() == 'browse' or com[0].lower() == 'b'): - if len(com) > 1: - if com[1].lower() == 'entries': - self.filtered_entries = self.lib.search_library() - self.scr_browse_entries_gallery(0) - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - else: - self.filtered_entries = self.lib.search_library() - self.scr_browse_entries_gallery(0) - # Search =========================================================== - elif (com[0].lower() == 'search' or com[0].lower() == 's'): - if len(com) > 1: - self.filtered_entries = self.lib.search_library(' '.join(com[1:])) - self.scr_browse_entries_gallery(0) - else: - self.scr_browse_entries_gallery(0) - # self.scr_library_home(clear_scr=False) - # Add New Entries ================================================== - elif ' '.join(com) == 'add new': - 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: - self.lib.refresh_dir() - # self.is_new_file_count_init = False - new_ids: list[int] = self.lib.add_new_files_as_entries() - print( - f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') - for id in new_ids: - self.run_macro('autofill', id) - # print(f'{INFO} Scanning for files in \'{self.lib.library_dir}\' (This may take a while)...') - # self.lib.refresh_dir() - self.is_new_file_count_init = True - # self.scr_library_home() - # Fix ============================================================== - elif (com[0].lower() == 'fix') and len(com) > 1: - if com[1].lower() == 'missing': - subtitle = f'Fix Missing Files' - choices: list[(str, str)] = [ - ('Search with Manual & Automated Repair', - f"""Searches the Library directory ({self.lib.library_dir}) for files with the same name as the missing one(s), and automatically repairs Entries which only point to one matching file. If there are multiple filename matches for one Entry, a manual selection screen appears after any automatic repairing.\nRecommended if you moved files and don\'t have use strictly unique filenames in your Library directory."""), - ('Search with Automated Repair Only', - 'Same as above, only skipping the manual step.'), - ('Remove Entries', """Removes Entries from the Library which point to missing files.\nOnly use if you know why a file is missing, and/or don\'t wish to keep that Entry\'s data.""")] - prompt: str = 'Choose how you want to repair Entries that point to missing files.' - selection: int = self.scr_choose_option( - subtitle=subtitle, choices=choices, prompt=prompt) - - if selection >= 0 and not self.is_missing_count_init: - print( - f'{INFO} Checking for missing files in \'{self.lib.library_dir}\'...') - self.lib.refresh_missing_files() - - if selection == 0: - print( - f'{INFO} Attempting to resolve {len(self.lib.missing_files)} missing files in \'{self.lib.library_dir}\' (This will take long for several results)...') - self.lib.fix_missing_files() - - fixed_indices = [] - if self.lib.missing_matches: - clear() - 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 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]}') - self.lib.entries[self.lib.get_entry_id_from_filepath( - unresolved)].path = self.lib.missing_matches[unresolved][res] - fixed_indices.append(unresolved) - elif res and int(res) < 0: - clear() - print( - f'{INFO} Skipped match resolution selection..') - if self.args.external_preview: - self.set_external_preview_default() - self.lib.remove_missing_matches(fixed_indices) - elif selection == 1: - print( - f'{INFO} Attempting to resolve missing files in \'{self.lib.library_dir}\' (This may take a LOOOONG while)...') - self.lib.fix_missing_files() - elif selection == 2: - print( - f'{WARNING} Remove all Entries pointing to missing files? (Y/N)\n>{RESET} ', end='') - confirmation = input() - if confirmation.lower() == 'y' or confirmation.lower() == 'yes': - deleted = [] - for i, missing in enumerate(self.lib.missing_files): - print(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') - try: - id = self.lib.get_entry_id_from_filepath(missing) - print(f'Removing Entry ID {id}:\n\t{missing}') - self.lib.remove_entry(id) - self.driver.purge_item_from_navigation(ItemType.ENTRY, id) - deleted.append(missing) - except KeyError: - print( - f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') - for d in deleted: - self.lib.missing_files.remove(d) - # for missing in self.lib.missing_files: - # try: - # index = self.lib.get_entry_index_from_filename(missing) - # print(f'Removing Entry at Index [{index+1}/{len(self.lib.entries)}]:\n\t{missing}') - # self.lib.remove_entry(index) - # except KeyError: - # print( - # f'{ERROR} \"{index}\" was reported as missing, but is not in the file_to_entry_index map.') - - if selection >= 0: - print( - f'{INFO} Checking for missing files in \'{self.lib.library_dir}\'...') - self.lib.refresh_missing_files() - self.is_missing_count_init = True - - # Fix Duplicates =============================================================== - elif com[1].lower() == 'duplicate' or com[1].lower() == 'dupe': - if len(com) > 2: - # Fix Duplicate Entries ---------------------------------------------------- - if com[2].lower() == 'entries' or com[2].lower() == 'e': - - subtitle = f'Fix Duplicate Entries' - choices: list[(str, str)] = [ - ('Merge', - f'Each Entry pointing to the same file will have their data merged into a single remaining Entry.')] - prompt: str = 'Choose how you want to address groups of Entries which point to the same file.' - selection: int = self.scr_choose_option( - subtitle=subtitle, choices=choices, prompt=prompt) - - if selection == 0: - if self.is_dupe_entry_count_init: - print( - f'{WARNING} Are you sure you want to merge {len(self.lib.dupe_entries)} Entries? (Y/N)\n> ', end='') - else: - print( - f'{WARNING} Are you sure you want to merge any duplicate Entries? (Y/N)\n> ', end='') - confirmation = input() - if confirmation.lower() == 'y' or confirmation.lower() == 'yes': - if not self.is_dupe_entry_count_init: - print( - f'{INFO} Checking for duplicate entries in Library \'{self.lib.library_dir}\'...') - self.lib.refresh_dupe_entries() - self.lib.merge_dupe_entries() - self.is_dupe_entry_count_init = False - # Fix Duplicate Entries ---------------------------------------------------- - elif com[2].lower() == 'files' or com[2].lower() == 'f': - subtitle = f'Fix Duplicate Files' - choices: list[(str, str)] = [ - ('Mirror', - f"""For every predetermined duplicate file, mirror those files\' Entries with each other.\nMirroring involves merging all Entry field data together and then duplicating it across each Entry.\nThis process does not delete any Entries or files.""")] - prompt: str = """Choose how you want to address handling data for files considered to be duplicates by an application such as DupeGuru. It\'s recommended that you mirror data here, then manually delete the duplicate files based on your own best judgement. Afterwards run \"fix missing\" and choose the \"Remove Entries\" option.""" - selection: int = self.scr_choose_option( - subtitle=subtitle, choices=choices, prompt=prompt) - - if selection == 0: - if self.is_dupe_file_count_init: - print( - f'{WARNING} Are you sure you want to mirror Entry fields for {len(self.lib.dupe_files)} duplicate files? (Y/N)\n> ', end='') - else: - print( - f'{WARNING} Are you sure you want to mirror any Entry felids for duplicate files? (Y/N)\n> ', end='') - confirmation = input() - if confirmation.lower() == 'y' or confirmation.lower() == 'yes': - print( - f'{INFO} Mirroring {len(self.lib.dupe_files)} Entries for duplicate files...') - for i, dupe in enumerate(self.lib.dupe_files): - entry_id_1 = self.lib.get_entry_id_from_filepath( - dupe[0]) - entry_id_2 = self.lib.get_entry_id_from_filepath( - dupe[1]) - self.lib.mirror_entry_fields( - [entry_id_1, entry_id_2]) - clear() - else: - clear() - print( - f'{ERROR} Invalid duplicate type "{" ".join(com[2:])}".') - clear_scr = False - else: - clear() - print( - f'{ERROR} Specify which duplicates to fix (entries, files, etc) "{" ".join(com)}".') - clear_scr = False - else: - clear() - print( - f'{ERROR} Invalid fix selection "{" ".join(com[1:])}". Try "fix missing", "fix dupe entries", etc.') - clear_scr = False - # # Save to Disk ========================================================= - # elif com[0].lower() in ['save', 'write', 'w']: - # self.lib.save_library_to_disk() - # clear() - # print( - # f'{INFO} Library saved to disk.') - # clear_scr = False - # # Save Backup to Disk ========================================================= - # elif (com[0].lower() == 'backup'): - # self.backup_library() - # clear_scr = False - # Close ============================================================ - elif com[0].lower() == 'close' or com[0].lower() == 'c': - # self.core.clear_internal_vars() - self.close_library() - # clear() - return - # Unknown Command ================================================== - else: - clear() - print( - f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr=False - # self.scr_library_home(clear_scr=False) - - def scr_browse_entries_gallery(self, index, clear_scr=True, refresh=True): - """Gallery View for browsing Library Entries.""" - - branch = (' ('+VERSION_BRANCH + ')') if VERSION_BRANCH else '' - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - while True: - # try: - if refresh: - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title)) - - 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]) - filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' - # if self.lib.is_legacy_library: - # title += ' (Legacy Format)' - h1 = f'[{index + 1}/{len(self.filtered_entries)}] {filename}' - - # print(self.format_subtitle(subtitle)) - print(self.format_h1(h1, self.get_file_color( - os.path.splitext(filename)[1]))) - print('') - - if not os.path.isfile(filename): - print( - f'{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run \'fix missing\' to resolve){RESET}') - print('') - if self.args.external_preview: - self.set_external_preview_broken() - else: - self.print_thumbnail(self.filtered_entries[index][1]) - - self.print_fields(self.filtered_entries[index][1]) - else: - if self.lib.entries: - print(self.format_h1('No Entry Results for Query', color=BRIGHT_RED_FG)) - self.set_external_preview_default() - else: - print(self.format_h1('No Entries in Library', color=BRIGHT_RED_FG)) - self.set_external_preview_default() - print('') - - print('') - print(self.format_subtitle( - 'Prev Next Goto <#> Open File Search List Tags', BRIGHT_MAGENTA_FG)) - print(self.format_subtitle( - 'Add, Remove, Edit Remove Close Quit', BRIGHT_MAGENTA_FG)) - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - # except IndexError: - # clear() - # print(f'{INFO} No matches found for query') - # # self.scr_library_home(clear_scr=False) - # # clear_scr=False - # return - - # Previous ============================================================= - if (com[0].lower() == 'prev' or com[0].lower() == 'p' or com[0].lower() == 'previous'): - if len(com) > 1: - try: - # self.scr_browse_entries_gallery( - # (index - int(com[1])) % len(self.filtered_entries)) - # return - index = ( - index - int(com[1])) % len(self.filtered_entries) - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (IndexError, ValueError): - clear() - print( - f'{ERROR} Invalid \"Previous\" Index: \'{com[1]}\'') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - else: - # self.scr_browse_entries_gallery( - # (index - 1) % len(self.filtered_entries)) - # return - index = (index - 1) % len(self.filtered_entries) - # Next ================================================================= - elif (com[0].lower() == 'next' or com[0].lower() == 'n'): - if len(com) > 1: - try: - # NOTE: Will returning this as-is instead of after screw up the try-catch? - index = ( - index + int(com[1])) % len(self.filtered_entries) - # self.scr_browse_entries_gallery( - # (index + int(com[1])) % len(self.filtered_entries)) - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (IndexError, ValueError): - clear() - print(f'{ERROR} Invalid \"Next\" Index: \'{com[1]}\'') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - else: - # self.scr_browse_entries_gallery( - # (index + 1) % len(self.filtered_entries)) - # return - index = (index + 1) % len(self.filtered_entries) - # Goto ================================================================= - elif (com[0].lower() == 'goto' or com[0].lower() == 'g') and len(com) > 1: - try: - if int(com[1])-1 < 0: - raise IndexError - if int(com[1]) > len(self.filtered_entries): - raise IndexError - # self.scr_browse_entries_gallery(int(com[1])-1) - # return - index = int(com[1])-1 - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (IndexError, ValueError): - clear() - print(f'{ERROR} Invalid \"Goto\" Index: \'{com[1]}\'') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # Search =============================================================== - elif (com[0].lower() == 'search' or com[0].lower() == 's'): - if len(com) > 1: - self.filtered_entries = self.lib.search_library(' '.join(com[1:])) - # self.scr_browse_entries_gallery(0) - index = 0 - else: - self.filtered_entries = self.lib.search_library() - # self.scr_browse_entries_gallery(0) - index = 0 - # running = False - # return - # self.scr_library_home(clear_scr=False) - # return - # # Toggle Debug =========================================================== - # elif (com[0].lower() == 'toggle-debug'): - # self.args.debug = not self.args.debug - # Open with Default Application ======================================== - elif (com[0].lower() == 'open' or com[0].lower() == 'o'): - if len(com) > 1: - if com[1].lower() == 'location' or com[1].lower() == 'l': - open_file(filename, True) - else: - open_file(filename) - # refresh=False - # self.scr_browse_entries_gallery(index) - # Add Field ============================================================ - elif (com[0].lower() == 'add' or com[0].lower() == 'a'): - if len(com) > 1: - id_list = self.lib.filter_field_templates( - ' '.join(com[1:]).lower()) - if id_list: - final_ids = [] - if len(id_list) == 1: - final_ids.append(id_list[0]) - else: - final_ids = self.scr_select_field_templates( - id_list) - - for id in final_ids: - if id >= 0: - self.lib.add_field_to_entry( - self.filtered_entries[index][1], id) - # self.scr_browse_entries_gallery(index) - # return - # else: - # clear() - # print(f'{ERROR} Invalid selection.') - # return self.scr_browse_entries_gallery(index, clear_scr=False) - - else: - clear() - print(f'{INFO} Please specify a field to add.') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Remove Field ========================================================= - elif (com[0].lower() == 'remove' or com[0].lower() == 'rm'): - if len(com) > 1: - # entry_fields = self.lib.get_entry_from_index( - # self.filtered_entries[index]).fields - entry_fields = self.lib.get_entry(self.filtered_entries[index][1]).fields - field_indices: list[int] = [] - for i, f in enumerate(entry_fields): - if int(self.lib.get_field_attr(f, 'id')) in self.lib.filter_field_templates(' '.join(com[1:]).lower()): - field_indices.append(i) - - try: - final_field_index = -1 - # if len(field_indices) == 1: - # final_index = field_indices[0] - # NOTE: The difference between this loop and Edit is that it always asks - # you to specify the field, even if there is only one option. - if len(field_indices) >= 1: - print(field_indices) - print(entry_fields) - print([self.lib.get_field_attr( - entry_fields[x], 'id') for x in field_indices]) - final_field_index = field_indices[self.scr_select_field_templates( - [self.lib.get_field_attr( - entry_fields[x], 'id') for x in field_indices], - allow_multiple=False, - mode='remove', - return_index=True)[0]] - else: - clear() - print( - f'{ERROR} Entry does not contain the field \"{" ".join(com[1:])}\".') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except IndexError: - pass - - if final_field_index >= 0: - self.lib.get_entry(self.filtered_entries[index][1]).fields.pop( - final_field_index) - # self.lib.entries[self.filtered_entries[index]].fields.pop( - # final_field_index) - else: - clear() - print(f'{INFO} Please specify a field to remove.') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Edit Field =========================================================== - elif (com[0].lower() == 'edit' or com[0].lower() == 'e'): - if len(com) > 1: - # entry_fields = self.lib.get_entry_from_index( - # self.filtered_entries[index]).fields - entry_fields = self.lib.get_entry(self.filtered_entries[index][1]).fields - field_indices: list[int] = [] - for i, f in enumerate(entry_fields): - if int(self.lib.get_field_attr(f, 'id')) in self.lib.filter_field_templates(' '.join(com[1:]).lower()): - field_indices.append(i) - - try: - final_field_index = -1 - if len(field_indices) == 1: - final_field_index = field_indices[0] - elif len(field_indices) > 1: - print(field_indices) - print(entry_fields) - print([self.lib.get_field_attr( - entry_fields[x], 'id') for x in field_indices]) - final_field_index = field_indices[self.scr_select_field_templates( - [self.lib.get_field_attr( - entry_fields[x], 'id') for x in field_indices], - allow_multiple=False, - mode='edit', - return_index=True)[0]] - else: - clear() - print( - f'{ERROR} Entry does not contain the field \"{" ".join(com[1:])}\".') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except IndexError: - pass - - if final_field_index >= 0: - if self.lib.get_field_attr(entry_fields[final_field_index], 'type') == 'tag_box': - self.scr_edit_entry_tag_box( - self.filtered_entries[index][1], - field_index=final_field_index) - elif self.lib.get_field_attr(entry_fields[final_field_index], 'type') == 'text_line': - self.scr_edit_entry_text( - self.filtered_entries[index][1], - field_index=final_field_index, - allow_newlines=False) - elif self.lib.get_field_attr(entry_fields[final_field_index], 'type') == 'text_box': - self.scr_edit_entry_text( - self.filtered_entries[index][1], - field_index=final_field_index) - else: - clear() - print( - f'{INFO} Sorry, this type of field ({self.lib.get_field_attr(entry_fields[final_field_index], "type")}) isn\'t editable yet.') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - else: - clear() - print(f'{INFO} Please specify a field to edit.') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Copy Field =========================================================== - elif (com[0].lower() == 'copy' or com[0].lower() == 'cp'): - # NOTE: Nearly identical code to the Edit section. - if len(com) > 1: - # entry_fields = self.lib.get_entry_from_index( - # self.filtered_entries[index]).fields - entry_fields = self.lib.get_entry(self.filtered_entries[index][1]).fields - field_indices: list[int] = [] - for i, f in enumerate(entry_fields): - if int(self.lib.get_field_attr(f, 'id')) in self.lib.filter_field_templates(' '.join(com[1:]).lower()): - field_indices.append(i) - - # try: - final_field_index = -1 - if len(field_indices) == 1: - final_field_index = field_indices[0] - elif len(field_indices) > 1: - print(field_indices) - print(entry_fields) - print([self.lib.get_field_attr( - entry_fields[x], 'id') for x in field_indices]) - final_field_index = field_indices[self.scr_select_field_templates( - [self.lib.get_field_attr( - entry_fields[x], 'id') for x in field_indices], - allow_multiple=False, - mode='edit', - return_index=True)[0]] - else: - clear() - print( - f'{ERROR} Entry does not contain the field \"{" ".join(com[1:])}\".') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - # except: - # pass - - if final_field_index >= 0: - self.copy_field_to_buffer( - entry.fields[final_field_index]) - # refresh = False - else: - clear() - print(f'{INFO} Please specify a field to copy.') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # self.scr_browse_entries_gallery(index) - # return - # Paste Field =========================================================== - elif (com[0].lower() == 'paste' or com[0].lower() == 'ps'): - self.paste_field_from_buffer(self.filtered_entries[index][1]) - # self.scr_browse_entries_gallery(index) - # return - # Run Macro ============================================================ - elif len(com) > 1 and com[0].lower() == 'run': - if len(com) > 2 and com[1].lower() == 'macro': - macro_name = (com[2]).lower() - if len(com) > 3: - # Run on all filtered Entries - if com[-1].lower() == '--all' or com[-1].lower() == '-a': - clear() - print( - f'{INFO} Running Macro \"{macro_name}\" on {len(self.filtered_entries)} Entries...') - for type, id in self.filtered_entries: - self.run_macro(name=macro_name, entry_id=id) - # self.scr_browse_entries_gallery(index) - else: - # Run on current Entry - self.run_macro( - name=macro_name, - entry_id=self.filtered_entries[index][1]) - # self.scr_browse_entries_gallery(index) - # return - else: - clear() - print( - f'{ERROR} Please specify a Macro to run.') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - # List Tags ============================================================ - elif (com[0].lower() == 'list' or com[0].lower() == 'ls') and len(com) > 1: - if com[1].lower() == 'tags': - clear() - self.scr_list_tags(tag_ids=self.lib.search_tags('')) - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr = False - # self.scr_browse_entries_gallery(index, clear_scr=False) - - # return - # # Save to Disk ========================================================= - # elif (com[0].lower() == 'save' or com[0].lower() == 'write' or com[0].lower() == 'w'): - # self.lib.save_library_to_disk() - # clear() - # print( - # f'{INFO} Library saved to disk.') - # # self.scr_browse_entries_gallery(index, clear_scr=False) - # clear_scr = False - # # return - # # Save Backup to Disk ========================================================= - # elif (com[0].lower() == 'backup'): - # clear() - # self.backup_library() - # clear_scr = False - # Close View =========================================================== - elif (com[0].lower() == 'close' or com[0].lower() == 'c'): - if self.args.external_preview: - self.set_external_preview_default() - # self.scr_library_home() - clear() - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - elif com: - clear() - print( - f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_browse_entries_gallery(index, clear_scr=False) - clear_scr = False - # return - - def scr_choose_option(self, subtitle: str, choices: list, prompt: str = '', required=False, clear_scr=True) -> int: - """ - Screen for choosing one of a given set of generic options. - Takes in a list of (str,str) tuples which consist of (option name, option description), - with the description being optional. - Returns the index of the selected choice (starting at 0), or -1 if the choice was '0', 'Cancel', or 'C'. - """ - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - # invalid_input: bool = False - - while True: - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title( - title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - # if invalid_input: - # print(self.format_h1( - # str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) - # invalid_input = False - print('') - if prompt: - print(prompt) - print('') - print('') - - for i, choice in enumerate(choices, start=1): - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG}[{str(i).zfill(len(str(len(choices))))}]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} {choice[0]} {RESET}') - if choice[1]: - print(f'{WHITE_FG}{choice[1]}{RESET}') - print('') - - if not required: - print('') - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG}[0]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} Cancel {RESET}') - - print('') - if not required: - print(self.format_subtitle( - '<#> 0 or Cancel Quit', BRIGHT_CYAN_FG)) - else: - print(self.format_subtitle( - '<#> Quit', BRIGHT_CYAN_FG)) - print('> ', end='') - - com: list[str] = input().strip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - com_name = com[0].lower() - - try: - # # Quit ========================================================= - # if com.lower() == 'quit' or com.lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ========================================== - # elif com.lower() == 'quit!' or com.lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Cancel ======================================================= - if com_name in ('cancel', 'c', '0') and not required: - clear() - return -1 - # Selection ==================================================== - elif com_name.isdigit() and 0 < int(com_name) <= len(choices): - clear() - return int(com_name) - 1 - else: - # invalid_input = True - # print(self.format_h1(str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) - clear() - print(f'{ERROR} Please Enter a Valid Selection Number/Option.') - clear_scr=False - except (TypeError, ValueError): - clear() - print(f'{ERROR} Please Enter a Valid Selection Number/Option.') - clear_scr=False - - def scr_choose_missing_match(self, index, clear_scr=True, refresh=True) -> int: - """ - Screen for manually resolving a missing file. - Returns the index of the choice made (starting at 0), or -1 if skipped. - """ - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - subtitle = f'Resolve Missing File Conflict' - - while True: - entry = self.lib.get_entry_from_index(index) - filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' - - if refresh: - if clear_scr: - clear() - clear_scr = True - print(self.format_title( - title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - print('') - print(self.format_h1(filename, BRIGHT_RED_FG), end='\n\n') - - self.print_fields(index) - - for i, match in enumerate(self.lib.missing_matches[filename]): - print(self.format_h1(f'[{i+1}] {match}'), end='\n\n') - fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry.filename)}' - self.print_thumbnail(index=-1, filepath=fn, - max_width=(os.get_terminal_size()[1]//len(self.lib.missing_matches[filename])-2)) - if fn in self.lib.filename_to_entry_id_map.keys(): - self.print_fields( - self.lib.get_entry_id_from_filepath(fn)) - print('') - print(self.format_subtitle( - '<#> 0 to Skip Open Files Quit', BRIGHT_CYAN_FG)) - print('> ', end='') - - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - - # Refresh ============================================================== - if (com[0].lower() == 'refresh' or com[0].lower() == 'r'): - # if (com[0].lower() == 'refresh' or com[0].lower() == 'r') and len(com) > 1: - # if com[1].lower() == 'files' or com[1].lower() == 'dir': - # clear() - # return self.scr_choose_missing_match(index) - # else: - # clear() - # print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_library_home(clear_scr=False) - # clear_scr=False - pass - # Open ============================================================= - elif (com[0].lower() == 'open' or com[0].lower() == 'o'): - for match in self.lib.missing_matches[filename]: - fn = os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry.filename) - open_file(fn) - refresh = False - # clear() - # return self.scr_choose_missing_match(index, clear_scr=False) - # # Quit ============================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ============================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Selection/Other ================================================== - else: - try: - i = int(com[0]) - 1 - if i < len(self.lib.missing_matches[filename]): - if i < -1: - return -1 - else: - return i - else: - raise IndexError - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (ValueError, IndexError): - clear() - print(f'{ERROR} Invalid command \'{" ".join(com)}\'') - # return self.scr_choose_missing_match(index, clear_scr=False) - clear_scr = False - - def scr_resolve_dupe_files(self, index, clear_scr=True): - """Screen for manually resolving duplicate files.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - subtitle = f'Resolve Duplicate Files' - - while True: - dupe = self.lib.dupe_files[index] - - if os.path.exists(os.path.normpath(f'{dupe[0]}')) and os.path.exists(os.path.normpath(f'{dupe[1]}')): - # entry = self.lib.get_entry_from_index(index_1) - entry_1_index = self.lib.get_entry_id_from_filepath(dupe[0]) - entry_2_index = self.lib.get_entry_id_from_filepath(dupe[1]) - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title( - title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - - print('') - print(f'{WHITE_BG}{BLACK_FG} Similarity: {RESET} ', end='') - print(f'{dupe[2]}%') - - # File 1 - print('') - print(self.format_h1(dupe[0], BRIGHT_RED_FG), end='\n\n') - print(f'{WHITE_BG}{BLACK_FG} File Size: {RESET} ', end='') - print(f'0 KB') - print(f'{WHITE_BG}{BLACK_FG} Resolution: {RESET} ', end='') - print(f'0x0') - if entry_1_index is not None: - print('') - self.print_fields(entry_1_index) - else: - print(f'{BRIGHT_RED_FG}No Library Entry for file.{RESET}') - - # File 2 - print('') - print(self.format_h1(dupe[1], BRIGHT_RED_FG), end='\n\n') - print(f'{WHITE_BG}{BLACK_FG} File Size: {RESET} ', end='') - print(f'0 KB') - print(f'{WHITE_BG}{BLACK_FG} Resolution: {RESET} ', end='') - print(f'0x0') - if entry_2_index is not None: - print('') - self.print_fields(entry_2_index) - else: - print(f'{BRIGHT_RED_FG}No Library Entry for file.{RESET}') - - # for i, match in enumerate(self.lib.missing_matches[filename]): - # print(self.format_h1(f'[{i+1}] {match}'), end='\n\n') - # fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry_1.filename)}' - # self.print_thumbnail(self.lib.get_entry_from_filename(fn), - # max_width=(os.get_terminal_size()[1]//len(self.lib.missing_matches[filename])-2)) - # self.print_fields(self.lib.get_entry_from_filename(fn)) - print('') - print(self.format_subtitle( - 'Mirror Delete <#> Skip Close Open Files Quit', BRIGHT_CYAN_FG)) - print('> ', end='') - - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - - # Refresh ========================================================== - if (com[0].lower() == 'refresh' or com[0].lower() == 'r') and len(com) > 1: - # if com[1].lower() == 'files' or com[1].lower() == 'dir': - # clear() - # return self.scr_resolve_dupe_files(index, clear_scr=True) - # else: - # clear() - # print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_library_home(clear_scr=False) - pass - # Open ============================================================= - elif (com[0].lower() == 'open' or com[0].lower() == 'o'): - # for match in self.lib.missing_matches[filename]: - # fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry_1.filename)}' - # open_file(fn) - open_file(dupe[0]) - open_file(dupe[1]) - # clear() - # return self.scr_resolve_dupe_files(index, clear_scr=False) - # Mirror Entries =================================================== - elif (com[0].lower() == 'mirror' or com[0].lower() == 'mir'): - return com - # Skip ============================================================ - elif (com[0].lower() == 'skip'): - return com - # Skip ============================================================ - elif (com[0].lower() == 'close' or com[0].lower() == 'cancel' or com[0].lower() == 'c'): - return ['close'] - # Delete =========================================================== - elif (com[0].lower() == 'delete' or com[0].lower() == 'del'): - if len(com) > 1: - if com[1] == '1': - return ['del', 1] - elif com[1] == '2': - return ['del', 2] - else: - # return self.scr_resolve_dupe_files(index) - pass - else: - clear() - print( - f'{ERROR} Please specify which file (ex. delete 1, delete 2) to delete file.') - # return self.scr_resolve_dupe_files(index, clear_scr=False) - clear_scr=False - # # Quit ============================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ============================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Other ============================================================ - else: - # try: - # i = int(com[0]) - 1 - # if i < len(self.lib.missing_matches[filename]): - # return i - # else: - # raise IndexError - # except SystemExit: - # sys.exit() - # except: - clear() - print(f'{ERROR} Invalid command \'{" ".join(com)}\'') - # return self.scr_resolve_dupe_files(index, clear_scr=False) - clear_scr=False - - def scr_edit_entry_tag_box(self, entry_index, field_index, clear_scr=True): - """Screen for editing an Entry tag-box field.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - entry = self.lib.entries[entry_index] - filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' - field_name = self.lib.get_field_attr( - entry.fields[field_index], 'name') - subtitle = f'Editing \"{field_name}\" Field' - h1 = f'{filename}' - - while True: - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - print(self.format_h1(h1, self.get_file_color( - os.path.splitext(filename)[1]))) - print('') - - if not os.path.isfile(filename): - print( - f'{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run \'fix missing\' to resolve){RESET}') - print('') - else: - self.print_thumbnail(entry_index) - - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG} {field_name}: {RESET} ') - for i, tag_id in enumerate(entry.fields[field_index][list(entry.fields[field_index].keys())[0]]): - tag = self.lib.get_tag(tag_id) - print( - f'{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}') - # if tag_id != field[field_id][-1]: - # print(' ', end='') - print('') - - print(self.format_subtitle( - 'Add Remove <#> Open File Close/Done Quit')) - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - - # Open with Default Application ======================================== - if (com[0].lower() == 'open' or com[0].lower() == 'o'): - open_file(filename) - # self.scr_edit_entry_tag_box(entry_index, field_index) - # return - # Close View =========================================================== - elif (com[0].lower() == 'close' or com[0].lower() == 'c' or com[0].lower() == 'done'): - # self.scr_browse_entries_gallery() - clear() - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Add Tag ============================================================== - elif com[0].lower() == 'add': - if len(com) > 1: - tag_list = self.lib.search_tags( - ' '.join(com[1:]), include_cluster=True) - t: list[int] = [] - if len(tag_list) > 1: - t = self.scr_select_tags(tag_list) - else: - t = tag_list # Single Tag - if t: - self.lib.update_entry_field(entry_index, - field_index, - content=t, - mode='append') - # self.scr_edit_entry_tag_box(entry_index, field_index) - # return - # Remove Tag =========================================================== - elif com[0].lower() == 'remove' or com[0].lower() == 'rm': - if len(com) > 1: - try: - selected_tag_ids: list[int] = [] - for c in com[1:]: - if (int(c)-1) < 0: - raise IndexError - # print(self.lib.get_field_attr(entry.fields[field_index], 'content')) - # print(self.lib.get_field_attr(entry.fields[field_index], 'content')[int(c)-1]) - selected_tag_ids.append(self.lib.get_field_attr( - entry.fields[field_index], 'content')[int(c)-1]) - # i = int(com[1]) - 1 - - # tag = entry.fields[field_index][list( - # entry.fields[field_index].keys())[0]][i] - self.lib.update_entry_field(entry_index, - field_index, - content=selected_tag_ids, - mode='remove') - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except: - clear() - print(f'{ERROR} Invalid Tag Selection \'{com[1:]}\'') - clear_scr=False - # self.scr_edit_entry_tag_box( - # entry_index, field_index, clear_scr=False) - # return - # self.scr_edit_entry_tag_box(entry_index, field_index) - # return - # Unknown Command ====================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_edit_entry_tag_box( - # entry_index, field_index, clear_scr=False) - # return - clear_scr=False - - def scr_select_tags(self, tag_ids: list[int], clear_scr=True) -> list[int]: - """Screen for selecting and returning one or more Tags. Used for Entry editing.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - subtitle = f'Select Tag(s) to Add' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f'{BLACK_FG}{BRIGHT_GREEN_BG}')) - print(self.format_subtitle(subtitle, BRIGHT_GREEN_FG)) - # print(self.format_h1(h1, self.get_file_color( - # os.path.splitext(filename)[1]))) - print('') - - tag_tuple_list = [] - for tag_id in tag_ids: - tag = self.lib.get_tag(tag_id) - tag_tuple_list.append( - (tag.display_name(self.lib), self.get_tag_color(tag.color))) - - self.print_columns(tag_tuple_list, add_enum=True) - print('') - - print(self.format_subtitle( - 'Enter #(s) Cancel', BRIGHT_GREEN_FG)) - print('> ', end='') - - com: list[str] = input().rstrip().split(' ') - selected_ids: list[int] = [] - try: - for c in com: - selected_ids.append(tag_ids[int(c)-1]) - except SystemExit: - self.cleanup_before_exit() - sys.exit() - except: - print(f'{ERROR} Invalid Tag Selection') - - return selected_ids - - # TODO: This can be replaced by the new scr_choose_option method. - def scr_select_field_templates(self, field_ids: list[int], allow_multiple=True, mode='add', return_index=False, clear_scr=True) -> list[int]: - """ - Screen for selecting and returning one or more Field Templates. Used for Entry editing. - Allow Multiple: Lets the user select multiple items, returned in a list. If false, returns a list of only the first selected item. - Mode: 'add', 'edit', 'remove' - Changes prompt text and colors. - Return Index: Instead of returning the Field IDs that were selected, this returns the indices of the selected items from the given list. - """ - - branch = (' ('+VERSION_BRANCH + ')') if VERSION_BRANCH else '' - title = f'TagStudio {VERSION}{branch} - CLI Mode - Library \'{self.lib.library_dir}\'' - subtitle = f'Select Field(s) to Add' - plural = '(s)' - - if not allow_multiple: - plural = '' - - fg_text_color = BLACK_FG - fg_color = BRIGHT_GREEN_FG - bg_color = BRIGHT_GREEN_BG - if mode == 'edit': - fg_color = BRIGHT_CYAN_FG - bg_color = BRIGHT_CYAN_BG - subtitle = f'Select Field{plural} to Edit' - elif mode == 'remove': - fg_color = BRIGHT_RED_FG - bg_color = BRIGHT_RED_BG - # fg_text_color = BRIGHT_WHITE_FG - subtitle = f'Select Field{plural} to Remove' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f'{fg_text_color}{bg_color}')) - print(self.format_subtitle(subtitle, fg_color)) - # print(self.format_h1(h1, self.get_file_color( - # os.path.splitext(filename)[1]))) - print('') - - for i, field_id in enumerate(field_ids): - name = self.lib.get_field_obj(field_id)['name'] - type = self.lib.get_field_obj(field_id)['type'] - if i < (os.get_terminal_size()[1] - 7): - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG}[{i+1}]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} {name} ({type}) {RESET}') - else: - print(f'{WHITE_FG}[...]{RESET}') - break - print('') - - print(self.format_subtitle( - f'Enter #{plural} Cancel', fg_color)) - print('> ', end='') - - com: list[str] = input().split(' ') - selected_ids: list[int] = [] - try: - for c in com: - if int(c) > 0: - if return_index: - selected_ids.append(int(c)-1) - else: - selected_ids.append(field_ids[int(c)-1]) - except SystemExit: - self.cleanup_before_exit() - sys.exit() - except: - print(f'{ERROR} Invalid Tag Selection') - - if not allow_multiple and selected_ids: - return [selected_ids[0]] - return selected_ids - - def scr_edit_entry_text(self, entry_index, field_index, allow_newlines=True, clear_scr=True): - """Screen for editing an Entry text_line field.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - entry = self.lib.entries[entry_index] - filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' - field_name = self.lib.get_field_attr( - entry.fields[field_index], 'name') - subtitle = f'Editing \"{field_name}\" Field' - h1 = f'{filename}' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - print(self.format_h1(h1, self.get_file_color( - os.path.splitext(filename)[1]))) - print('') - - if not os.path.isfile(filename): - print( - f'{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run \'fix missing\' to resolve){RESET}') - print('') - else: - self.print_thumbnail(entry_index, ignore_fields=True) - - print(self.format_title('Opened with Default Text Editor', - f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - # print('') - # print( - # f'{BRIGHT_WHITE_BG}{BLACK_FG} {field_name}: {RESET} ') - # print(self.lib.get_field_attr(entry.fields[field_index], 'content')) - # for i, tag_id in enumerate(entry.fields[field_index][list(entry.fields[field_index].keys())[0]]): - # tag = self.lib.get_tag_from_id(tag_id) - # print( - # f'{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}') - # print('') - - # print(self.format_subtitle( - # 'Add Remove <#> Open File Close/Done Quit')) - - # new_content: str = click.edit(self.lib.get_field_attr( - # entry.fields[field_index], 'content')) - new_content: str = '' # NOTE: Removing - if new_content is not None: - if not allow_newlines: - new_content = new_content.replace('\r', '').replace('\n', '') - 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] = 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}\'' - - - while True: - h1 = f'{len(self.lib.tags)} Tags' - - if tag_ids: - if len(tag_ids) < len(self.lib.search_tags('')): - h1 = f'[{len(tag_ids)}/{len(self.lib.tags)}] Tags' - if query: - h1 += f' connected to \'{query}\'' - else: - h1 = f'No Tags' - if query: - h1 += f' connected to \'{query}\'' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title)) - print(self.format_h1(h1)) - print('') - - tag_tuple_list = [] - for tag_id in tag_ids: - tag = self.lib.get_tag(tag_id) - if self.args.debug: - tag_tuple_list.append( - (tag.debug_name(), self.get_tag_color(tag.color))) - else: - tag_tuple_list.append( - (tag.display_name(self.lib), self.get_tag_color(tag.color))) - - self.print_columns(tag_tuple_list, add_enum=True) - - print('') - print(self.format_subtitle( - 'Create Edit <#> Delete <#> Search Close/Done', BRIGHT_MAGENTA_FG)) - print('> ', end='') - - com: list[str] = input().strip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - com_name = com[0].lower() - # Search Tags ========================================================== - 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, - # tag_ids=self.lib.filter_tags(new_query, include_cluster=True)) - query=new_query - tag_ids=self.lib.search_tags(new_query, include_cluster=True) - # return - else: - # self.scr_list_tags(prev_scr, tag_ids=self.lib.filter_tags('')) - tag_ids=self.lib.search_tags('') - # return - # Edit Tag =========================================================== - elif com_name in ('edit', 'e'): - if len(com) > 1: - try: - index = int(com[1]) - 1 - if index < 0: - raise IndexError - self.scr_manage_tag(tag_ids[index]) - - # Refilter in case edits change results - tag_ids=self.lib.search_tags(query, include_cluster=True) - # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except (ValueError, IndexError): - clear() - print(f'{ERROR} Invalid Selection \'{" ".join(com[1])}\'') - clear_scr=False - # self.scr_list_tags(prev_scr, query=query, - # tag_ids=tag_ids, clear_scr=False) - # return - - # Create Tag ============================================================ - elif com_name in ('create', 'mk'): - tag = Tag(id=0, name='New Tag', shorthand='', - aliases=[], subtags_ids=[], color='') - self.scr_manage_tag( - self.lib.add_tag_to_library(tag), mode='create') - - tag_ids=self.lib.search_tags(query, include_cluster=True) - - # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) - # return - # Delete Tag =========================================================== - elif com_name in ('delete', 'del'): - if len(com) > 1: - if len(com) > 1: - try: - index = int(com[1]) - 1 - if index < 0: - raise IndexError - deleted = self.scr_delete_tag(tag_ids[index]) - if deleted: - tag_ids.remove(tag_ids[index]) - tag_ids=self.lib.search_tags(query, include_cluster=True) - # self.scr_list_tags( - # prev_scr, query=query, tag_ids=tag_ids) - # return - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except IndexError: - clear() - print( - f'{ERROR} Invalid Selection \'{" ".join(com[1])}\'') - clear_scr=False - # self.scr_list_tags(prev_scr, query=query, - # tag_ids=tag_ids, clear_scr=False) - # return - # Close View =========================================================== - elif com_name in ('close', 'c', 'done'): - # prev_scr() - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - else: - clear() - print( - f'{ERROR} Unknown command \'{" ".join(com)}\'') - # self.scr_list_tags(prev_scr, query=query, - # tag_ids=tag_ids, clear_scr=False) - # return - clear_scr=False - - - def scr_top_tags(self, clear_scr=True) -> None: - """A screen that lists out the top tags for the library.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - while True: - h1 = f'Top Tags' - - # if tag_ids: - # if len(tag_ids) < len(self.lib.filter_tags('')): - # h1 = f'[{len(tag_ids)}/{len(self.lib.tags)}] Tags' - # if query: - # h1 += f' connected to \'{query}\'' - # else: - # h1 = f'No Tags' - # if query: - # h1 += f' connected to \'{query}\'' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title)) - print(self.format_h1(h1)) - print('') - - tag_tuple_list = [] - for tag_id, count in self.lib.tag_entry_refs: - tag = self.lib.get_tag(tag_id) - if self.args.debug: - tag_tuple_list.append( - (f'{tag.debug_name()} - {count}', self.get_tag_color(tag.color))) - else: - tag_tuple_list.append( - (f'{tag.display_name(self.lib)} - {count}', self.get_tag_color(tag.color))) - - self.print_columns(tag_tuple_list, add_enum=True) - - print('') - print(self.format_subtitle( - 'Close/Done', BRIGHT_MAGENTA_FG)) - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - # Close View =================================================== - if (com[0].lower() == 'close' or com[0].lower() == 'c' or com[0].lower() == 'done'): - return - # Unknown Command ============================================== - elif com[0]: - clear() - print( - f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr=False - - - - def scr_manage_tag(self, tag_id: int, mode='edit', clear_scr=True): - """Screen for editing fields of a Tag object.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - while True: - tag: Tag = self.lib.get_tag(tag_id) - subtitle = f'Editing Tag \"{self.lib.get_tag(tag_id).display_name(self.lib)}\"' - # h1 = f'{self.lib.tags[tag_index].display_name()}' - - fg_text_color = BLACK_FG - fg_color = BRIGHT_CYAN_FG - bg_color = BRIGHT_CYAN_BG - if mode == 'create': - subtitle = f'Creating Tag \"{self.lib.get_tag(tag_id).display_name(self.lib)}\"' - fg_color = BRIGHT_GREEN_FG - bg_color = BRIGHT_GREEN_BG - # elif mode == 'remove': - # # TODO: Uhh is this ever going to get used? Delete this when you know. - # subtitle = f'Removing Tag \"{self.lib.get_tag_from_id(tag_id).display_name(self.lib)}\"' - # fg_color = BRIGHT_RED_FG - # bg_color = BRIGHT_RED_BG - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f'{fg_text_color}{bg_color}')) - print(self.format_subtitle(subtitle, fg_color)) - # print(self.format_h1(h1, self.get_file_color( - # os.path.splitext(filename)[1]))) - if self.args.debug: - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} ID: {RESET} ', end='') - print(tag.id) - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Name: {RESET} ', end='') - print(tag.name) - - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Shorthand: {RESET} ', end='') - print(tag.shorthand) - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Aliases: {RESET} ', end='\n') - for a in tag.aliases: - print(f'{a}') - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Subtags: {RESET} ', end='\n') - char_count: int = 0 - for id in tag.subtag_ids: - st = self.lib.get_tag(id) - # Properly wrap Tags on screen - char_count += len(f' {st.display_name(self.lib)} ') + 1 - if char_count > os.get_terminal_size()[0]: - print('') - char_count = len(f' {st.display_name(self.lib)} ') + 1 - print( - f'{self.get_tag_color(st.color)} {st.display_name(self.lib)} {RESET}', end='') - # If the tag isn't the last one, print a space for the next one. - if id != tag.subtag_ids[-1]: - print(' ', end='') - else: - print('') - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Color: {RESET} ', end='') - print(f'{self.get_tag_color(tag.color)} {tag.color.title()} {RESET}') - - print('') - print(self.format_subtitle( - 'Edit Close/Done', fg_color)) - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - - # Edit Tag Field ======================================================= - if (com[0].lower() == 'edit' or com[0].lower() == 'e'): - if len(com) > 1: - selection: str = ' '.join(com[1:]).lower() - if 'id'.startswith(selection) and self.args.debug: - clear() - print( - f'{ERROR} Tag IDs are not editable.') - clear_scr=False - elif 'name'.startswith(selection): - new_name: str = self.scr_edit_text( - text=tag.name, - field_name='Name', - allow_newlines=False) - new_tag: Tag = Tag( - id=tag.id, - name=new_name, - shorthand=tag.shorthand, - aliases=tag.aliases, - subtags_ids=tag.subtag_ids, - color=tag.color) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif 'shorthand'.startswith(selection): - new_shorthand: str = self.scr_edit_text( - text=tag.shorthand, - field_name='Shorthand', - allow_newlines=False) - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=new_shorthand, - aliases=tag.aliases, - subtags_ids=tag.subtag_ids, - color=tag.color) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif 'aliases'.startswith(selection): - new_aliases: list[str] = self.scr_edit_text( - text='\n'.join(tag.aliases), - field_name='Aliases', - note=f'# Tag Aliases Below Are Separated By Newlines', - allow_newlines=True).split('\n') - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - aliases=new_aliases, - subtags_ids=tag.subtag_ids, - color=tag.color) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif 'subtags'.startswith(selection): - new_subtag_ids: list[int] = self.scr_edit_generic_tag_box( - tag_ids=tag.subtag_ids, - tag_box_name='Subtags' - ) - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - aliases=tag.aliases, - subtags_ids=new_subtag_ids, - color=tag.color) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - elif 'color'.startswith(selection): - new_color: str = self.scr_tag_color_dropdown( - fallback=tag.color, - colors=TAG_COLORS) - new_tag: Tag = Tag( - id=tag.id, - name=tag.name, - shorthand=tag.shorthand, - aliases=tag.aliases, - subtags_ids=tag.subtag_ids, - color=new_color) - self.lib.update_tag(new_tag) - # self.scr_manage_tag(tag_id=tag_id, mode=mode) - # return - # clear_scr=False - else: - clear() - print( - f'{ERROR} Unknown Tag field \"{" ".join(com[1:])}\".') - # self.scr_manage_tag(tag_id, mode, clear_scr=False) - # return - clear_scr=False - # Close View =========================================================== - elif (com[0].lower() == 'close' or com[0].lower() == 'done' or com[0].lower() == 'c'): - return - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - else: - clear() - print( - f'{ERROR} Unknown command \'{" ".join(com)}\'') - clear_scr=False - # return self.scr_browse_entries_gallery(index, clear_scr=False) - - def scr_delete_tag(self, tag_id: int, clear_scr=True) -> bool: - """Screen for confirming the deletion of a Tag.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - tag: Tag = self.lib.get_tag(tag_id) - subtitle = f'Confirm Deletion of Tag \"{self.lib.get_tag(tag_id).display_name(self.lib)}\"' - # h1 = f'{self.lib.tags[tag_index].display_name()}' - entry_ref_count, subtag_ref_count = self.lib.get_tag_ref_count(tag_id) - - fg_text_color = BLACK_FG - fg_color = BRIGHT_RED_FG - bg_color = BRIGHT_RED_BG - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f'{fg_text_color}{bg_color}')) - print(self.format_subtitle(subtitle, fg_color)) - print('') - - print(f'{INFO} {BRIGHT_WHITE_FG}This Tag is in {fg_color}{entry_ref_count}{RESET}{BRIGHT_WHITE_FG} Entries{RESET} ', end='') - print('') - - print(f'{INFO} {BRIGHT_WHITE_FG}This Tag is a Subtag for {fg_color}{subtag_ref_count}{RESET}{BRIGHT_WHITE_FG} Tags{RESET} ', end='') - print('') - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Name: {RESET} ', end='') - print(tag.name) - - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Shorthand: {RESET} ', end='') - print(tag.shorthand) - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Aliases: {RESET} ', end='\n') - for a in tag.aliases: - print(f'{a}') - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Subtags: {RESET} ', end='\n') - char_count: int = 0 - for id in tag.subtag_ids: - st = self.lib.get_tag(id) - # Properly wrap Tags on screen - char_count += len(f' {st.display_name(self.lib)} ') + 1 - if char_count > os.get_terminal_size()[0]: - print('') - char_count = len(f' {st.display_name(self.lib)} ') + 1 - print( - f'{self.get_tag_color(st.color)} {st.display_name(self.lib)} {RESET}', end='') - # If the tag isn't the last one, print a space for the next one. - if id != tag.subtag_ids[-1]: - print(' ', end='') - else: - print('') - - print('') - print(f'{BRIGHT_WHITE_BG}{BLACK_FG} Color: {RESET} ', end='') - print(f'{self.get_tag_color(tag.color)} {tag.color.title()} {RESET}') - - print('') - print(self.format_subtitle( - 'Yes Cancel', fg_color)) - print('> ', end='') - - com: str = input().rstrip() - - if com.lower() == 'yes' or com.lower() == 'y': - self.lib.remove_tag(tag_id) - return True - - return False - - def scr_edit_text(self, text: str, field_name: str, note: str = '', allow_newlines=True, clear_scr=True) -> str: - """ - Screen for editing generic text. Currently used in Tag editing.\n - `text`: The text to be edited and returned.\n - `field_name`: The name to display of what is being edited.\n - `note`: An optional help message to display on screen for users..\n - `allow_newlines`: Determines if the text should be allowed to contain newlines.\n - """ - # NOTE: This code is derived from scr_edit_entry_text, just without the - # specific entry stuff like filenames and preview images. There may be - # a good way to combine the methods in the future, but for now here's this. - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - subtitle = f'Editing \"{field_name}\"' - - if clear_scr: - clear() - clear_scr = True - print(self.format_title(title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - print('') - - print(self.format_title('Opened with Default Text Editor', - f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - - # new_text: str = click.edit(text) - new_text: str = input() - if new_text is not None: - if not allow_newlines: - new_text = new_text.replace('\r', '').replace('\n', '') - else: - new_text = new_text.rstrip('\n').rstrip('\r') - return new_text - return text - - def scr_tag_color_dropdown(self, fallback: str, colors: list[str], clear_scr=True) -> str: - """ - Screen for selecting and returning a string of a color name. Used in Tag editing. - Fallback: The value to return if an invalid selection by the user was made. - """ - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - subtitle = f'Select Color' - - fg_text_color = BLACK_FG - fg_color = BRIGHT_CYAN_FG - bg_color = BRIGHT_CYAN_BG - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f'{fg_text_color}{bg_color}')) - print(self.format_subtitle(subtitle, fg_color)) - print('') - - color_tuple_list = [] - for color in colors: - color_tuple_list.append((color.title(), self.get_tag_color(color))) - - self.print_columns(color_tuple_list, add_enum=True) - print('') - - # for i, color in enumerate(colors): - # if i < (os.get_terminal_size()[1] - 7): - # print( - # f'{self.get_tag_color(color)}[{i+1}]{RESET} {self.get_tag_color(color)} {color.title()} {RESET}') - # else: - # print(f'{WHITE_FG}[...]{RESET}') - # break - # print('') - - print(self.format_subtitle( - f'Enter # Cancel', fg_color)) - print('> ', end='') - - selected: str = input() - try: - if selected.isdigit() and 0 < int(selected) <= len(colors): - selected = colors[int(selected)-1] - return selected - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except: - print(f'{ERROR} Invalid Tag Selection') - - return fallback - - def scr_edit_generic_tag_box(self, tag_ids: list[int], tag_box_name: str, clear_scr=True) -> list[int]: - """Screen for editing a generic tag_box. Used in Tag subtag modification.""" - - title = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - - while True: - subtitle = f'Editing {tag_box_name}' - - if clear_scr: - clear() - clear_scr = True - - print(self.format_title(title, color=f'{BLACK_FG}{BRIGHT_CYAN_BG}')) - print(self.format_subtitle(subtitle)) - print('') - - print( - f'{BRIGHT_WHITE_BG}{BLACK_FG} {tag_box_name}: {RESET} ') - for i, id in enumerate(tag_ids): - tag = self.lib.get_tag(id) - print( - f'{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}') - print('') - - print(self.format_subtitle( - 'Add Remove <#> Close/Done Quit')) - print('> ', end='') - - com: list[str] = input().lstrip().rstrip().split(' ') - gc, message = self.global_commands(com) - if gc: - if message: - clear() - print(message) - clear_scr=False - else: - - # Add Tag ============================================================== - if com[0].lower() == 'add': - if len(com) > 1: - tag_list = self.lib.search_tags( - ' '.join(com[1:]), include_cluster=True) - selected_ids: list[int] = [] - if len(tag_list) > 1: - selected_ids = self.scr_select_tags(tag_list) - else: - selected_ids = tag_list # Single Tag - if selected_ids: - for id in selected_ids: - if id in tag_ids: - selected_ids.remove(id) - return self.scr_edit_generic_tag_box(tag_ids+selected_ids, tag_box_name) - tag_ids = tag_ids+selected_ids - # else: - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name) - # Remove Tag =========================================================== - elif com[0].lower() == 'remove' or com[0].lower() == 'rm': - if len(com) > 1: - try: - # selected_tag_ids: list[int] = [] - # for c in com[1:]: - # if (int(c)-1) < 0: - # raise IndexError - # selected_tag_ids.append(tag_ids[int(c[1])-1]) - selected_id = tag_ids[int(com[1])-1] - tag_ids.remove(selected_id) - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name) - # except SystemExit: - # self.cleanup_before_exit() - # sys.exit() - except: - clear() - print(f'{ERROR} Invalid Tag Selection \'{com[1:]}\'') - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name, clear_scr=False) - clear_scr=False - # Close View =========================================================== - elif (com[0].lower() == 'close' or com[0].lower() == 'c' or com[0].lower() == 'done'): - # clear() - # pass - return tag_ids - # # Quit ================================================================= - # elif com[0].lower() == 'quit' or com[0].lower() == 'q': - # self.lib.save_library_to_disk() - # # self.cleanup() - # sys.exit() - # # Quit without Saving ================================================== - # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': - # # self.cleanup() - # sys.exit() - # Unknown Command ====================================================== - else: - clear() - print(f'{ERROR} Unknown command \'{" ".join(com)}\'') - # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name, clear_scr=False) - clear_scr=False - - # return tag_ids + """A basic CLI driver for TagStudio.""" + + def __init__(self, core, args): + self.core: TagStudioCore = core + self.lib = self.core.lib + self.filtered_entries: list[tuple[ItemType, int]] = [] + self.args = args + self.first_open: bool = True + self.first_browse: bool = True + self.is_missing_count_init: bool = False + self.is_new_file_count_init: bool = False + self.is_dupe_entry_count_init: bool = False + self.is_dupe_file_count_init: bool = False + + self.external_preview_size: tuple[int, int] = (960, 960) + epd_path = os.path.normpath( + f"{Path(__file__).parent.parent.parent}/resources/cli/images/external_preview.png" + ) + self.external_preview_default: Image = ( + Image.open(epd_path) + if os.path.exists(epd_path) + else Image.new(mode="RGB", size=(self.external_preview_size)) + ) + self.external_preview_default.thumbnail(self.external_preview_size) + epb_path = os.path.normpath( + f"{Path(__file__).parent.parent.parent}/resources/cli/images/no_preview.png" + ) + self.external_preview_broken: Image = ( + Image.open(epb_path) + if os.path.exists(epb_path) + else Image.new(mode="RGB", size=(self.external_preview_size)) + ) + self.external_preview_broken.thumbnail(self.external_preview_size) + + self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" + self.base_title: str = f"TagStudio {VERSION}{self.branch} - CLI Mode" + self.title_text: str = self.base_title + self.buffer = {} + + def start(self): + """Enters the CLI.""" + print(SAVE_SCREEN, end="") + try: + self.scr_main_menu() + except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + self.exit(save=False, backup=False) + except KeyboardInterrupt: + # traceback.print_exc() + print("\nForce Quitting TagStudio...") + # if self.lib and self.lib.library_dir: + # self.backup_library() + # self.cleanup_before_exit() + # sys.exit() + self.exit(save=False, backup=False) + except: + traceback.print_exc() + print("\nPress Enter to Continue...") + input() + # if self.lib and self.lib.library_dir: + # self.backup_library() + # self.cleanup_before_exit() + # sys.exit() + self.exit(save=False, backup=True) + # except: + # print( + # '\nAn Unknown Exception in TagStudio has Occurred. Press Enter to Continue...') + # input() + # # if self.lib and self.lib.library_dir: + # # self.backup_library() + # # self.cleanup_before_exit() + # # sys.exit() + # self.quit(save=False, backup=True) + + def cleanup_before_exit(self, restore_screen=True): + """Things do be done on application exit.""" + try: + if self.args.external_preview: + self.close_external_preview() + except Exception: + traceback.print_exc() + print("\nCrashed on Cleanup! This is unusual... Press Enter to Continue...") + input() + self.backup_library() + + if restore_screen: + print(f"{RESET}{RESTORE_SCREEN}", end="") + + def exit(self, save: bool, backup: bool): + """Exists TagStudio, and optionally saves and/or backs up data.""" + + if save: + print(f"{INFO} Saving Library to disk...") + self.save_library(display_message=False) + if backup: + print(f"{INFO} Saving Library changes to Backups folder...") + self.backup_library(display_message=False) + + self.cleanup_before_exit() + + try: + sys.exit() + except SystemExit: + sys.exit() + + def format_title(self, str, color=f"{BRIGHT_WHITE_FG}{MAGENTA_BG}") -> str: + """Formats a string with title formatting.""" + # Floating Pill (Requires NerdFont) + # return f'◀ {str} ▶'.center(os.get_terminal_size()[0], " ").replace('◀', '\033[96m\033[0m\033[30m\033[106m').replace('▶', '\033[0m\033[96m\033[0m') + # Solid Background + return f'{color}{str.center(os.get_terminal_size()[0], " ")[:os.get_terminal_size()[0]]}{RESET}' + + def format_subtitle(self, str, color=BRIGHT_CYAN_FG) -> str: + """Formats a string with subtitle formatting.""" + return f'{color}{(" "+str+" ").center(os.get_terminal_size()[0], "═")[:os.get_terminal_size()[0]]}{RESET}' + + def format_h1(self, str, color=BRIGHT_MAGENTA_FG) -> str: + """Formats a string with h1 formatting.""" + return f'{color}{("┫ "+str+" ┣").center(os.get_terminal_size()[0], "━")[:os.get_terminal_size()[0]]}{RESET}' + + def format_h2(self, str, color=BRIGHT_GREEN_FG) -> str: + """Formats a string with h2 formatting.""" + return f'{color}{(" "+str+" ").center(os.get_terminal_size()[0], "·")[:os.get_terminal_size()[0]]}{RESET}' + + def get_file_color(self, ext: str): + if ext.lower().replace(".", "", 1) == "gif": + return BRIGHT_YELLOW_FG + if ext.lower().replace(".", "", 1) in IMAGE_TYPES: + return WHITE_FG + elif ext.lower().replace(".", "", 1) in VIDEO_TYPES: + return BRIGHT_CYAN_FG + elif ext.lower().replace(".", "", 1) in DOC_TYPES: + return BRIGHT_GREEN_FG + else: + return BRIGHT_WHITE_FG + + def get_tag_color(self, color: str) -> str: + if color.lower() == "black": + return "\033[48;2;17;16;24m" + "\033[38;2;183;182;190m" + # return '\033[48;5;233m' + BRIGHT_WHITE_FG + elif color.lower() == "dark gray": + return "\033[48;2;36;35;42m" + "\033[38;2;189;189;191m" + # return '\033[48;5;233m' + BRIGHT_WHITE_FG + elif color.lower() == "gray": + return "\033[48;2;83;82;90m" + "\033[38;2;203;202;210m" + # return '\033[48;5;246m' + BRIGHT_WHITE_FG + elif color.lower() == "light gray": + return "\033[48;2;170;169;176m" + "\033[38;2;34;33;40m" + # return '\033[48;5;250m' + BLACK_FG + elif color.lower() == "white": + return "\033[48;2;242;241;248m" + "\033[38;2;48;47;54m" + # return '\033[48;5;231m' + '\033[38;5;244m' + elif color.lower() == "light pink": + return "\033[48;2;255;143;190m" + "\033[38;2;108;43;57m" + # return '\033[48;5;212m' + '\033[38;5;88m' + elif color.lower() == "pink": + return "\033[48;2;250;74;117m" + "\033[38;2;91;23;35m" + # return '\033[48;5;204m' + '\033[38;5;224m' + elif color.lower() == "magenta": + return "\033[48;2;224;43;132m" + "\033[38;2;91;13;54m" + # return '\033[48;5;197m' + '\033[38;5;224m' + elif color.lower() == "red": + return "\033[48;2;226;44;60m" + "\033[38;2;68;13;18m" + # return '\033[48;5;196m' + '\033[38;5;224m' + elif color.lower() == "red orange": + return "\033[48;2;232;55;38m" + "\033[38;2;97;18;11m" + # return '\033[48;5;202m' + '\033[38;5;221m' + elif color.lower() == "salmon": + return "\033[48;2;246;88;72m" + "\033[38;2;111;27;22m" + # return '\033[48;5;203m' + '\033[38;5;88m' + elif color.lower() == "orange": + return "\033[48;2;237;96;34m" + "\033[38;2;85;30;10m" + # return '\033[48;5;208m' + '\033[38;5;229m' + elif color.lower() == "yellow orange": + return "\033[48;2;250;154;44m" + "\033[38;2;102;51;13m" + # return '\033[48;5;214m' + '\033[38;5;88m' + elif color.lower() == "yellow": + return "\033[48;2;255;214;61m" + "\033[38;2;117;67;18m" + # return '\033[48;5;220m' + '\033[38;5;88m' + elif color.lower() == "mint": + return "\033[48;2;74;237;144m" + "\033[38;2;22;79;62m" + # return '\033[48;5;84m' + '\033[38;5;17m' + elif color.lower() == "lime": + return "\033[48;2;149;227;69m" + "\033[38;2;65;84;21m" + # return '\033[48;5;154m' + '\033[38;5;17m' + elif color.lower() == "light green": + return "\033[48;2;138;236;125m" + "\033[38;2;44;85;38m" + # return '\033[48;5;40m' + '\033[38;5;17m' + elif color.lower() == "green": + return "\033[48;2;40;187;72m" + "\033[38;2;13;56;40m" + # return '\033[48;5;28m' + '\033[38;5;191m' + elif color.lower() == "teal": + return "\033[48;2;23;191;157m" + "\033[38;2;7;58;68m" + # return '\033[48;5;36m' + '\033[38;5;17m' + elif color.lower() == "cyan": + return "\033[48;2;60;222;196m" + "\033[38;2;12;64;66m" + # return '\033[48;5;50m' + '\033[38;5;17m' + elif color.lower() == "light blue": + return "\033[48;2;85;187;246m" + "\033[38;2;18;37;65m" + # return '\033[48;5;75m' + '\033[38;5;17m' + elif color.lower() == "blue": + return "\033[48;2;59;99;240m" + "\033[38;2;158;192;249m" + # return '\033[48;5;27m' + BRIGHT_WHITE_FG + elif color.lower() == "blue violet": + return "\033[48;2;93;88;241m" + "\033[38;2;149;176;249m" + # return '\033[48;5;63m' + BRIGHT_WHITE_FG + elif color.lower() == "violet": + return "\033[48;2;120;60;239m" + "\033[38;2;187;157;247m" + # return '\033[48;5;57m' + BRIGHT_WHITE_FG + elif color.lower() == "purple": + return "\033[48;2;155;79;240m" + "\033[38;2;73;24;98m" + # return '\033[48;5;135m' + BRIGHT_WHITE_FG + elif color.lower() == "peach": + return "\033[48;2;241;198;156m" + "\033[38;2;97;63;47m" + # return '\033[48;5;223m' + '\033[38;5;88m' + elif color.lower() == "brown": + return "\033[48;2;130;50;22m" + "\033[38;2;205;157;131m" + # return '\033[48;5;130m' + BRIGHT_WHITE_FG + elif color.lower() == "lavender": + return "\033[48;2;173;142;239m" + "\033[38;2;73;43;101m" + # return '\033[48;5;141m' + '\033[38;5;17m' + elif color.lower() == "blonde": + return "\033[48;2;239;198;100m" + "\033[38;2;109;70;30m" + # return '\033[48;5;221m' + '\033[38;5;88m' + elif color.lower() == "auburn": + return "\033[48;2;161;50;32m" + "\033[38;2;217;138;127m" + # return '\033[48;5;88m' + '\033[38;5;216m' + elif color.lower() == "light brown": + return "\033[48;2;190;91;45m" + "\033[38;2;76;41;14m" + elif color.lower() == "dark brown": + return "\033[48;2;76;35;21m" + "\033[38;2;183;129;113m" + # return '\033[48;5;172m' + BRIGHT_WHITE_FG + elif color.lower() == "cool gray": + return "\033[48;2;81;87;104m" + "\033[38;2;158;161;195m" + # return '\033[48;5;102m' + BRIGHT_WHITE_FG + elif color.lower() == "warm gray": + return "\033[48;2;98;88;80m" + "\033[38;2;192;171;146m" + # return '\033[48;5;59m' + BRIGHT_WHITE_FG + elif color.lower() == "olive": + return "\033[48;2;76;101;46m" + "\033[38;2;180;193;122m" + # return '\033[48;5;58m' + '\033[38;5;193m' + elif color.lower() == "berry": + return "\033[48;2;159;42;167m" + "\033[38;2;204;143;220m" + else: + return "" + + def copy_field_to_buffer(self, entry_field) -> None: + """Copies an Entry Field object into the internal buffer.""" + self.buffer = dict(entry_field) + + def paste_field_from_buffer(self, entry_id) -> None: + """Merges or adds the Entry Field object in the internal buffer to the Entry.""" + if self.buffer: + # entry: Entry = self.lib.entries[entry_index] + # entry = self.lib.get_entry(entry_id) + field_id: int = self.lib.get_field_attr(self.buffer, "id") + content = self.lib.get_field_attr(self.buffer, "content") + + # NOTE: This code is pretty much identical to the match_conditions code + # found in the core. Could this be made generic? Especially for merging Entries. + if self.lib.get_field_obj(int(field_id))["type"] == "tag_box": + existing_fields: list[int] = self.lib.get_field_index_in_entry( + entry_id, field_id + ) + if existing_fields: + self.lib.update_entry_field( + entry_id, existing_fields[0], content, "append" + ) + else: + self.lib.add_field_to_entry(entry_id, field_id) + self.lib.update_entry_field(entry_id, -1, content, "append") + + if self.lib.get_field_obj(int(field_id))["type"] in TEXT_FIELDS: + if not self.lib.does_field_content_exist(entry_id, field_id, content): + self.lib.add_field_to_entry(entry_id, field_id) + self.lib.update_entry_field(entry_id, -1, content, "replace") + + # existing_fields: list[int] = self.lib.get_field_index_in_entry(entry_index, field_id) + # if existing_fields: + # self.lib.update_entry_field(entry_index, existing_fields[0], content, 'append') + # else: + # self.lib.add_field_to_entry(entry_index, field_id) + # self.lib.update_entry_field(entry_index, -1, content, 'replace') + + def init_external_preview(self) -> None: + """Initialized the external preview image file.""" + if self.lib and self.lib.library_dir: + external_preview_path: str = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg" + ) + if not os.path.isfile(external_preview_path): + temp = self.external_preview_default + temp.save(external_preview_path) + + open_file(external_preview_path) + + def set_external_preview_default(self) -> None: + """Sets the external preview to its default image.""" + if self.lib and self.lib.library_dir: + external_preview_path: str = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg" + ) + if os.path.isfile(external_preview_path): + temp = self.external_preview_default + temp.save(external_preview_path) + + def set_external_preview_broken(self) -> None: + """Sets the external preview image file to the 'broken' placeholder.""" + if self.lib and self.lib.library_dir: + external_preview_path: str = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg" + ) + if os.path.isfile(external_preview_path): + temp = self.external_preview_broken + temp.save(external_preview_path) + + def close_external_preview(self) -> None: + """Destroys and closes the external preview image file.""" + if self.lib and self.lib.library_dir: + external_preview_path: str = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg" + ) + if os.path.isfile(external_preview_path): + os.remove(external_preview_path) + + def scr_create_library(self, path=""): + """Screen for creating a new TagStudio library.""" + + subtitle = "Create Library" + + clear() + print(f"{self.format_title(self.title_text)}") + print(self.format_subtitle(subtitle)) + print("") + + if not path: + print("Enter Library Folder Path: \n> ", end="") + path = input() + if os.path.exists(path): + print("") + print( + f'{INFO} Are you sure you want to create a new Library at "{path}"? (Y/N)\n> ', + end="", + ) + con = input().lower() + if con == "y" or con == "yes": + result = self.lib.create_library(path) + if result == 0: + print( + f'{INFO} Created new TagStudio Library at: "{path}"\nPress Enter to Return to Main Menu...' + ) + input() + # self.open_library(path) + elif result == 1: + print( + f'{ERROR} Could not create Library. Path: "{path}" is pointing inside an existing TagStudio Folder.\nPress Enter to Return to Main Menu...' + ) + input() + elif result == 2: + print( + f'{ERROR} Could not write inside path: "{path}"\nPress Enter to Return to Main Menu...' + ) + input() + else: + print( + f'{ERROR} Invalid Path: "{path}"\nPress Enter to Return to Main Menu...' + ) + input() + # if Core.open_library(path) == 1: + # self.library_name = path + # self.scr_library_home() + # else: + # print(f'[ERROR]: No existing TagStudio library found at \'{path}\'') + # self.scr_main_menu() + + def open_library(self, path): + """Opens a TagStudio library.""" + + return_code = self.lib.open_library(path) + if return_code == 1: + # self.lib = self.core.library + if self.args.external_preview: + self.init_external_preview() + + if len(self.lib.entries) <= 1000: + print( + f"{INFO} Checking for missing files in Library '{self.lib.library_dir}'..." + ) + self.lib.refresh_missing_files() + # else: + # print( + # f'{INFO} Automatic missing file refreshing is turned off for large libraries (1,000+ Entries)') + self.title_text: str = self.base_title + "" + self.scr_library_home() + else: + clear() + print(f"{ERROR} No existing TagStudio library found at '{path}'") + self.scr_main_menu(clear_scr=False) + + def close_library(self, save=True): + """ + Saves (by default) and clears the current Library as well as related operations. + Does *not* direct the navigation back to the main menu, that's not my job. + """ + if save: + self.lib.save_library_to_disk() + if self.args.external_preview: + self.close_external_preview() + self.lib.clear_internal_vars() + + def backup_library(self, display_message: bool = True) -> bool: + """Saves a backup copy of the Library file to disk. Returns True if successful.""" + if self.lib and self.lib.library_dir: + filename = self.lib.save_library_backup_to_disk() + location = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/backups/{filename}" + ) + if display_message: + print(f'{INFO} Backup of Library saved at "{location}".') + return True + return False + + def save_library(self, display_message: bool = True) -> bool: + """Saves the Library file to disk. Returns True if successful.""" + if self.lib and self.lib.library_dir: + self.lib.save_library_to_disk() + if display_message: + print(f"{INFO} Library saved to disk.") + return True + return False + + def get_char_limit(self, text: str) -> int: + """ + Returns an estimated value for how many characters of a block of text should be allowed to display before being truncated. + """ + # char_limit: int = ( + # (os.get_terminal_size()[0] * os.get_terminal_size()[1]) // 6) + # char_limit -= (text.count('\n') + text.count('\r') * (os.get_terminal_size()[0] // 1.0)) + # char_limit = char_limit if char_limit > 0 else min(40, len(text)) + + char_limit: int = os.get_terminal_size()[0] * (os.get_terminal_size()[1] // 5) + char_limit -= (text.count("\n") + text.count("\r")) * ( + os.get_terminal_size()[0] // 2 + ) + char_limit = char_limit if char_limit > 0 else min((64), len(text)) + + # print(f'Char Limit: {char_limit}, Len: {len(text)}') + return char_limit + + 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)}') + return f"{text[:int(self.get_char_limit(text) - 1)]} {WHITE_FG}[...]{RESET}" + else: + return text + + def print_fields(self, index) -> None: + """Prints an Entry's formatted fields to the screen.""" + entry = self.lib.entries[index] + + if entry and self.args.debug: + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} ID: {RESET} ", end="") + print(entry.id_) + + if entry and entry.fields: + for i, field in enumerate(entry.fields): + # Buffer between box fields below other fields if this isn't the first field + if ( + i != 0 + and self.lib.get_field_attr(field, "type") in BOX_FIELDS + and self.lib.get_field_attr(entry.fields[i - 1], "type") + not in BOX_FIELDS + ): + print("") + # Format the field title differently for box fields. + if self.lib.get_field_attr(field, "type") in BOX_FIELDS: + print( + f'{BRIGHT_WHITE_BG}{BLACK_FG} {self.lib.get_field_attr(field, "name")}: {RESET} ', + end="\n", + ) + else: + print( + f'{BRIGHT_WHITE_BG}{BLACK_FG} {self.lib.get_field_attr(field, "name")}: {RESET} ', + end="", + ) + if self.lib.get_field_attr(field, "type") == "tag_box": + char_count: int = 0 + for tag_id in self.lib.get_field_attr(field, "content"): + tag = self.lib.get_tag(tag_id) + # Properly wrap Tags on screen + char_count += len(f" {tag.display_name(self.lib)} ") + 1 + if char_count > os.get_terminal_size()[0]: + print("") + char_count = len(f" {tag.display_name(self.lib)} ") + 1 + print( + f"{self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}", + end="", + ) + # If the tag isn't the last one, print a space for the next one. + if tag_id != self.lib.get_field_attr(field, "content")[-1]: + print(" ", end="") + else: + print("") + elif self.lib.get_field_attr(field, "type") in TEXT_FIELDS: + # Normalize line endings in any text content. + text: str = self.lib.get_field_attr(field, "content").replace( + "\r", "\n" + ) + print(self.truncate_text(text)) + elif self.lib.get_field_attr(field, "type") == "datetime": + try: + # TODO: Localize this and/or add preferences. + date = dt.strptime( + self.lib.get_field_attr(field, "content"), + "%Y-%m-%d %H:%M:%S", + ) + print(date.strftime("%D - %r")) + except: + print(self.lib.get_field_attr(field, "content")) + else: + print(self.lib.get_field_attr(field, "content")) + + # Buffer between box fields above other fields if this isn't the last field + if ( + entry.fields[i] != entry.fields[-1] + and self.lib.get_field_attr(field, "type") in BOX_FIELDS + ): + print("") + else: + # print(f'{MAGENTA_BG}{BRIGHT_WHITE_FG}[No Fields]{RESET}{WHITE_FG} (Run \'edit\', then \'add \' to add some!){RESET}') + print(f"{MAGENTA_BG}{BRIGHT_WHITE_FG}[No Fields]{RESET}{WHITE_FG}") + + def print_thumbnail( + self, index, filepath="", ignore_fields=False, max_width=-1 + ) -> None: + """ + Prints an Entry's formatted thumbnail to the screen. + Takes in either an Entry index or a direct filename. + """ + entry = None if index < 0 else self.lib.entries[index] + if entry: + filepath = os.path.normpath( + f"{self.lib.library_dir}/{entry.path}/{entry.filename}" + ) + external_preview_path: str = "" + if self.args.external_preview: + external_preview_path = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg" + ) + # thumb_width = min( + # os.get_terminal_size()[0]//2, + # math.floor(os.get_terminal_size()[1]*0.5)) + # thumb_width = math.floor(os.get_terminal_size()[1]*0.5) + + # if entry: + file_type = os.path.splitext(filepath)[1].lower()[1:] + if file_type in (IMAGE_TYPES + VIDEO_TYPES): + # TODO: Make the image-grabbing part try to get thumbnails. + + # Lots of calculations to determine an image width that works well. + w, h = (1, 1) + final_img_path = filepath + if file_type in IMAGE_TYPES: + try: + raw = Image.open(filepath) + w, h = raw.size + # NOTE: Temporary way to hack a non-terminal preview. + if self.args.external_preview: + raw = raw.convert("RGB") + # raw.thumbnail((512, 512)) + raw.thumbnail(self.external_preview_size) + raw.save(external_preview_path) + except: + print(f'{ERROR} Could not load image "{filepath}"') + if self.args.external_preview: + self.set_external_preview_broken() + elif file_type in VIDEO_TYPES: + try: + video = cv2.VideoCapture(filepath) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + final_frame = Image.fromarray(frame) + w, h = final_frame.size + final_frame.save( + os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg" + ), + quality=50, + ) + final_img_path = os.path.normpath( + f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg" + ) + # NOTE: Temporary way to hack a non-terminal preview. + if self.args.external_preview and entry: + final_frame.thumbnail(self.external_preview_size) + final_frame.save(external_preview_path) + except SystemExit: + sys.exit() + except: + print(f'{ERROR} Could not load video thumbnail for "{filepath}"') + if self.args.external_preview and entry: + self.set_external_preview_broken() + pass + + img_ratio: float = w / h + term_ratio_norm: float = ( + os.get_terminal_size()[1] / os.get_terminal_size()[0] + ) * 2 + base_mod: float = 0.7 + field_cnt_mod: float = 0 + desc_len_mod: float = 0 + tag_cnt_mod: float = 0 + if entry and entry.fields and not ignore_fields: + field_cnt_mod = 1.5 * len(entry.fields) + for f in entry.fields: + if self.lib.get_field_attr(f, "type") == "tag_box": + tag_cnt_mod += 0.5 * len(self.lib.get_field_attr(f, "content")) + elif self.lib.get_field_attr(f, "type") == "text_box": + desc_len_mod += 0.07 * len( + self.truncate_text(self.lib.get_field_attr(f, "content")) + ) + desc_len_mod += 1.7 * self.truncate_text( + self.lib.get_field_attr(f, "content") + ).count("\n") + desc_len_mod += 1.7 * self.truncate_text( + self.lib.get_field_attr(f, "content") + ).count("\r") + try: + thumb_width = min( + math.floor( + ( + os.get_terminal_size()[0] + * img_ratio + * term_ratio_norm + * base_mod + ) + - ( + (field_cnt_mod + desc_len_mod + tag_cnt_mod) + * (img_ratio * 0.7) + ) + ), + os.get_terminal_size()[0], + ) + if max_width > 0: + thumb_width = max_width if thumb_width > max_width else thumb_width + # image = climage.convert(final_img_path, is_truecolor=True, is_256color=False, + # is_16color=False, is_8color=False, width=thumb_width) + # Center Alignment Hack + spacing = (os.get_terminal_size()[0] - thumb_width) // 2 + if not self.args.external_preview or not entry: + print(" " * spacing, end="") + print(image.replace("\n", ("\n" + " " * spacing))) + + if file_type in VIDEO_TYPES: + os.remove(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg") + except: + if not self.args.external_preview or not entry: + print( + f"{ERROR} Could not display preview. Is there enough screen space?" + ) + + def print_columns(self, content: list[object], add_enum: False) -> None: + """ + Prints content in a column format. + Content: A list of tuples list[(element, formatting)] + """ + try: + if content: + # This is an estimate based on the existing screen formatting. + margin: int = 7 + enum_padding: int = 0 + term_width: int = os.get_terminal_size()[0] + + num_width: int = len(str(len(content) + 1)) + if add_enum: + enum_padding = num_width + 2 + + longest_width: int = ( + len(max(content, key=lambda x: len(x[0]))[0]) + 1 + ) # + Padding + column_count: int = term_width // (longest_width + enum_padding + 3) + column_count: int = column_count if column_count > 0 else 1 + max_display: int = column_count * (os.get_terminal_size()[1] - margin) + displayable: int = min(max_display, len(content)) + + # Recalculate based on displayable items + num_width = len(str(len(content[:max_display]) + 1)) + if add_enum: + enum_padding = num_width + 2 + longest_width = ( + len(max(content[:max_display], key=lambda x: len(x[0]))[0]) + 1 + ) + column_count = term_width // (longest_width + enum_padding + 3) + column_count = column_count if column_count > 0 else 1 + max_display = column_count * (os.get_terminal_size()[1] - margin) + # displayable: int = min(max_display, len(content)) + + num_width = len(str(len(content[:max_display]) + 1)) + if add_enum: + enum_padding = num_width + 2 + # longest_width = len(max(content[:max_display], key=lambda x: len(x[0]))[0]) + 1 + # column_count = term_width // (longest_width + enum_padding + 3) + # column_count = column_count if column_count > 0 else 1 + # max_display = column_count * (os.get_terminal_size()[1]-margin) + + # print(num_width) + # print(term_width) + # print(longest_width) + # print(columns) + # print(max(content, key = lambda x : len(x[0]))) + # print(len(max(content, key = lambda x : len(x[0]))[0])) + + # # Prints out the list in a left-to-right tabular column form with color formatting. + # for i, element in enumerate(content): + # if i != 0 and i % (columns-1) == 0: + # print('') + # if add_enum: + # print(f'{element[1]}[{str(i+1).zfill(num_width)}] {element[0]} {RESET}', end='') + # else: + # print(f'{element[1]} {element[0]} {RESET}', end='') + # print(' ' * (longest_width - len(element[0])), end='') + + # Prints out the list in a top-down tabular column form with color formatting. + # This is my greatest achievement. + row_count: int = math.floor(len(content) / column_count) + table_size: int = row_count * column_count + table_size = table_size if table_size > 0 else 1 + # print(f'Rows:{max_rows}, Cols:{max_columns}') + row_num = 1 + col_num = 1 + for i, element in enumerate(content): + if i < max_display: + if row_count > 1: + row_number = i // column_count + index = (i * row_count) - (row_number * (table_size - 1)) + # col_number = index // math.ceil(len(content) / max_columns) + offset: int = 0 + if displayable % table_size == 1: + offset = ( + 1 + if (index >= row_count) + and (row_number != row_count) + else 0 + ) + elif displayable % table_size != 0: + 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 + + if ( + col_num > 1 + and (os.get_terminal_size()[1] - margin) < row_count + ): + offset -= ( + row_count - (os.get_terminal_size()[1] - margin) + ) * (col_num - 1) + (col_num - 1) + + # print(f'{row_count}/{(os.get_terminal_size()[1]-margin)}', end='') + + index += offset + # print(offset, end='') + # print(f'{row_num}-{col_num}', end='') + else: + index = i + if i != 0 and i % column_count == 0: + row_num += 1 + col_num = 1 + print("") + if index < len(content): + col_num += 1 + col_num = col_num if col_num <= column_count else 1 + if add_enum: + print( + f"{content[index][1]}[{str(index+1).zfill(num_width)}] {content[index][0]} {RESET}", + end="", + ) + else: + print( + f"{content[index][1]} {content[index][0]} {RESET}", + end="", + ) + if row_count > 0: + print( + " " * (longest_width - len(content[index][0])), + end="", + ) + else: + print(" ", end="") + else: + print( + "\n" + + self.format_h2(f"[{len(content) - max_display} More...]"), + end="", + ) + # print(WHITE_FG + '\n' + f'[{len(content) - max_display} More...]'.center(os.get_terminal_size()[0], " ")[:os.get_terminal_size()[0]]+RESET) + # print(f'\n{WHITE_FG}[{{RESET}', end='') + break + # print(f'Rows:{row_count}, Cols:{column_count}') + print("") + + except Exception: + traceback.print_exc() + print("\nPress Enter to Continue...") + input() + pass + + def run_macro(self, name: str, entry_id: int): + """Runs a specific Macro on an Entry given a Macro name.""" + # entry: Entry = self.lib.get_entry_from_index(entry_id) + entry = self.lib.get_entry(entry_id) + path = os.path.normpath(f"{self.lib.library_dir}/{entry.path}/{entry.filename}") + source = path.split(os.sep)[1].lower() + if name == "sidecar": + self.lib.add_generic_data_to_entry( + self.core.get_gdl_sidecar(path, source), entry_id + ) + elif name == "autofill": + self.run_macro("sidecar", entry_id) + self.run_macro("build-url", entry_id) + self.run_macro("match", entry_id) + self.run_macro("clean-url", entry_id) + self.run_macro("sort-fields", entry_id) + elif name == "build-url": + data = {"source": self.core.build_url(entry_id, source)} + self.lib.add_generic_data_to_entry(data, entry_id) + elif name == "sort-fields": + order: list[int] = ( + [0] + + [1, 2] + + [9, 17, 18, 19, 20] + + [10, 14, 11, 12, 13, 22] + + [4, 5] + + [8, 7, 6] + + [3, 21] + ) + self.lib.sort_fields(entry_id, order) + elif name == "match": + self.core.match_conditions(entry_id) + elif name == "scrape": + self.core.scrape(entry_id) + elif name == "clean-url": + # entry = self.lib.get_entry_from_index(entry_id) + if entry.fields: + for i, field in enumerate(entry.fields, start=0): + if self.lib.get_field_attr(field, "type") == "text_line": + self.lib.update_entry_field( + entry_id=entry_id, + field_index=i, + content=strip_web_protocol( + self.lib.get_field_attr(field, "content") + ), + mode="replace", + ) + + def create_collage(self) -> str: + """Generates and saves an image collage based on Library Entries.""" + + run: bool = True + keep_aspect: bool = False + data_only_mode: bool = False + data_tint_mode: bool = False + + mode: int = self.scr_choose_option( + subtitle="Choose Collage Mode(s)", + choices=[ + ( + "Normal", + "Creates a standard square image collage made up of Library media files.", + ), + ( + "Data Tint", + "Tints the collage with a color representing data about the Library Entries/files.", + ), + ( + "Data Only", + "Ignores media files entirely and only outputs a collage of Library Entry/file data.", + ), + ("Normal & Data Only", "Creates both Normal and Data Only collages."), + ], + prompt="", + required=True, + ) + + if mode == 1: + data_tint_mode = True + + if mode == 2: + data_only_mode = True + + if mode in [0, 1, 3]: + keep_aspect = self.scr_choose_option( + subtitle="Choose Aspect Ratio Option", + choices=[ + ( + "Stretch to Fill", + "Stretches the media file to fill the entire collage square.", + ), + ( + "Keep Aspect Ratio", + "Keeps the original media file's aspect ratio, filling the rest of the square with black bars.", + ), + ], + prompt="", + required=True, + ) + + if mode in [1, 2, 3]: + # TODO: Choose data visualization options here. + pass + + full_thumb_size: int = 1 + + if mode in [0, 1, 3]: + full_thumb_size = self.scr_choose_option( + subtitle="Choose Thumbnail Size", + choices=[ + ("Tiny (32px)", ""), + ("Small (64px)", ""), + ("Medium (128px)", ""), + ("Large (256px)", ""), + ("Extra Large (512px)", ""), + ], + prompt="", + required=True, + ) + + thumb_size: int = ( + 32 + if (full_thumb_size == 0) + else 64 + if (full_thumb_size == 1) + else 128 + if (full_thumb_size == 2) + else 256 + if (full_thumb_size == 3) + else 512 + if (full_thumb_size == 4) + else 32 + ) + + # if len(com) > 1 and com[1] == 'keep-aspect': + # keep_aspect = True + # elif len(com) > 1 and com[1] == 'data-only': + # data_only_mode = True + # elif len(com) > 1 and com[1] == 'data-tint': + # data_tint_mode = True + grid_size = math.ceil(math.sqrt(len(self.lib.entries))) ** 2 + grid_len = math.floor(math.sqrt(grid_size)) + thumb_size = thumb_size if not data_only_mode else 1 + img_size = thumb_size * grid_len + + print( + f"Creating collage for {len(self.lib.entries)} Entries.\nGrid Size: {grid_size} ({grid_len}x{grid_len})\nIndividual Picture Size: ({thumb_size}x{thumb_size})" + ) + if keep_aspect: + print("Keeping original aspect ratios.") + if data_only_mode: + print("Visualizing Entry Data") + + if not data_only_mode: + time.sleep(5) + + collage = Image.new("RGB", (img_size, img_size)) + filename = os.path.normpath( + f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png' + ) + + i = 0 + for x in range(0, grid_len): + for y in range(0, grid_len): + try: + if i < len(self.lib.entries) and run: + # entry: Entry = self.lib.get_entry_from_index(i) + entry = self.lib.entries[i] + filepath = os.path.normpath( + f"{self.lib.library_dir}/{entry.path}/{entry.filename}" + ) + file_type = os.path.splitext(filepath)[1].lower()[1:] + color: str = "" + + if data_tint_mode or data_only_mode: + color = "#000000" # Black (Default) + + if entry.fields: + has_any_tags: bool = False + has_content_tags: bool = False + has_meta_tags: bool = False + for field in entry.fields: + if ( + self.lib.get_field_attr(field, "type") + == "tag_box" + ): + if self.lib.get_field_attr(field, "content"): + has_any_tags = True + if ( + self.lib.get_field_attr(field, "id") + == 7 + ): + has_content_tags = True + elif ( + self.lib.get_field_attr(field, "id") + == 8 + ): + has_meta_tags = True + if has_content_tags and has_meta_tags: + color = "#28bb48" # Green + elif has_any_tags: + color = "#ffd63d" # Yellow + # color = '#95e345' # Yellow-Green + else: + # color = '#fa9a2c' # Yellow-Orange + color = "#ed8022" # Orange + else: + color = "#e22c3c" # Red + + if data_only_mode: + pic: Image = Image.new( + "RGB", (thumb_size, thumb_size), color + ) + collage.paste(pic, (y * thumb_size, x * thumb_size)) + if not data_only_mode: + print( + f"\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}" + ) + # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') + # sys.stdout.flush() + if file_type in IMAGE_TYPES: + with Image.open( + os.path.normpath( + f"{self.lib.library_dir}/{entry.path}/{entry.filename}" + ) + ) as pic: + if keep_aspect: + pic.thumbnail((thumb_size, thumb_size)) + else: + pic = pic.resize((thumb_size, thumb_size)) + if data_tint_mode and color: + pic = pic.convert(mode="RGB") + pic = ImageChops.hard_light( + pic, + Image.new( + "RGB", (thumb_size, thumb_size), color + ), + ) + collage.paste(pic, (y * thumb_size, x * thumb_size)) + elif file_type in VIDEO_TYPES: + video = cv2.VideoCapture(filepath) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + with Image.fromarray(frame, mode="RGB") as pic: + if keep_aspect: + pic.thumbnail((thumb_size, thumb_size)) + else: + pic = pic.resize((thumb_size, thumb_size)) + if data_tint_mode and color: + pic = ImageChops.hard_light( + pic, + Image.new( + "RGB", (thumb_size, thumb_size), color + ), + ) + collage.paste(pic, (y * thumb_size, x * thumb_size)) + except UnidentifiedImageError: + print( + f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}" + ) + except KeyboardInterrupt: + # self.quit(save=False, backup=True) + run = False + clear() + print(f"{INFO} Collage operation cancelled.") + clear_scr = False + except: + print(f"{ERROR} {entry.path}{os.sep}{entry.filename}") + traceback.print_exc() + print("Continuing...") + i = i + 1 + + if run: + self.lib.verify_ts_folders() + collage.save(filename) + return filename + return "" + + def global_commands(self, com: list[str]) -> tuple[bool, str]: + """ + Executes from a set of global commands.\n + Returns a (bool,str) tuple containing (was command executed?, optional command message) + """ + was_executed: bool = False + message: str = "" + com_name = com[0].lower() + + # Backup Library ======================================================= + 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_name == "collage": + filename = self.create_collage() + if filename: + was_executed = True + message = f'{INFO} Saved collage to "{filename}".' + # Save Library ========================================================= + 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_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_name == "toggle-external-preview": + self.args.external_preview = not self.args.external_preview + if self.args.external_preview: + self.init_external_preview() + else: + self.close_external_preview() + was_executed = True + message = ( + f"{INFO} External Preview Enabled." + if self.args.external_preview + else f"{INFO} External Preview Disabled." + ) + # Quit ================================================================= + elif com_name in ("quit", "q"): + self.exit(save=True, backup=False) + was_executed = True + # Quit without Saving ================================================== + elif com_name in ("quit!", "q!"): + self.exit(save=False, backup=False) + was_executed = True + + return (was_executed, message) + + def scr_browse_help(self, prev) -> None: + """A Help screen for commands available during Library Browsing.""" + pass + + def scr_main_menu(self, clear_scr=True): + """The CLI main menu.""" + + while True: + if self.args.open and self.first_open: + self.first_open = False + self.open_library(self.args.open) + + if clear_scr: + clear() + clear_scr = True + print(f"{self.format_title(self.title_text)}") + print("") + print(f"\t{BRIGHT_WHITE_FG}{MAGENTA_BG} - Basic Commands - {RESET}") + print(f"\t\tOpen Library: {WHITE_FG}open | o {RESET}") + print(f"\t\tCreate New Library: {WHITE_FG}new | n {RESET}") + # print(f'\t\tHelp: {WHITE_FG}help | h{RESET}') + print("") + print(f"\t\tQuit TagStudio: {WHITE_FG}quit | q{RESET}") + print("") + print( + f"\t💡TIP: {WHITE_FG}TagStudio can be launched with the --open (or -o) option followed\n\t\tby to immediately open a library!{RESET}" + ) + print("") + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + if com[0].lower() == "open" or com[0].lower() == "o": + if len(com) > 1: + self.open_library(com[1]) + elif com[0].lower() == "new" or com[0].lower() == "n": + if len(com) > 1: + self.scr_create_library(com[1]) + # elif (com[0].lower() == 'toggle-debug'): + # self.args.debug = not self.args.debug + # elif com[0].lower() in ['quit', 'q', 'close', 'c']: + # sys.exit() + # elif com[0].lower() in ['quit!', 'q!']: + # sys.exit() + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + + def scr_library_home(self, clear_scr=True): + """Home screen for an opened Library.""" + + while True: + subtitle = f"Library '{self.lib.library_dir}'" + if self.lib.is_legacy_library: + subtitle += " (Legacy Format)" + if self.args.debug: + subtitle += " (Debug Mode Active)" + # Directory Info ------------------------------------------------------- + file_count: str = ( + f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dir' to update){RESET}" + if self.lib.dir_file_count == -1 + else f"{WHITE_FG}{self.lib.dir_file_count}{RESET}" + ) + + new_file_count: str = ( + f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dir' to update){RESET}" + if ( + self.lib.files_not_in_library == [] + and not self.is_new_file_count_init + ) + else f"{WHITE_FG}{len(self.lib.files_not_in_library)}{RESET}" + ) + + # Issues --------------------------------------------------------------- + missing_file_count: str = ( + f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh missing' to update){RESET}" + if (self.lib.missing_files == [] and not self.is_missing_count_init) + else f"{BRIGHT_RED_FG}{len(self.lib.missing_files)}{RESET}" + ) + missing_file_count = ( + f"{BRIGHT_GREEN_FG}0{RESET}" + if (self.is_missing_count_init and len(self.lib.missing_files) == 0) + else missing_file_count + ) + + dupe_entry_count: str = ( + f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dupe entries' to update){RESET}" + if (self.lib.dupe_entries == [] and not self.is_dupe_entry_count_init) + else f"{BRIGHT_RED_FG}{len(self.lib.dupe_entries)}{RESET}" + ) + dupe_entry_count = ( + f"{BRIGHT_GREEN_FG}0{RESET}" + if (self.is_dupe_entry_count_init and len(self.lib.dupe_entries) == 0) + else dupe_entry_count + ) + + dupe_file_count: str = ( + f"{BRIGHT_YELLOW_FG}N/A (Run 'refresh dupe files' to update){RESET}" + if (self.lib.dupe_files == [] and not self.is_dupe_file_count_init) + else f"{BRIGHT_RED_FG}{len(self.lib.dupe_files)}{RESET}" + ) + dupe_file_count = ( + f"{BRIGHT_GREEN_FG}0{RESET}" + if (self.is_dupe_file_count_init and len(self.lib.dupe_files) == 0) + else dupe_file_count + ) + # fixed_file_count: str = 'N/A (Run \'fix missing\' to refresh)' if self.lib.fixed_files == [ + # ] else len(self.lib.fixed_files) + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(self.base_title)) + print(self.format_subtitle(subtitle)) + print("") + + if self.args.browse and self.first_browse: + self.first_browse = False + self.filtered_entries = self.lib.search_library() + self.scr_browse_entries_gallery(0) + else: + print(f"\t{BRIGHT_CYAN_BG}{BLACK_FG} - Library Info - {RESET}") + print(f"\t Entries: {WHITE_FG}{len(self.lib.entries)}{RESET}") + # print(f'\tCollations: {WHITE_FG}0{RESET}') + print(f"\t Tags: {WHITE_FG}{len(self.lib.tags)}{RESET}") + print(f"\t Fields: {WHITE_FG}{len(self.lib.default_fields)}{RESET}") + # print(f'\t Macros: {WHITE_FG}0{RESET}') + print("") + print(f"\t{BRIGHT_CYAN_BG}{BLACK_FG} - Directory Info - {RESET}") + print(f"\t Media Files: {file_count} (0 KB)") + print(f"\tNot in Library: {new_file_count} (0 KB)") + # print(f'\t Sidecar Files: 0 (0 KB)') + # print(f'\t Total Files: 0 (0 KB)') + print("") + print(f"\t{BRIGHT_CYAN_BG}{BLACK_FG} - Issues - {RESET}") + print(f"\t Missing Files: {missing_file_count}") + print(f"\tDuplicate Entries: {dupe_entry_count}") + print(f"\t Duplicate Files: {dupe_file_count}") + # print(f' Fixed Files: {WHITE_FG}{fixed_file_count}{RESET}') + print("") + print(f"\t{BRIGHT_WHITE_FG}{MAGENTA_BG} - Basic Commands - {RESET}") + + print(f"\tBrowse Library: {WHITE_FG}browse | b{RESET}") + print(f"\tSearch Library: {WHITE_FG}search | s < query >{RESET}") + print( + f"\tList Info: {WHITE_FG}list | ls < dir | entires | tags | fields | macros | new | missing >{RESET}" + ) + print(f"\tAdd New Files to Library: {WHITE_FG}add new{RESET}") + print( + f"\tRefresh Info: {WHITE_FG}refresh | r < dir | missing | dupe entries | dupe files >{RESET}" + ) + print( + f"\tFix Issues: {WHITE_FG}fix < missing | dupe entries | dupe files > {RESET}" + ) + # print(f'\tHelp: {WHITE_FG}help | h{RESET}') + + print("") + print(f"\tSave Library: {WHITE_FG}save | backup{RESET}") + print(f"\tClose Library: {WHITE_FG}close | c{RESET}") + print(f"\tQuit TagStudio: {WHITE_FG}quit | q{RESET}") + # print(f'Quit Without Saving: {WHITE_FG}quit! | q!{RESET}') + print("") + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Refresh ============================================================== + if (com[0].lower() == "refresh" or com[0].lower() == "r") and len( + com + ) > 1: + if com[1].lower() == "files" or com[1].lower() == "dir": + print( + f"{INFO} Scanning for files in '{self.lib.library_dir}'..." + ) + self.lib.refresh_dir() + self.is_new_file_count_init = True + elif com[1].lower() == "missing": + print( + f"{INFO} Checking for missing files in '{self.lib.library_dir}'..." + ) + self.lib.refresh_missing_files() + self.is_missing_count_init = True + elif com[1].lower() == "duplicate" or com[1].lower() == "dupe": + if len(com) > 2: + if com[2].lower() == "entries" or com[2].lower() == "e": + print( + f"{INFO} Checking for duplicate entries in Library '{self.lib.library_dir}'..." + ) + self.lib.refresh_dupe_entries() + self.is_dupe_entry_count_init = True + elif com[2].lower() == "files" or com[2].lower() == "f": + print( + f"{WHITE_FG}Enter the filename for your DupeGuru results file:\n> {RESET}", + end="", + ) + dg_results_file = os.path.normpath(input()) + print( + f"{INFO} Checking for duplicate files in Library '{self.lib.library_dir}'..." + ) + self.lib.refresh_dupe_files(dg_results_file) + self.is_dupe_file_count_init = True + else: + clear() + print( + f'{ERROR} Specify which duplicates to refresh (files, entries, all) \'{" ".join(com)}\'' + ) + clear_scr = False + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + # List ================================================================= + elif (com[0].lower() == "list" or com[0].lower() == "ls") and len( + com + ) > 1: + if com[1].lower() == "entries": + for i, e in enumerate(self.lib.entries, start=0): + title = f"[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path}{os.path.sep}{self.lib.entries[i].filename}" + print( + self.format_subtitle( + title, + color=self.get_file_color( + os.path.splitext( + self.lib.entries[i].filename + )[1] + ), + ) + ) + self.print_fields(i) + print("") + time.sleep(0.05) + print("Press Enter to Continue...") + input() + elif com[1].lower() == "new": + for i in self.lib.files_not_in_library: + print(i) + time.sleep(0.1) + print("Press Enter to Continue...") + input() + elif com[1].lower() == "missing": + for i in self.lib.missing_files: + print(i) + time.sleep(0.1) + print("Press Enter to Continue...") + input() + elif com[1].lower() == "fixed": + for i in self.lib.fixed_files: + print(i) + time.sleep(0.1) + print("Press Enter to Continue...") + input() + elif com[1].lower() == "files" or com[1].lower() == "dir": + # NOTE: This doesn't actually print the directory files, it just prints + # files that are attached to Entries. Should be made consistent. + # print(self.lib.file_to_entry_index_map.keys()) + for key in self.lib.filename_to_entry_id_map.keys(): + print(key) + time.sleep(0.05) + print("Press Enter to Continue...") + input() + elif com[1].lower() == "duplicate" or com[1].lower() == "dupe": + if len(com) > 2: + if com[2].lower() == "entries" or com[2].lower() == "e": + for dupe in self.lib.dupe_entries: + print( + self.lib.entries[dupe[0]].path + + os.path.sep + + self.lib.entries[dupe[0]].filename + ) + for d in dupe[1]: + print( + f"\t-> {(self.lib.entries[d].path + os.path.sep + self.lib.entries[d].filename)}" + ) + time.sleep(0.1) + print("Press Enter to Continue...") + input() + elif com[2].lower() == "files" or com[2].lower() == "f": + for dupe in self.lib.dupe_files: + print(dupe) + time.sleep(0.1) + print("Press Enter to Continue...") + input() + elif com[1].lower() == "tags": + self.scr_list_tags(tag_ids=self.lib.search_tags("")) + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + # Top ====================================================== + # Tags ----------------------------------------------------- + elif com[0].lower() == "top": + if len(com) > 1 and com[1].lower() == "tags": + self.lib.count_tag_entry_refs() + self.scr_top_tags() + # Browse =========================================================== + elif com[0].lower() == "browse" or com[0].lower() == "b": + if len(com) > 1: + if com[1].lower() == "entries": + self.filtered_entries = self.lib.search_library() + self.scr_browse_entries_gallery(0) + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + else: + self.filtered_entries = self.lib.search_library() + self.scr_browse_entries_gallery(0) + # Search =========================================================== + elif com[0].lower() == "search" or com[0].lower() == "s": + if len(com) > 1: + self.filtered_entries = self.lib.search_library( + " ".join(com[1:]) + ) + self.scr_browse_entries_gallery(0) + else: + self.scr_browse_entries_gallery(0) + # self.scr_library_home(clear_scr=False) + # Add New Entries ================================================== + elif " ".join(com) == "add new": + 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: + self.lib.refresh_dir() + # self.is_new_file_count_init = False + new_ids: list[int] = self.lib.add_new_files_as_entries() + print( + f"{INFO} Running configured Macros on {len(new_ids)} new Entries..." + ) + for id in new_ids: + self.run_macro("autofill", id) + # print(f'{INFO} Scanning for files in \'{self.lib.library_dir}\' (This may take a while)...') + # self.lib.refresh_dir() + self.is_new_file_count_init = True + # self.scr_library_home() + # Fix ============================================================== + elif (com[0].lower() == "fix") and len(com) > 1: + if com[1].lower() == "missing": + subtitle = f"Fix Missing Files" + choices: list[(str, str)] = [ + ( + "Search with Manual & Automated Repair", + f"""Searches the Library directory ({self.lib.library_dir}) for files with the same name as the missing one(s), and automatically repairs Entries which only point to one matching file. If there are multiple filename matches for one Entry, a manual selection screen appears after any automatic repairing.\nRecommended if you moved files and don\'t have use strictly unique filenames in your Library directory.""", + ), + ( + "Search with Automated Repair Only", + "Same as above, only skipping the manual step.", + ), + ( + "Remove Entries", + """Removes Entries from the Library which point to missing files.\nOnly use if you know why a file is missing, and/or don\'t wish to keep that Entry\'s data.""", + ), + ] + prompt: str = "Choose how you want to repair Entries that point to missing files." + selection: int = self.scr_choose_option( + subtitle=subtitle, choices=choices, prompt=prompt + ) + + if selection >= 0 and not self.is_missing_count_init: + print( + f"{INFO} Checking for missing files in '{self.lib.library_dir}'..." + ) + self.lib.refresh_missing_files() + + if selection == 0: + print( + f"{INFO} Attempting to resolve {len(self.lib.missing_files)} missing files in '{self.lib.library_dir}' (This will take long for several results)..." + ) + self.lib.fix_missing_files() + + fixed_indices = [] + if self.lib.missing_matches: + clear() + 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 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]}" + ) + self.lib.entries[ + self.lib.get_entry_id_from_filepath( + unresolved + ) + ].path = self.lib.missing_matches[ + unresolved + ][res] + fixed_indices.append(unresolved) + elif res and int(res) < 0: + clear() + print( + f"{INFO} Skipped match resolution selection.." + ) + if self.args.external_preview: + self.set_external_preview_default() + self.lib.remove_missing_matches(fixed_indices) + elif selection == 1: + print( + f"{INFO} Attempting to resolve missing files in '{self.lib.library_dir}' (This may take a LOOOONG while)..." + ) + self.lib.fix_missing_files() + elif selection == 2: + print( + f"{WARNING} Remove all Entries pointing to missing files? (Y/N)\n>{RESET} ", + end="", + ) + confirmation = input() + if ( + confirmation.lower() == "y" + or confirmation.lower() == "yes" + ): + deleted = [] + for i, missing in enumerate(self.lib.missing_files): + print( + f"Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries" + ) + try: + id = self.lib.get_entry_id_from_filepath( + missing + ) + print( + f"Removing Entry ID {id}:\n\t{missing}" + ) + self.lib.remove_entry(id) + self.driver.purge_item_from_navigation( + ItemType.ENTRY, id + ) + deleted.append(missing) + except KeyError: + print( + f'{ERROR} "{id}" was reported as missing, but is not in the file_to_entry_id map.' + ) + for d in deleted: + self.lib.missing_files.remove(d) + # for missing in self.lib.missing_files: + # try: + # index = self.lib.get_entry_index_from_filename(missing) + # print(f'Removing Entry at Index [{index+1}/{len(self.lib.entries)}]:\n\t{missing}') + # self.lib.remove_entry(index) + # except KeyError: + # print( + # f'{ERROR} \"{index}\" was reported as missing, but is not in the file_to_entry_index map.') + + if selection >= 0: + print( + f"{INFO} Checking for missing files in '{self.lib.library_dir}'..." + ) + self.lib.refresh_missing_files() + self.is_missing_count_init = True + + # Fix Duplicates =============================================================== + elif com[1].lower() == "duplicate" or com[1].lower() == "dupe": + if len(com) > 2: + # Fix Duplicate Entries ---------------------------------------------------- + if com[2].lower() == "entries" or com[2].lower() == "e": + subtitle = f"Fix Duplicate Entries" + choices: list[(str, str)] = [ + ( + "Merge", + f"Each Entry pointing to the same file will have their data merged into a single remaining Entry.", + ) + ] + prompt: str = "Choose how you want to address groups of Entries which point to the same file." + selection: int = self.scr_choose_option( + subtitle=subtitle, + choices=choices, + prompt=prompt, + ) + + if selection == 0: + if self.is_dupe_entry_count_init: + print( + f"{WARNING} Are you sure you want to merge {len(self.lib.dupe_entries)} Entries? (Y/N)\n> ", + end="", + ) + else: + print( + f"{WARNING} Are you sure you want to merge any duplicate Entries? (Y/N)\n> ", + end="", + ) + confirmation = input() + if ( + confirmation.lower() == "y" + or confirmation.lower() == "yes" + ): + if not self.is_dupe_entry_count_init: + print( + f"{INFO} Checking for duplicate entries in Library '{self.lib.library_dir}'..." + ) + self.lib.refresh_dupe_entries() + self.lib.merge_dupe_entries() + self.is_dupe_entry_count_init = False + # Fix Duplicate Entries ---------------------------------------------------- + elif com[2].lower() == "files" or com[2].lower() == "f": + subtitle = f"Fix Duplicate Files" + choices: list[(str, str)] = [ + ( + "Mirror", + f"""For every predetermined duplicate file, mirror those files\' Entries with each other.\nMirroring involves merging all Entry field data together and then duplicating it across each Entry.\nThis process does not delete any Entries or files.""", + ) + ] + prompt: str = """Choose how you want to address handling data for files considered to be duplicates by an application such as DupeGuru. It\'s recommended that you mirror data here, then manually delete the duplicate files based on your own best judgement. Afterwards run \"fix missing\" and choose the \"Remove Entries\" option.""" + selection: int = self.scr_choose_option( + subtitle=subtitle, + choices=choices, + prompt=prompt, + ) + + if selection == 0: + if self.is_dupe_file_count_init: + print( + f"{WARNING} Are you sure you want to mirror Entry fields for {len(self.lib.dupe_files)} duplicate files? (Y/N)\n> ", + end="", + ) + else: + print( + f"{WARNING} Are you sure you want to mirror any Entry felids for duplicate files? (Y/N)\n> ", + end="", + ) + confirmation = input() + if ( + confirmation.lower() == "y" + or confirmation.lower() == "yes" + ): + print( + f"{INFO} Mirroring {len(self.lib.dupe_files)} Entries for duplicate files..." + ) + for i, dupe in enumerate( + self.lib.dupe_files + ): + entry_id_1 = ( + self.lib.get_entry_id_from_filepath( + dupe[0] + ) + ) + entry_id_2 = ( + self.lib.get_entry_id_from_filepath( + dupe[1] + ) + ) + self.lib.mirror_entry_fields( + [entry_id_1, entry_id_2] + ) + clear() + else: + clear() + print( + f'{ERROR} Invalid duplicate type "{" ".join(com[2:])}".' + ) + clear_scr = False + else: + clear() + print( + f'{ERROR} Specify which duplicates to fix (entries, files, etc) "{" ".join(com)}".' + ) + clear_scr = False + else: + clear() + print( + f'{ERROR} Invalid fix selection "{" ".join(com[1:])}". Try "fix missing", "fix dupe entries", etc.' + ) + clear_scr = False + # # Save to Disk ========================================================= + # elif com[0].lower() in ['save', 'write', 'w']: + # self.lib.save_library_to_disk() + # clear() + # print( + # f'{INFO} Library saved to disk.') + # clear_scr = False + # # Save Backup to Disk ========================================================= + # elif (com[0].lower() == 'backup'): + # self.backup_library() + # clear_scr = False + # Close ============================================================ + elif com[0].lower() == "close" or com[0].lower() == "c": + # self.core.clear_internal_vars() + self.close_library() + # clear() + return + # Unknown Command ================================================== + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + # self.scr_library_home(clear_scr=False) + + def scr_browse_entries_gallery(self, index, clear_scr=True, refresh=True): + """Gallery View for browsing Library Entries.""" + + branch = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + while True: + # try: + if refresh: + if clear_scr: + clear() + clear_scr = True + + print(self.format_title(title)) + + 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]) + filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' + # if self.lib.is_legacy_library: + # title += ' (Legacy Format)' + h1 = f"[{index + 1}/{len(self.filtered_entries)}] {filename}" + + # print(self.format_subtitle(subtitle)) + print( + self.format_h1( + h1, self.get_file_color(os.path.splitext(filename)[1]) + ) + ) + print("") + + if not os.path.isfile(filename): + print( + f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}" + ) + print("") + if self.args.external_preview: + self.set_external_preview_broken() + else: + self.print_thumbnail(self.filtered_entries[index][1]) + + self.print_fields(self.filtered_entries[index][1]) + else: + if self.lib.entries: + print( + self.format_h1( + "No Entry Results for Query", color=BRIGHT_RED_FG + ) + ) + self.set_external_preview_default() + else: + print( + self.format_h1("No Entries in Library", color=BRIGHT_RED_FG) + ) + self.set_external_preview_default() + print("") + + print("") + print( + self.format_subtitle( + "Prev Next Goto <#> Open File Search List Tags", + BRIGHT_MAGENTA_FG, + ) + ) + print( + self.format_subtitle( + "Add, Remove, Edit Remove Close Quit", + BRIGHT_MAGENTA_FG, + ) + ) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + # except IndexError: + # clear() + # print(f'{INFO} No matches found for query') + # # self.scr_library_home(clear_scr=False) + # # clear_scr=False + # return + + # Previous ============================================================= + if ( + com[0].lower() == "prev" + or com[0].lower() == "p" + or com[0].lower() == "previous" + ): + if len(com) > 1: + try: + # self.scr_browse_entries_gallery( + # (index - int(com[1])) % len(self.filtered_entries)) + # return + index = (index - int(com[1])) % len( + self.filtered_entries + ) + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except (IndexError, ValueError): + clear() + print(f"{ERROR} Invalid \"Previous\" Index: '{com[1]}'") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + else: + # self.scr_browse_entries_gallery( + # (index - 1) % len(self.filtered_entries)) + # return + index = (index - 1) % len(self.filtered_entries) + # Next ================================================================= + elif com[0].lower() == "next" or com[0].lower() == "n": + if len(com) > 1: + try: + # NOTE: Will returning this as-is instead of after screw up the try-catch? + index = (index + int(com[1])) % len( + self.filtered_entries + ) + # self.scr_browse_entries_gallery( + # (index + int(com[1])) % len(self.filtered_entries)) + # return + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except (IndexError, ValueError): + clear() + print(f"{ERROR} Invalid \"Next\" Index: '{com[1]}'") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + else: + # self.scr_browse_entries_gallery( + # (index + 1) % len(self.filtered_entries)) + # return + index = (index + 1) % len(self.filtered_entries) + # Goto ================================================================= + elif (com[0].lower() == "goto" or com[0].lower() == "g") and len( + com + ) > 1: + try: + if int(com[1]) - 1 < 0: + raise IndexError + if int(com[1]) > len(self.filtered_entries): + raise IndexError + # self.scr_browse_entries_gallery(int(com[1])-1) + # return + index = int(com[1]) - 1 + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except (IndexError, ValueError): + clear() + print(f"{ERROR} Invalid \"Goto\" Index: '{com[1]}'") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # Search =============================================================== + elif com[0].lower() == "search" or com[0].lower() == "s": + if len(com) > 1: + self.filtered_entries = self.lib.search_library( + " ".join(com[1:]) + ) + # self.scr_browse_entries_gallery(0) + index = 0 + else: + self.filtered_entries = self.lib.search_library() + # self.scr_browse_entries_gallery(0) + index = 0 + # running = False + # return + # self.scr_library_home(clear_scr=False) + # return + # # Toggle Debug =========================================================== + # elif (com[0].lower() == 'toggle-debug'): + # self.args.debug = not self.args.debug + # Open with Default Application ======================================== + elif com[0].lower() == "open" or com[0].lower() == "o": + if len(com) > 1: + if com[1].lower() == "location" or com[1].lower() == "l": + open_file(filename, True) + else: + open_file(filename) + # refresh=False + # self.scr_browse_entries_gallery(index) + # Add Field ============================================================ + elif com[0].lower() == "add" or com[0].lower() == "a": + if len(com) > 1: + id_list = self.lib.filter_field_templates( + " ".join(com[1:]).lower() + ) + if id_list: + final_ids = [] + if len(id_list) == 1: + final_ids.append(id_list[0]) + else: + final_ids = self.scr_select_field_templates(id_list) + + for id in final_ids: + if id >= 0: + self.lib.add_field_to_entry( + self.filtered_entries[index][1], id + ) + # self.scr_browse_entries_gallery(index) + # return + # else: + # clear() + # print(f'{ERROR} Invalid selection.') + # return self.scr_browse_entries_gallery(index, clear_scr=False) + + else: + clear() + print(f"{INFO} Please specify a field to add.") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # self.scr_browse_entries_gallery(index) + # return + # Remove Field ========================================================= + elif com[0].lower() == "remove" or com[0].lower() == "rm": + if len(com) > 1: + # entry_fields = self.lib.get_entry_from_index( + # self.filtered_entries[index]).fields + entry_fields = self.lib.get_entry( + self.filtered_entries[index][1] + ).fields + field_indices: list[int] = [] + for i, f in enumerate(entry_fields): + if int( + self.lib.get_field_attr(f, "id") + ) in self.lib.filter_field_templates( + " ".join(com[1:]).lower() + ): + field_indices.append(i) + + try: + final_field_index = -1 + # if len(field_indices) == 1: + # final_index = field_indices[0] + # NOTE: The difference between this loop and Edit is that it always asks + # you to specify the field, even if there is only one option. + if len(field_indices) >= 1: + print(field_indices) + print(entry_fields) + print( + [ + self.lib.get_field_attr( + entry_fields[x], "id" + ) + for x in field_indices + ] + ) + final_field_index = field_indices[ + self.scr_select_field_templates( + [ + self.lib.get_field_attr( + entry_fields[x], "id" + ) + for x in field_indices + ], + allow_multiple=False, + mode="remove", + return_index=True, + )[0] + ] + else: + clear() + print( + f'{ERROR} Entry does not contain the field "{" ".join(com[1:])}".' + ) + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except IndexError: + pass + + if final_field_index >= 0: + self.lib.get_entry( + self.filtered_entries[index][1] + ).fields.pop(final_field_index) + # self.lib.entries[self.filtered_entries[index]].fields.pop( + # final_field_index) + else: + clear() + print(f"{INFO} Please specify a field to remove.") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # self.scr_browse_entries_gallery(index) + # return + # Edit Field =========================================================== + elif com[0].lower() == "edit" or com[0].lower() == "e": + if len(com) > 1: + # entry_fields = self.lib.get_entry_from_index( + # self.filtered_entries[index]).fields + entry_fields = self.lib.get_entry( + self.filtered_entries[index][1] + ).fields + field_indices: list[int] = [] + for i, f in enumerate(entry_fields): + if int( + self.lib.get_field_attr(f, "id") + ) in self.lib.filter_field_templates( + " ".join(com[1:]).lower() + ): + field_indices.append(i) + + try: + final_field_index = -1 + if len(field_indices) == 1: + final_field_index = field_indices[0] + elif len(field_indices) > 1: + print(field_indices) + print(entry_fields) + print( + [ + self.lib.get_field_attr( + entry_fields[x], "id" + ) + for x in field_indices + ] + ) + final_field_index = field_indices[ + self.scr_select_field_templates( + [ + self.lib.get_field_attr( + entry_fields[x], "id" + ) + for x in field_indices + ], + allow_multiple=False, + mode="edit", + return_index=True, + )[0] + ] + else: + clear() + print( + f'{ERROR} Entry does not contain the field "{" ".join(com[1:])}".' + ) + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except IndexError: + pass + + if final_field_index >= 0: + if ( + self.lib.get_field_attr( + entry_fields[final_field_index], "type" + ) + == "tag_box" + ): + self.scr_edit_entry_tag_box( + self.filtered_entries[index][1], + field_index=final_field_index, + ) + elif ( + self.lib.get_field_attr( + entry_fields[final_field_index], "type" + ) + == "text_line" + ): + self.scr_edit_entry_text( + self.filtered_entries[index][1], + field_index=final_field_index, + allow_newlines=False, + ) + elif ( + self.lib.get_field_attr( + entry_fields[final_field_index], "type" + ) + == "text_box" + ): + self.scr_edit_entry_text( + self.filtered_entries[index][1], + field_index=final_field_index, + ) + else: + clear() + print( + f'{INFO} Sorry, this type of field ({self.lib.get_field_attr(entry_fields[final_field_index], "type")}) isn\'t editable yet.' + ) + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + else: + clear() + print(f"{INFO} Please specify a field to edit.") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # self.scr_browse_entries_gallery(index) + # return + # Copy Field =========================================================== + elif com[0].lower() == "copy" or com[0].lower() == "cp": + # NOTE: Nearly identical code to the Edit section. + if len(com) > 1: + # entry_fields = self.lib.get_entry_from_index( + # self.filtered_entries[index]).fields + entry_fields = self.lib.get_entry( + self.filtered_entries[index][1] + ).fields + field_indices: list[int] = [] + for i, f in enumerate(entry_fields): + if int( + self.lib.get_field_attr(f, "id") + ) in self.lib.filter_field_templates( + " ".join(com[1:]).lower() + ): + field_indices.append(i) + + # try: + final_field_index = -1 + if len(field_indices) == 1: + final_field_index = field_indices[0] + elif len(field_indices) > 1: + print(field_indices) + print(entry_fields) + print( + [ + self.lib.get_field_attr(entry_fields[x], "id") + for x in field_indices + ] + ) + final_field_index = field_indices[ + self.scr_select_field_templates( + [ + self.lib.get_field_attr( + entry_fields[x], "id" + ) + for x in field_indices + ], + allow_multiple=False, + mode="edit", + return_index=True, + )[0] + ] + else: + clear() + print( + f'{ERROR} Entry does not contain the field "{" ".join(com[1:])}".' + ) + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + # except: + # pass + + if final_field_index >= 0: + self.copy_field_to_buffer( + entry.fields[final_field_index] + ) + # refresh = False + else: + clear() + print(f"{INFO} Please specify a field to copy.") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # self.scr_browse_entries_gallery(index) + # return + # Paste Field =========================================================== + elif com[0].lower() == "paste" or com[0].lower() == "ps": + self.paste_field_from_buffer(self.filtered_entries[index][1]) + # self.scr_browse_entries_gallery(index) + # return + # Run Macro ============================================================ + elif len(com) > 1 and com[0].lower() == "run": + if len(com) > 2 and com[1].lower() == "macro": + macro_name = (com[2]).lower() + if len(com) > 3: + # Run on all filtered Entries + if ( + com[-1].lower() == "--all" + or com[-1].lower() == "-a" + ): + clear() + print( + f'{INFO} Running Macro "{macro_name}" on {len(self.filtered_entries)} Entries...' + ) + for type, id in self.filtered_entries: + self.run_macro(name=macro_name, entry_id=id) + # self.scr_browse_entries_gallery(index) + else: + # Run on current Entry + self.run_macro( + name=macro_name, + entry_id=self.filtered_entries[index][1], + ) + # self.scr_browse_entries_gallery(index) + # return + else: + clear() + print(f"{ERROR} Please specify a Macro to run.") + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + # List Tags ============================================================ + elif (com[0].lower() == "list" or com[0].lower() == "ls") and len( + com + ) > 1: + if com[1].lower() == "tags": + clear() + self.scr_list_tags(tag_ids=self.lib.search_tags("")) + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + # self.scr_browse_entries_gallery(index, clear_scr=False) + + # return + # # Save to Disk ========================================================= + # elif (com[0].lower() == 'save' or com[0].lower() == 'write' or com[0].lower() == 'w'): + # self.lib.save_library_to_disk() + # clear() + # print( + # f'{INFO} Library saved to disk.') + # # self.scr_browse_entries_gallery(index, clear_scr=False) + # clear_scr = False + # # return + # # Save Backup to Disk ========================================================= + # elif (com[0].lower() == 'backup'): + # clear() + # self.backup_library() + # clear_scr = False + # Close View =========================================================== + elif com[0].lower() == "close" or com[0].lower() == "c": + if self.args.external_preview: + self.set_external_preview_default() + # self.scr_library_home() + clear() + return + # # Quit ================================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ================================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Unknown Command ====================================================== + elif com: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + # self.scr_browse_entries_gallery(index, clear_scr=False) + clear_scr = False + # return + + def scr_choose_option( + self, + subtitle: str, + choices: list, + prompt: str = "", + required=False, + clear_scr=True, + ) -> int: + """ + Screen for choosing one of a given set of generic options. + Takes in a list of (str,str) tuples which consist of (option name, option description), + with the description being optional. + Returns the index of the selected choice (starting at 0), or -1 if the choice was '0', 'Cancel', or 'C'. + """ + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + # invalid_input: bool = False + + while True: + if clear_scr: + clear() + clear_scr = True + + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + # if invalid_input: + # print(self.format_h1( + # str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) + # invalid_input = False + print("") + if prompt: + print(prompt) + print("") + print("") + + for i, choice in enumerate(choices, start=1): + print( + f"{BRIGHT_WHITE_BG}{BLACK_FG}[{str(i).zfill(len(str(len(choices))))}]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} {choice[0]} {RESET}" + ) + if choice[1]: + print(f"{WHITE_FG}{choice[1]}{RESET}") + print("") + + if not required: + print("") + print( + f"{BRIGHT_WHITE_BG}{BLACK_FG}[0]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} Cancel {RESET}" + ) + + print("") + if not required: + print( + self.format_subtitle("<#> 0 or Cancel Quit", BRIGHT_CYAN_FG) + ) + else: + print(self.format_subtitle("<#> Quit", BRIGHT_CYAN_FG)) + print("> ", end="") + + com: list[str] = input().strip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + com_name = com[0].lower() + + try: + # # Quit ========================================================= + # if com.lower() == 'quit' or com.lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ========================================== + # elif com.lower() == 'quit!' or com.lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Cancel ======================================================= + if com_name in ("cancel", "c", "0") and not required: + clear() + return -1 + # Selection ==================================================== + elif com_name.isdigit() and 0 < int(com_name) <= len(choices): + clear() + return int(com_name) - 1 + else: + # invalid_input = True + # print(self.format_h1(str='Please Enter a Valid Selection Number', color=BRIGHT_RED_FG)) + clear() + print(f"{ERROR} Please Enter a Valid Selection Number/Option.") + clear_scr = False + except (TypeError, ValueError): + clear() + print(f"{ERROR} Please Enter a Valid Selection Number/Option.") + clear_scr = False + + def scr_choose_missing_match(self, index, clear_scr=True, refresh=True) -> int: + """ + Screen for manually resolving a missing file. + Returns the index of the choice made (starting at 0), or -1 if skipped. + """ + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + subtitle = f"Resolve Missing File Conflict" + + while True: + entry = self.lib.get_entry_from_index(index) + filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' + + if refresh: + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + print("") + print(self.format_h1(filename, BRIGHT_RED_FG), end="\n\n") + + self.print_fields(index) + + for i, match in enumerate(self.lib.missing_matches[filename]): + print(self.format_h1(f"[{i+1}] {match}"), end="\n\n") + fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry.filename)}' + self.print_thumbnail( + index=-1, + filepath=fn, + max_width=( + os.get_terminal_size()[1] + // len(self.lib.missing_matches[filename]) + - 2 + ), + ) + if fn in self.lib.filename_to_entry_id_map.keys(): + self.print_fields(self.lib.get_entry_id_from_filepath(fn)) + print("") + print( + self.format_subtitle( + "<#> 0 to Skip Open Files Quit", BRIGHT_CYAN_FG + ) + ) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Refresh ============================================================== + if com[0].lower() == "refresh" or com[0].lower() == "r": + # if (com[0].lower() == 'refresh' or com[0].lower() == 'r') and len(com) > 1: + # if com[1].lower() == 'files' or com[1].lower() == 'dir': + # clear() + # return self.scr_choose_missing_match(index) + # else: + # clear() + # print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + # self.scr_library_home(clear_scr=False) + # clear_scr=False + pass + # Open ============================================================= + elif com[0].lower() == "open" or com[0].lower() == "o": + for match in self.lib.missing_matches[filename]: + fn = os.path.normpath( + self.lib.library_dir + "/" + match + "/" + entry.filename + ) + open_file(fn) + refresh = False + # clear() + # return self.scr_choose_missing_match(index, clear_scr=False) + # # Quit ============================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ============================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Selection/Other ================================================== + else: + try: + i = int(com[0]) - 1 + if i < len(self.lib.missing_matches[filename]): + if i < -1: + return -1 + else: + return i + else: + raise IndexError + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except (ValueError, IndexError): + clear() + print(f'{ERROR} Invalid command \'{" ".join(com)}\'') + # return self.scr_choose_missing_match(index, clear_scr=False) + clear_scr = False + + def scr_resolve_dupe_files(self, index, clear_scr=True): + """Screen for manually resolving duplicate files.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + subtitle = f"Resolve Duplicate Files" + + while True: + dupe = self.lib.dupe_files[index] + + if os.path.exists(os.path.normpath(f"{dupe[0]}")) and os.path.exists( + os.path.normpath(f"{dupe[1]}") + ): + # entry = self.lib.get_entry_from_index(index_1) + entry_1_index = self.lib.get_entry_id_from_filepath(dupe[0]) + entry_2_index = self.lib.get_entry_id_from_filepath(dupe[1]) + + if clear_scr: + clear() + clear_scr = True + + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + + print("") + print(f"{WHITE_BG}{BLACK_FG} Similarity: {RESET} ", end="") + print(f"{dupe[2]}%") + + # File 1 + print("") + print(self.format_h1(dupe[0], BRIGHT_RED_FG), end="\n\n") + print(f"{WHITE_BG}{BLACK_FG} File Size: {RESET} ", end="") + print(f"0 KB") + print(f"{WHITE_BG}{BLACK_FG} Resolution: {RESET} ", end="") + print(f"0x0") + if entry_1_index is not None: + print("") + self.print_fields(entry_1_index) + else: + print(f"{BRIGHT_RED_FG}No Library Entry for file.{RESET}") + + # File 2 + print("") + print(self.format_h1(dupe[1], BRIGHT_RED_FG), end="\n\n") + print(f"{WHITE_BG}{BLACK_FG} File Size: {RESET} ", end="") + print(f"0 KB") + print(f"{WHITE_BG}{BLACK_FG} Resolution: {RESET} ", end="") + print(f"0x0") + if entry_2_index is not None: + print("") + self.print_fields(entry_2_index) + else: + print(f"{BRIGHT_RED_FG}No Library Entry for file.{RESET}") + + # for i, match in enumerate(self.lib.missing_matches[filename]): + # print(self.format_h1(f'[{i+1}] {match}'), end='\n\n') + # fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry_1.filename)}' + # self.print_thumbnail(self.lib.get_entry_from_filename(fn), + # max_width=(os.get_terminal_size()[1]//len(self.lib.missing_matches[filename])-2)) + # self.print_fields(self.lib.get_entry_from_filename(fn)) + print("") + print( + self.format_subtitle( + "Mirror Delete <#> Skip Close Open Files Quit", + BRIGHT_CYAN_FG, + ) + ) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Refresh ========================================================== + if (com[0].lower() == "refresh" or com[0].lower() == "r") and len( + com + ) > 1: + # if com[1].lower() == 'files' or com[1].lower() == 'dir': + # clear() + # return self.scr_resolve_dupe_files(index, clear_scr=True) + # else: + # clear() + # print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + # self.scr_library_home(clear_scr=False) + pass + # Open ============================================================= + elif com[0].lower() == "open" or com[0].lower() == "o": + # for match in self.lib.missing_matches[filename]: + # fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry_1.filename)}' + # open_file(fn) + open_file(dupe[0]) + open_file(dupe[1]) + # clear() + # return self.scr_resolve_dupe_files(index, clear_scr=False) + # Mirror Entries =================================================== + elif com[0].lower() == "mirror" or com[0].lower() == "mir": + return com + # Skip ============================================================ + elif com[0].lower() == "skip": + return com + # Skip ============================================================ + elif ( + com[0].lower() == "close" + or com[0].lower() == "cancel" + or com[0].lower() == "c" + ): + return ["close"] + # Delete =========================================================== + elif com[0].lower() == "delete" or com[0].lower() == "del": + if len(com) > 1: + if com[1] == "1": + return ["del", 1] + elif com[1] == "2": + return ["del", 2] + else: + # return self.scr_resolve_dupe_files(index) + pass + else: + clear() + print( + f"{ERROR} Please specify which file (ex. delete 1, delete 2) to delete file." + ) + # return self.scr_resolve_dupe_files(index, clear_scr=False) + clear_scr = False + # # Quit ============================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ============================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Other ============================================================ + else: + # try: + # i = int(com[0]) - 1 + # if i < len(self.lib.missing_matches[filename]): + # return i + # else: + # raise IndexError + # except SystemExit: + # sys.exit() + # except: + clear() + print(f'{ERROR} Invalid command \'{" ".join(com)}\'') + # return self.scr_resolve_dupe_files(index, clear_scr=False) + clear_scr = False + + def scr_edit_entry_tag_box(self, entry_index, field_index, clear_scr=True): + """Screen for editing an Entry tag-box field.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + entry = self.lib.entries[entry_index] + filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' + field_name = self.lib.get_field_attr(entry.fields[field_index], "name") + subtitle = f'Editing "{field_name}" Field' + h1 = f"{filename}" + + while True: + if clear_scr: + clear() + clear_scr = True + + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + print( + self.format_h1(h1, self.get_file_color(os.path.splitext(filename)[1])) + ) + print("") + + if not os.path.isfile(filename): + print( + f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}" + ) + print("") + else: + self.print_thumbnail(entry_index) + + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} {field_name}: {RESET} ") + for i, tag_id in enumerate( + entry.fields[field_index][list(entry.fields[field_index].keys())[0]] + ): + tag = self.lib.get_tag(tag_id) + print( + f"{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}" + ) + # if tag_id != field[field_id][-1]: + # print(' ', end='') + print("") + + print( + self.format_subtitle( + "Add Remove <#> Open File Close/Done Quit" + ) + ) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Open with Default Application ======================================== + if com[0].lower() == "open" or com[0].lower() == "o": + open_file(filename) + # self.scr_edit_entry_tag_box(entry_index, field_index) + # return + # Close View =========================================================== + elif ( + com[0].lower() == "close" + or com[0].lower() == "c" + or com[0].lower() == "done" + ): + # self.scr_browse_entries_gallery() + clear() + return + # # Quit ================================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ================================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Add Tag ============================================================== + elif com[0].lower() == "add": + if len(com) > 1: + tag_list = self.lib.search_tags( + " ".join(com[1:]), include_cluster=True + ) + t: list[int] = [] + if len(tag_list) > 1: + t = self.scr_select_tags(tag_list) + else: + t = tag_list # Single Tag + if t: + self.lib.update_entry_field( + entry_index, field_index, content=t, mode="append" + ) + # self.scr_edit_entry_tag_box(entry_index, field_index) + # return + # Remove Tag =========================================================== + elif com[0].lower() == "remove" or com[0].lower() == "rm": + if len(com) > 1: + try: + selected_tag_ids: list[int] = [] + for c in com[1:]: + if (int(c) - 1) < 0: + raise IndexError + # print(self.lib.get_field_attr(entry.fields[field_index], 'content')) + # print(self.lib.get_field_attr(entry.fields[field_index], 'content')[int(c)-1]) + selected_tag_ids.append( + self.lib.get_field_attr( + entry.fields[field_index], "content" + )[int(c) - 1] + ) + # i = int(com[1]) - 1 + + # tag = entry.fields[field_index][list( + # entry.fields[field_index].keys())[0]][i] + self.lib.update_entry_field( + entry_index, + field_index, + content=selected_tag_ids, + mode="remove", + ) + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except: + clear() + print(f"{ERROR} Invalid Tag Selection '{com[1:]}'") + clear_scr = False + # self.scr_edit_entry_tag_box( + # entry_index, field_index, clear_scr=False) + # return + # self.scr_edit_entry_tag_box(entry_index, field_index) + # return + # Unknown Command ====================================================== + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + # self.scr_edit_entry_tag_box( + # entry_index, field_index, clear_scr=False) + # return + clear_scr = False + + def scr_select_tags(self, tag_ids: list[int], clear_scr=True) -> list[int]: + """Screen for selecting and returning one or more Tags. Used for Entry editing.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + subtitle = f"Select Tag(s) to Add" + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_GREEN_BG}")) + print(self.format_subtitle(subtitle, BRIGHT_GREEN_FG)) + # print(self.format_h1(h1, self.get_file_color( + # os.path.splitext(filename)[1]))) + print("") + + tag_tuple_list = [] + for tag_id in tag_ids: + tag = self.lib.get_tag(tag_id) + tag_tuple_list.append( + (tag.display_name(self.lib), self.get_tag_color(tag.color)) + ) + + self.print_columns(tag_tuple_list, add_enum=True) + print("") + + print(self.format_subtitle("Enter #(s) Cancel", BRIGHT_GREEN_FG)) + print("> ", end="") + + com: list[str] = input().rstrip().split(" ") + selected_ids: list[int] = [] + try: + for c in com: + selected_ids.append(tag_ids[int(c) - 1]) + except SystemExit: + self.cleanup_before_exit() + sys.exit() + except: + print(f"{ERROR} Invalid Tag Selection") + + return selected_ids + + # TODO: This can be replaced by the new scr_choose_option method. + def scr_select_field_templates( + self, + field_ids: list[int], + allow_multiple=True, + mode="add", + return_index=False, + clear_scr=True, + ) -> list[int]: + """ + Screen for selecting and returning one or more Field Templates. Used for Entry editing. + Allow Multiple: Lets the user select multiple items, returned in a list. If false, returns a list of only the first selected item. + Mode: 'add', 'edit', 'remove' - Changes prompt text and colors. + Return Index: Instead of returning the Field IDs that were selected, this returns the indices of the selected items from the given list. + """ + + branch = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" + title = ( + f"TagStudio {VERSION}{branch} - CLI Mode - Library '{self.lib.library_dir}'" + ) + subtitle = f"Select Field(s) to Add" + plural = "(s)" + + if not allow_multiple: + plural = "" + + fg_text_color = BLACK_FG + fg_color = BRIGHT_GREEN_FG + bg_color = BRIGHT_GREEN_BG + if mode == "edit": + fg_color = BRIGHT_CYAN_FG + bg_color = BRIGHT_CYAN_BG + subtitle = f"Select Field{plural} to Edit" + elif mode == "remove": + fg_color = BRIGHT_RED_FG + bg_color = BRIGHT_RED_BG + # fg_text_color = BRIGHT_WHITE_FG + subtitle = f"Select Field{plural} to Remove" + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) + print(self.format_subtitle(subtitle, fg_color)) + # print(self.format_h1(h1, self.get_file_color( + # os.path.splitext(filename)[1]))) + print("") + + for i, field_id in enumerate(field_ids): + name = self.lib.get_field_obj(field_id)["name"] + type = self.lib.get_field_obj(field_id)["type"] + if i < (os.get_terminal_size()[1] - 7): + print( + f"{BRIGHT_WHITE_BG}{BLACK_FG}[{i+1}]{RESET} {BRIGHT_WHITE_BG}{BLACK_FG} {name} ({type}) {RESET}" + ) + else: + print(f"{WHITE_FG}[...]{RESET}") + break + print("") + + print(self.format_subtitle(f"Enter #{plural} Cancel", fg_color)) + print("> ", end="") + + com: list[str] = input().split(" ") + selected_ids: list[int] = [] + try: + for c in com: + if int(c) > 0: + if return_index: + selected_ids.append(int(c) - 1) + else: + selected_ids.append(field_ids[int(c) - 1]) + except SystemExit: + self.cleanup_before_exit() + sys.exit() + except: + print(f"{ERROR} Invalid Tag Selection") + + if not allow_multiple and selected_ids: + return [selected_ids[0]] + return selected_ids + + def scr_edit_entry_text( + self, entry_index, field_index, allow_newlines=True, clear_scr=True + ): + """Screen for editing an Entry text_line field.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + entry = self.lib.entries[entry_index] + filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}' + field_name = self.lib.get_field_attr(entry.fields[field_index], "name") + subtitle = f'Editing "{field_name}" Field' + h1 = f"{filename}" + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + print(self.format_h1(h1, self.get_file_color(os.path.splitext(filename)[1]))) + print("") + + if not os.path.isfile(filename): + print( + f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}" + ) + print("") + else: + self.print_thumbnail(entry_index, ignore_fields=True) + + print( + self.format_title( + "Opened with Default Text Editor", f"{BLACK_FG}{BRIGHT_CYAN_BG}" + ) + ) + # print('') + # print( + # f'{BRIGHT_WHITE_BG}{BLACK_FG} {field_name}: {RESET} ') + # print(self.lib.get_field_attr(entry.fields[field_index], 'content')) + # for i, tag_id in enumerate(entry.fields[field_index][list(entry.fields[field_index].keys())[0]]): + # tag = self.lib.get_tag_from_id(tag_id) + # print( + # f'{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}') + # print('') + + # print(self.format_subtitle( + # 'Add Remove <#> Open File Close/Done Quit')) + + # new_content: str = click.edit(self.lib.get_field_attr( + # entry.fields[field_index], 'content')) + new_content: str = "" # NOTE: Removing + if new_content is not None: + if not allow_newlines: + new_content = new_content.replace("\r", "").replace("\n", "") + 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] = 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}'" + + while True: + h1 = f"{len(self.lib.tags)} Tags" + + if tag_ids: + if len(tag_ids) < len(self.lib.search_tags("")): + h1 = f"[{len(tag_ids)}/{len(self.lib.tags)}] Tags" + if query: + h1 += f" connected to '{query}'" + else: + h1 = f"No Tags" + if query: + h1 += f" connected to '{query}'" + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title)) + print(self.format_h1(h1)) + print("") + + tag_tuple_list = [] + for tag_id in tag_ids: + tag = self.lib.get_tag(tag_id) + if self.args.debug: + tag_tuple_list.append( + (tag.debug_name(), self.get_tag_color(tag.color)) + ) + else: + tag_tuple_list.append( + (tag.display_name(self.lib), self.get_tag_color(tag.color)) + ) + + self.print_columns(tag_tuple_list, add_enum=True) + + print("") + print( + self.format_subtitle( + "Create Edit <#> Delete <#> Search Close/Done", + BRIGHT_MAGENTA_FG, + ) + ) + print("> ", end="") + + com: list[str] = input().strip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + com_name = com[0].lower() + # Search Tags ========================================================== + 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, + # tag_ids=self.lib.filter_tags(new_query, include_cluster=True)) + query = new_query + tag_ids = self.lib.search_tags(new_query, include_cluster=True) + # return + else: + # self.scr_list_tags(prev_scr, tag_ids=self.lib.filter_tags('')) + tag_ids = self.lib.search_tags("") + # return + # Edit Tag =========================================================== + elif com_name in ("edit", "e"): + if len(com) > 1: + try: + index = int(com[1]) - 1 + if index < 0: + raise IndexError + self.scr_manage_tag(tag_ids[index]) + + # Refilter in case edits change results + tag_ids = self.lib.search_tags(query, include_cluster=True) + # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) + # return + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except (ValueError, IndexError): + clear() + print(f'{ERROR} Invalid Selection \'{" ".join(com[1])}\'') + clear_scr = False + # self.scr_list_tags(prev_scr, query=query, + # tag_ids=tag_ids, clear_scr=False) + # return + + # Create Tag ============================================================ + elif com_name in ("create", "mk"): + tag = Tag( + id=0, + name="New Tag", + shorthand="", + aliases=[], + subtags_ids=[], + color="", + ) + self.scr_manage_tag(self.lib.add_tag_to_library(tag), mode="create") + + tag_ids = self.lib.search_tags(query, include_cluster=True) + + # self.scr_list_tags(prev_scr, query=query, tag_ids=tag_ids) + # return + # Delete Tag =========================================================== + elif com_name in ("delete", "del"): + if len(com) > 1: + if len(com) > 1: + try: + index = int(com[1]) - 1 + if index < 0: + raise IndexError + deleted = self.scr_delete_tag(tag_ids[index]) + if deleted: + tag_ids.remove(tag_ids[index]) + tag_ids = self.lib.search_tags( + query, include_cluster=True + ) + # self.scr_list_tags( + # prev_scr, query=query, tag_ids=tag_ids) + # return + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except IndexError: + clear() + print( + f'{ERROR} Invalid Selection \'{" ".join(com[1])}\'' + ) + clear_scr = False + # self.scr_list_tags(prev_scr, query=query, + # tag_ids=tag_ids, clear_scr=False) + # return + # Close View =========================================================== + elif com_name in ("close", "c", "done"): + # prev_scr() + return + # # Quit ================================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ================================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Unknown Command ====================================================== + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + # self.scr_list_tags(prev_scr, query=query, + # tag_ids=tag_ids, clear_scr=False) + # return + clear_scr = False + + def scr_top_tags(self, clear_scr=True) -> None: + """A screen that lists out the top tags for the library.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + while True: + h1 = f"Top Tags" + + # if tag_ids: + # if len(tag_ids) < len(self.lib.filter_tags('')): + # h1 = f'[{len(tag_ids)}/{len(self.lib.tags)}] Tags' + # if query: + # h1 += f' connected to \'{query}\'' + # else: + # h1 = f'No Tags' + # if query: + # h1 += f' connected to \'{query}\'' + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title)) + print(self.format_h1(h1)) + print("") + + tag_tuple_list = [] + for tag_id, count in self.lib.tag_entry_refs: + tag = self.lib.get_tag(tag_id) + if self.args.debug: + tag_tuple_list.append( + (f"{tag.debug_name()} - {count}", self.get_tag_color(tag.color)) + ) + else: + tag_tuple_list.append( + ( + f"{tag.display_name(self.lib)} - {count}", + self.get_tag_color(tag.color), + ) + ) + + self.print_columns(tag_tuple_list, add_enum=True) + + print("") + print(self.format_subtitle("Close/Done", BRIGHT_MAGENTA_FG)) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Close View =================================================== + if ( + com[0].lower() == "close" + or com[0].lower() == "c" + or com[0].lower() == "done" + ): + return + # Unknown Command ============================================== + elif com[0]: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + + def scr_manage_tag(self, tag_id: int, mode="edit", clear_scr=True): + """Screen for editing fields of a Tag object.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + while True: + tag: Tag = self.lib.get_tag(tag_id) + subtitle = ( + f'Editing Tag "{self.lib.get_tag(tag_id).display_name(self.lib)}"' + ) + # h1 = f'{self.lib.tags[tag_index].display_name()}' + + fg_text_color = BLACK_FG + fg_color = BRIGHT_CYAN_FG + bg_color = BRIGHT_CYAN_BG + if mode == "create": + subtitle = ( + f'Creating Tag "{self.lib.get_tag(tag_id).display_name(self.lib)}"' + ) + fg_color = BRIGHT_GREEN_FG + bg_color = BRIGHT_GREEN_BG + # elif mode == 'remove': + # # TODO: Uhh is this ever going to get used? Delete this when you know. + # subtitle = f'Removing Tag \"{self.lib.get_tag_from_id(tag_id).display_name(self.lib)}\"' + # fg_color = BRIGHT_RED_FG + # bg_color = BRIGHT_RED_BG + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) + print(self.format_subtitle(subtitle, fg_color)) + # print(self.format_h1(h1, self.get_file_color( + # os.path.splitext(filename)[1]))) + if self.args.debug: + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} ID: {RESET} ", end="") + print(tag.id) + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Name: {RESET} ", end="") + print(tag.name) + + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Shorthand: {RESET} ", end="") + print(tag.shorthand) + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Aliases: {RESET} ", end="\n") + for a in tag.aliases: + print(f"{a}") + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Subtags: {RESET} ", end="\n") + char_count: int = 0 + for id in tag.subtag_ids: + st = self.lib.get_tag(id) + # Properly wrap Tags on screen + char_count += len(f" {st.display_name(self.lib)} ") + 1 + if char_count > os.get_terminal_size()[0]: + print("") + char_count = len(f" {st.display_name(self.lib)} ") + 1 + print( + f"{self.get_tag_color(st.color)} {st.display_name(self.lib)} {RESET}", + end="", + ) + # If the tag isn't the last one, print a space for the next one. + if id != tag.subtag_ids[-1]: + print(" ", end="") + else: + print("") + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Color: {RESET} ", end="") + print(f"{self.get_tag_color(tag.color)} {tag.color.title()} {RESET}") + + print("") + print(self.format_subtitle("Edit Close/Done", fg_color)) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Edit Tag Field ======================================================= + if com[0].lower() == "edit" or com[0].lower() == "e": + if len(com) > 1: + selection: str = " ".join(com[1:]).lower() + if "id".startswith(selection) and self.args.debug: + clear() + print(f"{ERROR} Tag IDs are not editable.") + clear_scr = False + elif "name".startswith(selection): + new_name: str = self.scr_edit_text( + text=tag.name, field_name="Name", allow_newlines=False + ) + new_tag: Tag = Tag( + id=tag.id, + name=new_name, + shorthand=tag.shorthand, + aliases=tag.aliases, + subtags_ids=tag.subtag_ids, + color=tag.color, + ) + self.lib.update_tag(new_tag) + # self.scr_manage_tag(tag_id=tag_id, mode=mode) + # return + # clear_scr=False + elif "shorthand".startswith(selection): + new_shorthand: str = self.scr_edit_text( + text=tag.shorthand, + field_name="Shorthand", + allow_newlines=False, + ) + new_tag: Tag = Tag( + id=tag.id, + name=tag.name, + shorthand=new_shorthand, + aliases=tag.aliases, + subtags_ids=tag.subtag_ids, + color=tag.color, + ) + self.lib.update_tag(new_tag) + # self.scr_manage_tag(tag_id=tag_id, mode=mode) + # return + # clear_scr=False + elif "aliases".startswith(selection): + new_aliases: list[str] = self.scr_edit_text( + text="\n".join(tag.aliases), + field_name="Aliases", + note=f"# Tag Aliases Below Are Separated By Newlines", + allow_newlines=True, + ).split("\n") + new_tag: Tag = Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + aliases=new_aliases, + subtags_ids=tag.subtag_ids, + color=tag.color, + ) + self.lib.update_tag(new_tag) + # self.scr_manage_tag(tag_id=tag_id, mode=mode) + # return + # clear_scr=False + elif "subtags".startswith(selection): + new_subtag_ids: list[int] = self.scr_edit_generic_tag_box( + tag_ids=tag.subtag_ids, tag_box_name="Subtags" + ) + new_tag: Tag = Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + aliases=tag.aliases, + subtags_ids=new_subtag_ids, + color=tag.color, + ) + self.lib.update_tag(new_tag) + # self.scr_manage_tag(tag_id=tag_id, mode=mode) + # return + # clear_scr=False + elif "color".startswith(selection): + new_color: str = self.scr_tag_color_dropdown( + fallback=tag.color, colors=TAG_COLORS + ) + new_tag: Tag = Tag( + id=tag.id, + name=tag.name, + shorthand=tag.shorthand, + aliases=tag.aliases, + subtags_ids=tag.subtag_ids, + color=new_color, + ) + self.lib.update_tag(new_tag) + # self.scr_manage_tag(tag_id=tag_id, mode=mode) + # return + # clear_scr=False + else: + clear() + print(f'{ERROR} Unknown Tag field "{" ".join(com[1:])}".') + # self.scr_manage_tag(tag_id, mode, clear_scr=False) + # return + clear_scr = False + # Close View =========================================================== + elif ( + com[0].lower() == "close" + or com[0].lower() == "done" + or com[0].lower() == "c" + ): + return + # # Quit ================================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ================================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Unknown Command ====================================================== + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + clear_scr = False + # return self.scr_browse_entries_gallery(index, clear_scr=False) + + def scr_delete_tag(self, tag_id: int, clear_scr=True) -> bool: + """Screen for confirming the deletion of a Tag.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + tag: Tag = self.lib.get_tag(tag_id) + subtitle = f'Confirm Deletion of Tag "{self.lib.get_tag(tag_id).display_name(self.lib)}"' + # h1 = f'{self.lib.tags[tag_index].display_name()}' + entry_ref_count, subtag_ref_count = self.lib.get_tag_ref_count(tag_id) + + fg_text_color = BLACK_FG + fg_color = BRIGHT_RED_FG + bg_color = BRIGHT_RED_BG + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) + print(self.format_subtitle(subtitle, fg_color)) + print("") + + print( + f"{INFO} {BRIGHT_WHITE_FG}This Tag is in {fg_color}{entry_ref_count}{RESET}{BRIGHT_WHITE_FG} Entries{RESET} ", + end="", + ) + print("") + + print( + f"{INFO} {BRIGHT_WHITE_FG}This Tag is a Subtag for {fg_color}{subtag_ref_count}{RESET}{BRIGHT_WHITE_FG} Tags{RESET} ", + end="", + ) + print("") + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Name: {RESET} ", end="") + print(tag.name) + + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Shorthand: {RESET} ", end="") + print(tag.shorthand) + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Aliases: {RESET} ", end="\n") + for a in tag.aliases: + print(f"{a}") + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Subtags: {RESET} ", end="\n") + char_count: int = 0 + for id in tag.subtag_ids: + st = self.lib.get_tag(id) + # Properly wrap Tags on screen + char_count += len(f" {st.display_name(self.lib)} ") + 1 + if char_count > os.get_terminal_size()[0]: + print("") + char_count = len(f" {st.display_name(self.lib)} ") + 1 + print( + f"{self.get_tag_color(st.color)} {st.display_name(self.lib)} {RESET}", + end="", + ) + # If the tag isn't the last one, print a space for the next one. + if id != tag.subtag_ids[-1]: + print(" ", end="") + else: + print("") + + print("") + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} Color: {RESET} ", end="") + print(f"{self.get_tag_color(tag.color)} {tag.color.title()} {RESET}") + + print("") + print(self.format_subtitle("Yes Cancel", fg_color)) + print("> ", end="") + + com: str = input().rstrip() + + if com.lower() == "yes" or com.lower() == "y": + self.lib.remove_tag(tag_id) + return True + + return False + + def scr_edit_text( + self, + text: str, + field_name: str, + note: str = "", + allow_newlines=True, + clear_scr=True, + ) -> str: + """ + Screen for editing generic text. Currently used in Tag editing.\n + `text`: The text to be edited and returned.\n + `field_name`: The name to display of what is being edited.\n + `note`: An optional help message to display on screen for users..\n + `allow_newlines`: Determines if the text should be allowed to contain newlines.\n + """ + # NOTE: This code is derived from scr_edit_entry_text, just without the + # specific entry stuff like filenames and preview images. There may be + # a good way to combine the methods in the future, but for now here's this. + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + subtitle = f'Editing "{field_name}"' + + if clear_scr: + clear() + clear_scr = True + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + print("") + + print( + self.format_title( + "Opened with Default Text Editor", f"{BLACK_FG}{BRIGHT_CYAN_BG}" + ) + ) + + # new_text: str = click.edit(text) + new_text: str = input() + if new_text is not None: + if not allow_newlines: + new_text = new_text.replace("\r", "").replace("\n", "") + else: + new_text = new_text.rstrip("\n").rstrip("\r") + return new_text + return text + + def scr_tag_color_dropdown( + self, fallback: str, colors: list[str], clear_scr=True + ) -> str: + """ + Screen for selecting and returning a string of a color name. Used in Tag editing. + Fallback: The value to return if an invalid selection by the user was made. + """ + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + subtitle = f"Select Color" + + fg_text_color = BLACK_FG + fg_color = BRIGHT_CYAN_FG + bg_color = BRIGHT_CYAN_BG + + if clear_scr: + clear() + clear_scr = True + + print(self.format_title(title, color=f"{fg_text_color}{bg_color}")) + print(self.format_subtitle(subtitle, fg_color)) + print("") + + color_tuple_list = [] + for color in colors: + color_tuple_list.append((color.title(), self.get_tag_color(color))) + + self.print_columns(color_tuple_list, add_enum=True) + print("") + + # for i, color in enumerate(colors): + # if i < (os.get_terminal_size()[1] - 7): + # print( + # f'{self.get_tag_color(color)}[{i+1}]{RESET} {self.get_tag_color(color)} {color.title()} {RESET}') + # else: + # print(f'{WHITE_FG}[...]{RESET}') + # break + # print('') + + print(self.format_subtitle(f"Enter # Cancel", fg_color)) + print("> ", end="") + + selected: str = input() + try: + if selected.isdigit() and 0 < int(selected) <= len(colors): + selected = colors[int(selected) - 1] + return selected + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except: + print(f"{ERROR} Invalid Tag Selection") + + return fallback + + def scr_edit_generic_tag_box( + self, tag_ids: list[int], tag_box_name: str, clear_scr=True + ) -> list[int]: + """Screen for editing a generic tag_box. Used in Tag subtag modification.""" + + title = f"{self.base_title} - Library '{self.lib.library_dir}'" + + while True: + subtitle = f"Editing {tag_box_name}" + + if clear_scr: + clear() + clear_scr = True + + print(self.format_title(title, color=f"{BLACK_FG}{BRIGHT_CYAN_BG}")) + print(self.format_subtitle(subtitle)) + print("") + + print(f"{BRIGHT_WHITE_BG}{BLACK_FG} {tag_box_name}: {RESET} ") + for i, id in enumerate(tag_ids): + tag = self.lib.get_tag(id) + print( + f"{self.get_tag_color(tag.color)}[{i+1}]{RESET} {self.get_tag_color(tag.color)} {tag.display_name(self.lib)} {RESET}" + ) + print("") + + print( + self.format_subtitle( + "Add Remove <#> Close/Done Quit" + ) + ) + print("> ", end="") + + com: list[str] = input().lstrip().rstrip().split(" ") + gc, message = self.global_commands(com) + if gc: + if message: + clear() + print(message) + clear_scr = False + else: + # Add Tag ============================================================== + if com[0].lower() == "add": + if len(com) > 1: + tag_list = self.lib.search_tags( + " ".join(com[1:]), include_cluster=True + ) + selected_ids: list[int] = [] + if len(tag_list) > 1: + selected_ids = self.scr_select_tags(tag_list) + else: + selected_ids = tag_list # Single Tag + if selected_ids: + for id in selected_ids: + if id in tag_ids: + selected_ids.remove(id) + return self.scr_edit_generic_tag_box( + tag_ids + selected_ids, tag_box_name + ) + tag_ids = tag_ids + selected_ids + # else: + # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name) + # Remove Tag =========================================================== + elif com[0].lower() == "remove" or com[0].lower() == "rm": + if len(com) > 1: + try: + # selected_tag_ids: list[int] = [] + # for c in com[1:]: + # if (int(c)-1) < 0: + # raise IndexError + # selected_tag_ids.append(tag_ids[int(c[1])-1]) + selected_id = tag_ids[int(com[1]) - 1] + tag_ids.remove(selected_id) + # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name) + # except SystemExit: + # self.cleanup_before_exit() + # sys.exit() + except: + clear() + print(f"{ERROR} Invalid Tag Selection '{com[1:]}'") + # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name, clear_scr=False) + clear_scr = False + # Close View =========================================================== + elif ( + com[0].lower() == "close" + or com[0].lower() == "c" + or com[0].lower() == "done" + ): + # clear() + # pass + return tag_ids + # # Quit ================================================================= + # elif com[0].lower() == 'quit' or com[0].lower() == 'q': + # self.lib.save_library_to_disk() + # # self.cleanup() + # sys.exit() + # # Quit without Saving ================================================== + # elif com[0].lower() == 'quit!' or com[0].lower() == 'q!': + # # self.cleanup() + # sys.exit() + # Unknown Command ====================================================== + else: + clear() + print(f'{ERROR} Unknown command \'{" ".join(com)}\'') + # return self.scr_edit_generic_tag_box(tag_ids, tag_box_name, clear_scr=False) + clear_scr = False + + # return tag_ids diff --git a/tagstudio/src/core/field_template.py b/tagstudio/src/core/field_template.py index 88cc446..bb3313b 100644 --- a/tagstudio/src/core/field_template.py +++ b/tagstudio/src/core/field_template.py @@ -2,6 +2,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio + class FieldTemplate: """A TagStudio Library Field Template object.""" @@ -11,7 +12,7 @@ class FieldTemplate: self.type = type def __str__(self) -> str: - return f'\nID: {self.id}\nName: {self.name}\nType: {self.type}\n' + return f"\nID: {self.id}\nName: {self.name}\nType: {self.type}\n" def __repr__(self) -> str: return self.__str__() @@ -20,8 +21,8 @@ class FieldTemplate: """An alternative to __dict__ that only includes fields containing non-default data.""" obj = {} # All Field fields (haha) are mandatory, so no value checks are done. - obj['id'] = self.id - obj['name'] = self.name - obj['type'] = self.type + obj["id"] = self.id + obj["name"] = self.name + obj["type"] = self.type return obj diff --git a/tagstudio/src/core/json_typing.py b/tagstudio/src/core/json_typing.py index 24abd73..c624e84 100644 --- a/tagstudio/src/core/json_typing.py +++ b/tagstudio/src/core/json_typing.py @@ -1,33 +1,38 @@ from typing import TypedDict -class JsonLibary(TypedDict("",{"ts-version":str})): - #"ts-version": str + +class JsonLibary(TypedDict("", {"ts-version": str})): + # "ts-version": str tags: "list[JsonTag]" collations: "list[JsonCollation]" - fields: list #TODO + fields: list # TODO macros: "list[JsonMacro]" entries: "list[JsonEntry]" + class JsonBase(TypedDict): id: int -class JsonTag(JsonBase,total=False): + +class JsonTag(JsonBase, total=False): name: str aliases: list[str] color: str shorthand: str subtag_ids: list[int] -class JsonCollation(JsonBase,total=False): + +class JsonCollation(JsonBase, total=False): title: str e_ids_and_pages: list[list[int]] sort_order: str cover_id: int -class JsonEntry(JsonBase,total=False): + +class JsonEntry(JsonBase, total=False): filename: str path: str - fields: list[dict] #TODO + fields: list[dict] # TODO -class JsonMacro(JsonBase,total=False): - ... #TODO \ No newline at end of file + +class JsonMacro(JsonBase, total=False): ... # TODO diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library.py index d463dc5..a57fc36 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library.py @@ -21,2223 +21,2318 @@ 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'] +TYPE = ["file", "meta", "alt", "mask"] + + # RESULT_TYPE = Enum('Result', ['ENTRY', 'COLLATION', 'TAG_GROUP']) class ItemType(Enum): - ENTRY = 0 - COLLATION = 1 - TAG_GROUP = 2 + ENTRY = 0 + COLLATION = 1 + TAG_GROUP = 2 + logging.basicConfig(format="%(message)s", level=logging.INFO) + class Entry: - """A Library Entry Object. Referenced by ID.""" + """A Library Entry Object. Referenced by ID.""" - def __init__(self, id: int, filename: str, path: str, - fields: list[dict]) -> None: - # Required Fields ====================================================== - self.id = int(id) - self.filename = filename - self.path = path - self.fields = fields - self.type = None + def __init__(self, id: int, filename: str, path: str, fields: list[dict]) -> None: + # Required Fields ====================================================== + self.id = int(id) + self.filename = filename + self.path = path + self.fields = fields + self.type = None - # Optional Fields ====================================================== - # # Any Type - # self.alts: list[id] = None - # # Image/Video - # self.crop: tuple[int, int, int, int] = None - # self.mask: list[id] = None - # # Video - # self.trim: tuple[float, float] = None + # Optional Fields ====================================================== + # # Any Type + # self.alts: list[id] = None + # # Image/Video + # self.crop: tuple[int, int, int, int] = None + # self.mask: list[id] = None + # # Video + # self.trim: tuple[float, float] = None - # Handy Data =========================================================== - # # Any Type - # self.date_created: datetime.datetime = None - # self.date_modified: datetime.datetime = None - # self.file_size: int = None - # self.isArchived: bool = None - # self.isFavorite: bool = None - # # Image/Video - # self.dimensions: tuple[int, int] = None - # # Video - # self.length: float = None - # # Text - # self.word_count: int = None - + # Handy Data =========================================================== + # # Any Type + # self.date_created: datetime.datetime = None + # self.date_modified: datetime.datetime = None + # self.file_size: int = None + # self.isArchived: bool = None + # self.isFavorite: bool = None + # # Image/Video + # self.dimensions: tuple[int, int] = None + # # Video + # self.length: float = None + # # Text + # self.word_count: int = None - def __str__(self) -> str: - return f'\n{self.compressed_dict()}\n' + def __str__(self) -> str: + return f"\n{self.compressed_dict()}\n" - def __repr__(self) -> str: - return self.__str__() + def __repr__(self) -> str: + return self.__str__() - def __eq__(self, __value: object) -> bool: - if os.name == 'nt': - return (int(self.id) == int(__value.id) - and self.filename.lower() == __value.filename.lower() - and self.path.lower() == __value.path.lower() - and self.fields == __value.fields) - else: - return (int(self.id) == int(__value.id) - and self.filename == __value.filename - and self.path == __value.path - and self.fields == __value.fields) + def __eq__(self, __value: object) -> bool: + if os.name == "nt": + return ( + int(self.id) == int(__value.id) + and self.filename.lower() == __value.filename.lower() + and self.path.lower() == __value.path.lower() + and self.fields == __value.fields + ) + else: + return ( + int(self.id) == int(__value.id) + and self.filename == __value.filename + and self.path == __value.path + and self.fields == __value.fields + ) - def compressed_dict(self) -> JsonEntry: - """ - An alternative to __dict__ that only includes fields containing - non-default data. - """ - obj: JsonEntry = { - "id": self.id - } - if self.filename: - obj['filename'] = self.filename - if self.path: - obj['path'] = self.path - if self.fields: - obj['fields'] = self.fields + def compressed_dict(self) -> JsonEntry: + """ + An alternative to __dict__ that only includes fields containing + non-default data. + """ + obj: JsonEntry = {"id": self.id} + if self.filename: + obj["filename"] = self.filename + if self.path: + obj["path"] = self.path + if self.fields: + obj["fields"] = self.fields - return obj + return obj - def has_tag(self, library:'Library', tag_id:int) -> bool: - if self.fields: - for f in self.fields: - if library.get_field_attr(f, 'type') == 'tag_box': - if tag_id in library.get_field_attr(f, 'content'): - return True - return False - - def remove_tag(self, library:'Library', tag_id:int, field_index=-1): - """ - Removes a Tag from the Entry. If given a field index, the given Tag will - only be removed from that index. If left blank, all instances of that - Tag will be removed from the Entry. - """ - if self.fields: - for i, f in enumerate(self.fields): - if library.get_field_attr(f, 'type') == 'tag_box': - if field_index >= 0 and field_index == i: - t: list[int] = library.get_field_attr(f, 'content') - logging.info(f't:{tag_id}, i:{i}, idx:{field_index}, c:{library.get_field_attr(f, "content")}') - t.remove(tag_id) - elif field_index < 0: - t: list[int] = library.get_field_attr(f, 'content') - while tag_id in t: - t.remove(tag_id) - - def add_tag(self, library:'Library', tag_id:int, field_id:int, field_index:int=None): - # field_index: int = -1 - # 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 is None else field_index - for i, f in enumerate(self.fields): - if library.get_field_attr(f, 'id') == field_id: - field_index = i - # logging.info(f'[LIBRARY] FOUND F-INDEX:{field_index}') - break + def has_tag(self, library: "Library", tag_id: int) -> bool: + if self.fields: + for f in self.fields: + if library.get_field_attr(f, "type") == "tag_box": + if tag_id in library.get_field_attr(f, "content"): + return True + return False - if field_index == -1: - library.add_field_to_entry(self.id, field_id) - # logging.info(f'[LIBRARY] USING NEWEST F-INDEX:{field_index}') + def remove_tag(self, library: "Library", tag_id: int, field_index=-1): + """ + Removes a Tag from the Entry. If given a field index, the given Tag will + only be removed from that index. If left blank, all instances of that + Tag will be removed from the Entry. + """ + if self.fields: + for i, f in enumerate(self.fields): + if library.get_field_attr(f, "type") == "tag_box": + if field_index >= 0 and field_index == i: + t: list[int] = library.get_field_attr(f, "content") + logging.info( + f't:{tag_id}, i:{i}, idx:{field_index}, c:{library.get_field_attr(f, "content")}' + ) + t.remove(tag_id) + elif field_index < 0: + t: list[int] = library.get_field_attr(f, "content") + while tag_id in t: + t.remove(tag_id) - # logging.info(list(self.fields[field_index].keys())) - field_id = list(self.fields[field_index].keys())[0] - # logging.info(f'Entry Field ID: {field_id}, Index: {field_index}') - - tags: list[int] = self.fields[field_index][field_id] - if tag_id not in tags: - # logging.info(f'Adding Tag: {tag_id}') - tags.append(tag_id) - self.fields[field_index][field_id] = sorted(tags, key=lambda t: library.get_tag(t).display_name(library)) + def add_tag( + self, library: "Library", tag_id: int, field_id: int, field_index: int = None + ): + # field_index: int = -1 + # 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 is None else field_index + for i, f in enumerate(self.fields): + if library.get_field_attr(f, "id") == field_id: + field_index = i + # logging.info(f'[LIBRARY] FOUND F-INDEX:{field_index}') + break - # logging.info(f'Tags: {self.fields[field_index][field_id]}') - + if field_index == -1: + library.add_field_to_entry(self.id, field_id) + # logging.info(f'[LIBRARY] USING NEWEST F-INDEX:{field_index}') + + # logging.info(list(self.fields[field_index].keys())) + field_id = list(self.fields[field_index].keys())[0] + # logging.info(f'Entry Field ID: {field_id}, Index: {field_index}') + + tags: list[int] = self.fields[field_index][field_id] + if tag_id not in tags: + # logging.info(f'Adding Tag: {tag_id}') + tags.append(tag_id) + self.fields[field_index][field_id] = sorted( + tags, key=lambda t: library.get_tag(t).display_name(library) + ) + + # logging.info(f'Tags: {self.fields[field_index][field_id]}') class Tag: - """A Library Tag Object. Referenced by ID.""" + """A Library Tag Object. Referenced by ID.""" - def __init__(self, id: int, name: str, shorthand: str, aliases: list[str], - subtags_ids: list[int], color: str) -> None: - self.id = int(id) - self.name = name - self.shorthand = shorthand - self.aliases = aliases - # Ensures no duplicates while retaining order. - self.subtag_ids = [] - for s in subtags_ids: - if int(s) not in self.subtag_ids: - self.subtag_ids.append(int(s)) - # [int(s) for s in subtags_ids] - self.color = color + def __init__( + self, + id: int, + name: str, + shorthand: str, + aliases: list[str], + subtags_ids: list[int], + color: str, + ) -> None: + self.id = int(id) + self.name = name + self.shorthand = shorthand + self.aliases = aliases + # Ensures no duplicates while retaining order. + self.subtag_ids = [] + for s in subtags_ids: + if int(s) not in self.subtag_ids: + self.subtag_ids.append(int(s)) + # [int(s) for s in subtags_ids] + self.color = color - def __str__(self) -> str: - return (f"\nID: {self.id}\nName: {self.name}\n" - f"Shorthand: {self.shorthand}\nAliases: {self.aliases}\n" - f"Subtags: {self.subtag_ids}\nColor: {self.color}\n") + def __str__(self) -> str: + return ( + f"\nID: {self.id}\nName: {self.name}\n" + f"Shorthand: {self.shorthand}\nAliases: {self.aliases}\n" + f"Subtags: {self.subtag_ids}\nColor: {self.color}\n" + ) - def __repr__(self) -> str: - return self.__str__() + def __repr__(self) -> str: + return self.__str__() - def debug_name(self) -> str: - """Returns a formatted tag name intended for displaying.""" - # return (f'{self.name} (ID: {self.id}) Subtags: {self.subtag_ids}') - return (f'{self.name} (ID: {self.id})') + def debug_name(self) -> str: + """Returns a formatted tag name intended for displaying.""" + # return (f'{self.name} (ID: {self.id}) Subtags: {self.subtag_ids}') + return f"{self.name} (ID: {self.id})" - def display_name(self, library: 'Library') -> str: - """Returns a formatted tag name intended for displaying.""" - if self.subtag_ids: - if library.get_tag(self.subtag_ids[0]).shorthand: - return (f'{self.name}' - f' ({library.get_tag(self.subtag_ids[0]).shorthand})') - else: - return (f'{self.name}' - f' ({library.get_tag(self.subtag_ids[0]).name})') - else: - return (f'{self.name}') + def display_name(self, library: "Library") -> str: + """Returns a formatted tag name intended for displaying.""" + if self.subtag_ids: + if library.get_tag(self.subtag_ids[0]).shorthand: + return ( + f"{self.name}" f" ({library.get_tag(self.subtag_ids[0]).shorthand})" + ) + else: + return f"{self.name}" f" ({library.get_tag(self.subtag_ids[0]).name})" + else: + return f"{self.name}" - def compressed_dict(self) -> JsonTag: - """ - An alternative to __dict__ that only includes fields containing - non-default data. - """ - obj: JsonTag = { - "id":self.id - } - if self.name: - obj["name"] = self.name - if self.shorthand: - obj["shorthand"] = self.shorthand - if self.aliases: - obj["aliases"] = self.aliases - if self.subtag_ids: - obj["subtag_ids"] = self.subtag_ids - if self.color: - obj["color"] = self.color + def compressed_dict(self) -> JsonTag: + """ + An alternative to __dict__ that only includes fields containing + non-default data. + """ + obj: JsonTag = {"id": self.id} + if self.name: + obj["name"] = self.name + if self.shorthand: + obj["shorthand"] = self.shorthand + if self.aliases: + obj["aliases"] = self.aliases + if self.subtag_ids: + obj["subtag_ids"] = self.subtag_ids + if self.color: + obj["color"] = self.color - return obj + return obj + + def add_subtag(self, tag_id: int): + if tag_id not in self.subtag_ids: + self.subtag_ids.append(tag_id) + + def remove_subtag(self, tag_id: int): + try: + self.subtag_ids.remove(tag_id) + except ValueError: + pass - def add_subtag(self, tag_id:int): - if tag_id not in self.subtag_ids: - self.subtag_ids.append(tag_id) - - def remove_subtag(self, tag_id:int): - try: - self.subtag_ids.remove(tag_id) - except ValueError: - pass class Collation: - """ - A Library Collation Object. Referenced by ID. - Entries and their Page #s are grouped together in the e_ids_and_paged tuple. - Sort order is `(filename | title | date, asc | desc)`. - """ + """ + A Library Collation Object. Referenced by ID. + Entries and their Page #s are grouped together in the e_ids_and_paged tuple. + Sort order is `(filename | title | date, asc | desc)`. + """ - def __init__(self, id: int, title: str, e_ids_and_pages: list[tuple[int, int]], - sort_order:str, cover_id:int = -1) -> None: - self.id = int(id) - self.title = title - self.e_ids_and_pages = e_ids_and_pages - self.sort_order = sort_order - self.cover_id = cover_id - self.fields = None # Optional Collation-wide fields. WIP. + def __init__( + self, + id: int, + title: str, + e_ids_and_pages: list[tuple[int, int]], + sort_order: str, + cover_id: int = -1, + ) -> None: + self.id = int(id) + self.title = title + self.e_ids_and_pages = e_ids_and_pages + self.sort_order = sort_order + self.cover_id = cover_id + self.fields = None # Optional Collation-wide fields. WIP. - def __str__(self) -> str: - return f'\n{self.compressed_dict()}\n' + def __str__(self) -> str: + return f"\n{self.compressed_dict()}\n" - def __repr__(self) -> str: - return self.__str__() + def __repr__(self) -> str: + return self.__str__() - def __eq__(self, __value: object) -> bool: - if os.name == 'nt': - return (int(self.id) == int(__value.id_) - and self.filename.lower() == __value.filename.lower() - and self.path.lower() == __value.path.lower() - and self.fields == __value.fields) - else: - return (int(self.id) == int(__value.id_) - and self.filename == __value.filename - and self.path == __value.path - and self.fields == __value.fields) + def __eq__(self, __value: object) -> bool: + if os.name == "nt": + return ( + int(self.id) == int(__value.id_) + and self.filename.lower() == __value.filename.lower() + and self.path.lower() == __value.path.lower() + and self.fields == __value.fields + ) + else: + return ( + int(self.id) == int(__value.id_) + and self.filename == __value.filename + and self.path == __value.path + and self.fields == __value.fields + ) - def compressed_dict(self) -> JsonCollation: - """ - An alternative to __dict__ that only includes fields containing - non-default data. - """ - obj: JsonCollation = { - "id":self.id - } - if self.title: - obj['title'] = self.title - if self.e_ids_and_pages: - # TODO: work with tuples - obj['e_ids_and_pages'] = [list(x) for x in self.e_ids_and_pages] - # obj['e_ids_and_pages'] = self.e_ids_and_pages - if self.sort_order: - obj['sort_order'] = self.sort_order - if self.cover_id: - obj['cover_id'] = self.cover_id + def compressed_dict(self) -> JsonCollation: + """ + An alternative to __dict__ that only includes fields containing + non-default data. + """ + obj: JsonCollation = {"id": self.id} + if self.title: + obj["title"] = self.title + if self.e_ids_and_pages: + # TODO: work with tuples + obj["e_ids_and_pages"] = [list(x) for x in self.e_ids_and_pages] + # obj['e_ids_and_pages'] = self.e_ids_and_pages + if self.sort_order: + obj["sort_order"] = self.sort_order + if self.cover_id: + obj["cover_id"] = self.cover_id + + return obj - return obj class Library: - """Class for the Library object, and all CRUD operations made upon it.""" - - def __init__(self) -> None: - # Library Info ========================================================= - self.library_dir: str = None - - # Entries ============================================================== - # List of every Entry object. - self.entries: list[Entry] = [] - self._next_entry_id: int = 0 - # Map of every Entry ID to the index of the Entry in self.entries. - self._entry_id_to_index_map: dict[int, int] = {} - # # List of filtered Entry indexes generated by the filter_entries() method. - # self.filtered_entries: list[int] = [] - # Duplicate Entries - # Defined by Entries that point to files that one or more other Entries are also pointing to. - # tuple(int, list[int]) - self.dupe_entries: list[tuple[int, list[int]]] = [] - - # Collations =========================================================== - # List of every Collation object. - self.collations: list[Collation] = [] - self._next_collation_id: int = 0 - self._collation_id_to_index_map: dict[int, int] = {} - - # File Interfacing ===================================================== - self.dir_file_count: int = -1 - self.files_not_in_library: list[str] = [] - self.missing_files: list[str] = [] - self.fixed_files: list[str] = [] # TODO: Get rid of this. - self.missing_matches = {} - # Duplicate Files - # Defined by files that are exact or similar copies to others. Generated by DupeGuru. - # (Filepath, Matched Filepath, Match Percentage) - self.dupe_files: list[tuple[str, str, int]] = [] - # Maps the filenames of entries in the Library to their entry's index in the self.entries list. - # Used for O(1) lookup of a file based on the current index (page number - 1) of the image being looked at. - # 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). - self.tags: list[Tag] = [] - self._next_tag_id: int = 1000 - # Map of each Tag ID with its entry reference count. - self._tag_entry_ref_map: dict[int,int] = {} - self.tag_entry_refs: list[tuple[int,int]] = [] - # Map of every Tag name and alias to the ID(s) of its associated Tag(s). - # Used for O(1) lookup of Tag IDs based on search terms. - # NOTE: While it is recommended to keep Tag aliases unique to each Tag, - # there may be circumstances where this is not possible or elegant. - # Because of this, names and aliases are mapped to a list of IDs rather than a - # singular ID to handle potential alias collision. - self._tag_strings_to_id_map: dict[str, list[int]] = {} - # Map of every Tag ID to an array of Tag IDs that make up the Tag's "cluster", aka a list - # of references from other Tags that specify this Tag as one of its subtags. - # This in effect is like a reverse subtag map. - # Used for O(1) lookup of the Tags to return in a query given a Tag ID. - self._tag_id_to_cluster_map: dict[int, list[int]] = {} - # Map of every Tag ID to the index of the Tag in self.tags. - self._tag_id_to_index_map: dict[int, int] = {} - - self.default_tags: list[JsonTag] = [ - { - "id": 0, - "name": "Archived", - "aliases": [ - "Archive" - ], - "color": "Red" - }, - { - "id": 1, - "name": "Favorite", - "aliases": [ - "Favorited", - "Favorites" - ], - "color": "Yellow" - }, - ] - - # self.default_tags = [ - # Tag(id=0, name='Archived', shorthand='', aliases=['Archive'], subtags_ids=[], color='red'), - # Tag(id=1, name='Favorite', shorthand='', aliases=['Favorited, Favorites, Likes, Liked, Loved'], subtags_ids=[], color='yellow'), - # ] - - self.default_fields = [ - { - "id": 0, - "name": "Title", - "type": "text_line" - }, - { - "id": 1, - "name": "Author", - "type": "text_line" - }, - { - "id": 2, - "name": "Artist", - "type": "text_line" - }, - { - "id": 3, - "name": "URL", - "type": "text_line" - }, - { - "id": 4, - "name": "Description", - "type": "text_box" - }, - { - "id": 5, - "name": "Notes", - "type": "text_box" - }, - { - "id": 6, - "name": "Tags", - "type": "tag_box" - }, - { - "id": 7, - "name": "Content Tags", - "type": "tag_box" - }, - { - "id": 8, - "name": "Meta Tags", - "type": "tag_box" - }, - { - "id": 9, - "name": "Collation", - "type": "collation" - }, - { - "id": 10, - "name": "Date", - "type": "datetime" - }, - { - "id": 11, - "name": "Date Created", - "type": "datetime" - }, - { - "id": 12, - "name": "Date Modified", - "type": "datetime" - }, - { - "id": 13, - "name": "Date Taken", - "type": "datetime" - }, - { - "id": 14, - "name": "Date Published", - "type": "datetime" - }, - { - "id": 15, - "name": "Archived", - "type": "checkbox" - }, - { - "id": 16, - "name": "Favorite", - "type": "checkbox" - }, - { - "id": 17, - "name": "Book", - "type": "collation" - }, - { - "id": 18, - "name": "Comic", - "type": "collation" - }, - { - "id": 19, - "name": "Series", - "type": "collation" - }, - { - "id": 20, - "name": "Manga", - "type": "collation" - }, - { - "id": 21, - "name": "Source", - "type": "text_line" - }, - { - "id": 22, - "name": "Date Uploaded", - "type": "datetime" - }, - { - "id": 23, - "name": "Date Released", - "type": "datetime" - }, - { - "id": 24, - "name": "Volume", - "type": "collation" - }, - { - "id": 25, - "name": "Anthology", - "type": "collation" - }, - { - "id": 26, - "name": "Magazine", - "type": "collation" - }, - { - "id": 27, - "name": "Publisher", - "type": "text_line" - }, - { - "id": 28, - "name": "Guest Artist", - "type": "text_line" - }, - { - "id": 29, - "name": "Composer", - "type": "text_line" - }, - { - "id": 30, - "name": "Comments", - "type": "text_box" - }, - ] - - def create_library(self, path) -> int: - """ - Creates a TagStudio library in the given directory.\n - Return Codes:\n - 0: Library Successfully Created\n - 2: File creation error - """ - - path = os.path.normpath(path).rstrip('\\') - - # If '.TagStudio' is included in the path, trim the path up to it. - if ts_core.TS_FOLDER_NAME in path: - path = path.split(ts_core.TS_FOLDER_NAME)[0] - - try: - self.clear_internal_vars() - self.library_dir = path - self.verify_ts_folders() - self.save_library_to_disk() - self.open_library(self.library_dir) - except: - traceback.print_exc() - return 2 - - return 0 - - def verify_ts_folders(self) -> None: - """Verifies/creates folders required by TagStudio.""" - - full_ts_path = os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}') - full_backup_path = os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}') - full_collage_path = os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.COLLAGE_FOLDER_NAME}') - - if not os.path.isdir(full_ts_path): - os.mkdir(full_ts_path) - - if not os.path.isdir(full_backup_path): - os.mkdir(full_backup_path) - - if not os.path.isdir(full_collage_path): - os.mkdir(full_collage_path) - - def verify_default_tags(self, tag_list: list[JsonTag]) -> list[JsonTag]: - """ - 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. - """ - missing: list[JsonTag] = [] - - for dt in self.default_tags: - if dt['id'] not in [t['id'] for t in tag_list]: - missing.append(dt) - - for m in missing: - tag_list.append(m) - - return tag_list - - def open_library(self, path: str) -> int: - """ - Opens a TagStudio v9+ Library. - Returns 0 if library does not exist, 1 if successfully opened, 2 if corrupted. - """ - - return_code: int = 2 - path = os.path.normpath(path).rstrip('\\') - - # If '.TagStudio' is included in the path, trim the path up to it. - if ts_core.TS_FOLDER_NAME in path: - path = path.split(ts_core.TS_FOLDER_NAME)[0] - - if os.path.exists(os.path.normpath(f'{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json')): - - try: - with open(os.path.normpath(f'{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json'), 'r', encoding='utf-8') as f: - json_dump: JsonLibary = ujson.load(f) - self.library_dir = str(path) - 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() - - # Step 1: Verify default built-in tags are present. - json_dump['tags'] = self.verify_default_tags(json_dump['tags']) - - for tag in json_dump['tags']: - - # 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 = int(tag.get('id', 0)) - - # 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 = tag.get('name', '') - shorthand = tag.get('shorthand', '') - aliases = tag.get('aliases', []) - subtag_ids = tag.get('subtag_ids', []) - color = tag.get('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) - 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: - self._map_tag_id_to_cluster(t) - - end_time = time.time() - logging.info(f'[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds') - - # Parse Entries ------------------------------------------------ - if entries := json_dump.get('entries'): - start_time = time.time() - for entry in entries: - - if 'id' in entry: - id = int(entry['id']) - if id >= self._next_entry_id: - self._next_entry_id = id + 1 - else: - # Version 9.1.x+ Compatibility - id = self._next_entry_id - self._next_entry_id += 1 - - filename = entry.get('filename', '') - e_path = entry.get('path', '') - fields = [] - if 'fields' in entry: - # Cast JSON str keys to ints - for f in entry['fields']: - f[int(list(f.keys())[0]) - ] = f[list(f.keys())[0]] - del f[list(f.keys())[0]] - fields = entry['fields'] - - # Look through fields for legacy Collation data -------- - if int(major) >= 9 and int(minor) < 1: - for f in fields: - if self.get_field_attr(f, 'type') == 'collation': - # NOTE: This legacy support will be removed in - # a later version, probably 9.2. - # Legacy Collation data present in v9.0.x - # DATA SHAPE: {name: str, page: int} - - # We'll do an inefficient linear search each - # time to convert the legacy data. - matched = False - collation_id = -1 - for c in self.collations: - if c.title == self.get_field_attr(f, 'content')['name']: - c.e_ids_and_pages.append((id, int(self.get_field_attr(f, 'content')['page']))) - matched = True - collation_id = c.id - if not matched: - c = Collation(id=self._next_collation_id, - title=self.get_field_attr(f, 'content')['name'], - e_ids_and_pages=[], - sort_order='') - collation_id = self._next_collation_id - self._next_collation_id += 1 - c.e_ids_and_pages.append((id, int(self.get_field_attr(f, 'content')['page']))) - self.collations.append(c) - self._map_collation_id_to_index(c, -1) - f_id = self.get_field_attr(f, 'id') - f.clear() - f[int(f_id)] = collation_id - # Collation Field data present in v9.1.x+ - # DATA SHAPE: int - elif int(major) >= 9 and int(minor) >= 1: - pass - - e = Entry( - id=int(id), - filename=filename, - path=e_path, - fields=fields - ) - self.entries.append(e) - self._map_entry_id_to_index(e, -1) - end_time = time.time() - logging.info(f'[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds') - - # Parse Collations --------------------------------------------------- - if 'collations' in json_dump.keys(): - start_time = time.time() - for collation in json_dump['collations']: - - # Step 1: Create a Collation object and append it to - # the internal Collations list, then map that - # Collation's ID to its index in the Collations list. - - id = int(collation.get('id', 0)) - if id >= self._next_collation_id: - self._next_collation_id = id + 1 - - 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=id, - title=title, - e_ids_and_pages=e_ids_and_pages, - sort_order=sort_order, - cover_id=cover_id - ) - - # NOTE: This does NOT use the add_collation_to_library() method - # which is intended to be used at runtime. However, there is - # currently no reason why it couldn't be used here, and is - # instead not used for consistency. - self.collations.append(c) - self._map_collation_id_to_index(c, -1) - end_time = time.time() - logging.info(f'[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds') - - return_code = 1 - except ujson.JSONDecodeError: - logging.info('[LIBRARY][ERROR]: Empty JSON file!') - - # If the Library is loaded, continue other processes. - if return_code == 1: - - if not os.path.exists(os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}')): - os.makedirs(os.path.normpath( - f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}')) - - self._map_filenames_to_entry_ids() - - return return_code - - # @deprecated('Use new Entry ID system.') - def _map_filenames_to_entry_ids(self): - """Maps a full filepath to its corresponding Entry's ID.""" - self.filename_to_entry_id_map.clear() - for entry in self.entries: - if os.name == 'nt': - # print(str(os.path.normpath( - # f'{entry.path}/{entry.filename}')).lower().lstrip('\\').lstrip('/')) - self.filename_to_entry_id_map[str(os.path.normpath( - f'{entry.path}/{entry.filename}')).lower().lstrip('\\').lstrip('/')] = entry.id - else: - self.filename_to_entry_id_map[str( - os.path.normpath(f'{entry.path}/{entry.filename}')).lstrip('/')] = entry.id - - # def _map_filenames_to_entry_ids(self): - # """Maps the file paths of entries to their index in the library list.""" - # self.file_to_entry_index_map.clear() - # for i, entry in enumerate(self.entries): - # if os.name == 'nt': - # self.file_to_entry_index_map[str(os.path.normpath( - # f'{entry.path}/{entry.filename}')).lower()] = i - # else: - # self.file_to_entry_index_map[str( - # os.path.normpath(f'{entry.path}/{entry.filename}'))] = i - - - # def close_library(self, save: bool = True): - # """Closes the open TagStudio Library.""" - # self.clear_internal_vars() - - def to_json(self): - """ - Creates a JSON serialized string from the Library object. - Used in saving the library to disk. - """ - - file_to_save: JsonLibary = {"ts-version": ts_core.VERSION, - "ignored_extensions": [], - "tags": [], - "collations": [], - "fields": [], - "macros": [], - "entries": [], - } - - print('[LIBRARY] Formatting Tags to JSON...') - - file_to_save['ignored_extensions'] = [i for i in self.ignored_extensions if i] - - 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()) - - print('[LIBRARY] Formatting Collations to JSON...') - for collation in self.collations: - file_to_save["collations"].append(collation.compressed_dict()) - - print('[LIBRARY] Done Formatting to JSON!') - return file_to_save - - def save_library_to_disk(self): - """Saves the Library to disk at the default TagStudio folder location.""" - - logging.info(f'[LIBRARY] Saving Library to Disk...') - start_time = time.time() - filename = 'ts_library.json' - - self.verify_ts_folders() - - with open(os.path.normpath(f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{filename}'), 'w', encoding='utf-8') as outfile: - outfile.flush() - ujson.dump(self.to_json(), outfile, ensure_ascii=False, escape_forward_slashes=False) - # , indent=4 <-- How to prettyprint dump - end_time = time.time() - logging.info(f'[LIBRARY] Library saved to disk in {(end_time - start_time):.3f} seconds') - - def save_library_backup_to_disk(self) -> str: - """ - Saves a backup file of the Library to disk at the default TagStudio folder location. - Returns the filename used, including the date and time.""" - - logging.info(f'[LIBRARY] Saving Library Backup to Disk...') - start_time = time.time() - filename = f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json' - - self.verify_ts_folders() - with open(os.path.normpath(f'{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}/{filename}'), 'w', encoding='utf-8') as outfile: - outfile.flush() - ujson.dump(self.to_json(), outfile, ensure_ascii=False, escape_forward_slashes=False) - end_time = time.time() - logging.info(f'[LIBRARY] Library backup saved to disk in {(end_time - start_time):.3f} seconds') - return filename - # , indent=4 <-- How to prettyprint dump - - def clear_internal_vars(self): - """Clears the internal variables of the Library object.""" - self.library_dir = None - self.is_legacy_library = False - - self.entries.clear() - self._next_entry_id: int = 0 - # self.filtered_entries.clear() - self._entry_id_to_index_map.clear() - - self._collation_id_to_index_map.clear() - - self.missing_matches = {} - self.dir_file_count: int = -1 - self.files_not_in_library.clear() - 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 - self._tag_strings_to_id_map: dict[str, list[int]] = {} - self._tag_id_to_cluster_map: dict[int, list[int]] = {} - self._tag_id_to_index_map: dict[int, int] = {} - self._tag_entry_ref_map.clear() - - def refresh_dir(self): - """Scans a directory for files, and adds those relative filenames to internal variables.""" - - # Reset file interfacing variables. - # -1 means uninitialized, aka a scan like this was never attempted before. - self.dir_file_count: int = 0 - self.files_not_in_library.clear() - - # Scans the directory for files, keeping track of: - # - Total file count - # - Files without library entries - # for type in ts_core.TYPES: - start_time = time.time() - for f in glob.glob(self.library_dir + "/**/*", recursive=True): - # p = Path(os.path.normpath(f)) - if ('$RECYCLE.BIN' not in f and ts_core.TS_FOLDER_NAME not in f - and 'tagstudio_thumbs' not in f and not os.path.isdir(f)): - if os.path.splitext(f)[1][1:].lower() not in self.ignored_extensions: - self.dir_file_count += 1 - file = str(os.path.relpath(f, self.library_dir)) - - try: - if os.name == 'nt': - _ = self.filename_to_entry_id_map[file.lower()] - else: - _ = self.filename_to_entry_id_map[file] - except KeyError: - # print(file) - self.files_not_in_library.append(file) - - - # sys.stdout.write(f'\r[LIBRARY] {self.dir_file_count} files found in "{self.library_dir}"...') - # sys.stdout.flush() - end_time = time.time() - # Yield output every 1/30 of a second - if (end_time - start_time) > 0.034: - yield self.dir_file_count - start_time = time.time() - # print('') - - # Sorts the files by date modified, descending. - if len(self.files_not_in_library) <= 100000: - try: - self.files_not_in_library = sorted( - self.files_not_in_library, key=lambda t: -os.stat(os.path.normpath(self.library_dir + '/' + t)).st_ctime) - except (FileExistsError, FileNotFoundError): - print(f'[LIBRARY][ERROR] Couldn\'t sort files, some were moved during the scanning/sorting process.') - pass - else: - print(f'[LIBRARY][INFO] Not bothering to sort files because there\'s OVER 100,000! Better sorting methods will be added in the future.') - - def refresh_missing_files(self): - """Tracks the number of Entries that point to an invalid file path.""" - self.missing_files.clear() - for i, entry in enumerate(self.entries): - full_path = os.path.normpath( - f'{self.library_dir}/{entry.path}/{entry.filename}') - if not os.path.isfile(full_path): - self.missing_files.append(full_path) - yield i - - def remove_entry(self, entry_id: int) -> None: - """Removes an Entry from the Library.""" - # del self.entries[entry_index] - # self._map_filenames_to_entry_indices() - - # Step [1/2]: - # Remove this Entry from the Entries list. - entry = self.get_entry(entry_id) - path = str(os.path.normpath(f'{entry.path}/{entry.filename}')).lstrip('\\').lstrip('/') - path = path.lower() if os.name == 'nt' else path - # logging.info(f'Removing path: {path}') - del self.filename_to_entry_id_map[path] - - del self.entries[self._entry_id_to_index_map[entry_id]] - - # self.entries.remove(self.entries[self._entry_id_to_index_map[entry_id]]) - - # Step [2/2]: - # Remap the other Entry IDs to their new indices in the Entries list. - self._entry_id_to_index_map.clear() - for i, e in enumerate(self.entries): - self._map_entry_id_to_index(e, i) - - # # Step [3/3]: - # # Remap filenames to new indices. - # self._map_filenames_to_entry_ids() - - def refresh_dupe_entries(self): - """ - Refreshes the list of duplicate Entries. - A duplicate Entry is defined as an Entry pointing to a file that one or more - other Entries are also pointing to.\n - `dupe_entries = tuple(int, list[int])` - """ - - # self.dupe_entries.clear() - # known_files: set = set() - # for entry in self.entries: - # full_path = os.path.normpath(f'{self.library_dir}/{entry.path}/{entry.filename}') - # if full_path in known_files: - # self.dupe_entries.append(full_path) - # else: - # known_files.add(full_path) - - self.dupe_entries.clear() - checked = set() - remaining: list[Entry] = list(self.entries) - for p, entry_p in enumerate(self.entries, start=0): - if p not in checked: - matched: list[int] = [] - for c, entry_c in enumerate(remaining, start=0): - if os.name == 'nt': - if entry_p.path.lower() == entry_c.path.lower() and entry_p.filename.lower() == entry_c.filename.lower() and c != p: - matched.append(c) - checked.add(c) - else: - if entry_p.path == entry_c.path and entry_p.filename == entry_c.filename and c != p: - matched.append(c) - checked.add(c) - if matched: - self.dupe_entries.append((p, matched)) - sys.stdout.write( - f'\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has Duplicate(s): {matched}') - sys.stdout.flush() - else: - sys.stdout.write( - f'\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has No Duplicates') - sys.stdout.flush() - checked.add(p) - print('') - - def merge_dupe_entries(self): - """ - Merges duplicate Entries. - A duplicate Entry is defined as an Entry pointing to a file that one or more - other Entries are also pointing to.\n - `dupe_entries = tuple(int, list[int])` - """ - - print('[LIBRARY] Mirroring Duplicate Entries...') - for dupe in self.dupe_entries: - self.mirror_entry_fields([dupe[0]] + dupe[1]) - - # print('Consolidating Entries...') - # for dupe in self.dupe_entries: - # for index in dupe[1]: - # print(f'Consolidating Duplicate: {(self.entries[index].path + os.pathsep + self.entries[index].filename)}') - # self.entries.remove(self.entries[index]) - # self._map_filenames_to_entry_indices() - - print( - '[LIBRARY] Consolidating Entries... (This may take a while for larger libraries)') - unique: list[Entry] = [] - for i, e in enumerate(self.entries): - if e not in unique: - unique.append(e) - # print(f'[{i}/{len(self.entries)}] Appending: {(e.path + os.pathsep + e.filename)[0:32]}...') - sys.stdout.write( - f'\r[LIBRARY] [{i}/{len(self.entries)}] Appending Unique Entry...') - else: - sys.stdout.write( - f'\r[LIBRARY] [{i}/{len(self.entries)}] Consolidating Duplicate: {(e.path + os.pathsep + e.filename)[0:]}...') - print('') - # [unique.append(x) for x in self.entries if x not in unique] - self.entries = unique - self._map_filenames_to_entry_ids() - - def refresh_dupe_files(self, results_filepath): - """ - Refreshes the list of duplicate files. - A duplicate file is defined as an identical or near-identical file as determined - by a DupeGuru results file. - """ - full_results_path = os.path.normpath( - f'{self.library_dir}/{results_filepath}') if self.library_dir not in results_filepath else os.path.normpath(f'{results_filepath}') - if os.path.exists(full_results_path): - self.dupe_files.clear() - self._map_filenames_to_entry_ids() - tree = ET.parse(full_results_path) - root = tree.getroot() - for i, group in enumerate(root): - # print(f'-------------------- Match Group {i}---------------------') - files: list[str] = [] - # (File Index, Matched File Index, Match Percentage) - matches: list[tuple[int, int, int]] = [] - for element in group: - if element.tag == 'file': - file = element.attrib.get('path') - files.append(file) - if element.tag == 'match': - matches.append(( - int(element.attrib.get('first')), - int(element.attrib.get('second')), - int(element.attrib.get('percentage')))) - for match in matches: - # print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}') - if os.name == 'nt': - file_1 = str(os.path.relpath( - files[match[0]], self.library_dir)) - file_2 = str(os.path.relpath( - files[match[1]], self.library_dir)) - if file_1.lower() in self.filename_to_entry_id_map.keys() and file_2.lower() in self.filename_to_entry_id_map.keys(): - self.dupe_files.append( - (files[match[0]], files[match[1]], match[2])) - else: - if file_1 in self.filename_to_entry_id_map.keys() and file_2 in self.filename_to_entry_id_map.keys(): - self.dupe_files.append( - (files[match[0]], files[match[1]], match[2])) - # self.dupe_files.append((files[match[0]], files[match[1]], match[2])) - - print('') - - for dupe in self.dupe_files: - print( - f'[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}', end='\n') - # self.dupe_files.append(full_path) - - def remove_missing_files(self): - deleted = [] - for i, missing in enumerate(self.missing_files): - # pb.setValue(i) - # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') - try: - id = self.get_entry_id_from_filepath(missing) - logging.info(f'Removing Entry ID {id}:\n\t{missing}') - self.remove_entry(id) - # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) - deleted.append(missing) - except KeyError: - logging.info( - f'[LIBRARY][ERROR]: \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') - yield (i, id) - for d in deleted: - self.missing_files.remove(d) - - def remove_missing_matches(self, fixed_indices: list[int]): - """Removes a list of fixed Entry indices from the internal missing_matches list.""" - for i in fixed_indices: - del self.missing_matches[i] - - def fix_missing_files(self): - """ - Attempts to repair Entries that point to invalid file paths. - """ - - # self.refresh_missing_files() - - # matched_json_filepath = os.path.normpath( - # f'{self.library_dir}/{TS_FOLDER_NAME}/missing_matched.json') - # # if not os.path.exists(matched_json_filepath): - # # self.match_missing_files() - - self.missing_matches.clear() - - fixed_indices = [] - # if os.path.exists(matched_json_filepath): - # with open(matched_json_filepath, "r", encoding="utf8") as f: - # self.missing_matches = json.load(f) - - # self.refresh_missing_files() - for i, missing in enumerate(self.missing_files): - print(missing) - if missing not in self.missing_matches.keys(): - matches = self._match_missing_file(missing) - if matches: - print( - f'[LIBRARY] Adding key {missing} with matches {matches}') - self.missing_matches[missing] = matches - yield (i, True) - else: - yield (i, False) - - # self._purge_empty_missing_entries() - - for i, matches in enumerate(self.missing_matches): - if len(self.missing_matches[matches]) == 1: - id = self.get_entry_id_from_filepath(matches) - self.update_entry_path(id, self.missing_matches[matches][0]) - fixed_indices.append(matches) - # print(f'Fixed {self.entries[self.get_entry_index_from_filename(i)].filename}') - print(f'[LIBRARY] Fixed {self.get_entry(id).filename}') - # (int, str) - - - self._map_filenames_to_entry_ids() - self.remove_missing_matches(fixed_indices) - - # for i in fixed_indices: - # # print(json_dump[i]) - # del self.missing_matches[i] - - # with open(matched_json_filepath, "w") as outfile: - # outfile.flush() - # json.dump({}, outfile, indent=4) - # print(f'Re-saved to disk at {matched_json_filepath}') - - def _match_missing_file(self, file: str) -> list[str]: - """ - Tries to find missing entry files within the library directory. - Works if files were just moved to different subfolders and don't have duplicate names. - """ - - # self.refresh_missing_files() - - matches = [] - - # for file in self.missing_files: - head, tail = os.path.split(file) - for (root, dirs, files) in os.walk(self.library_dir, topdown=True): - for f in files: - # print(f'{tail} --- {f}') - if tail == f and '$recycle.bin' not in root.lower(): - # self.fixed_files.append(tail) - - new_path = str(os.path.relpath(root, self.library_dir)) - - matches.append(new_path) - - # if file not in matches.keys(): - # matches[file] = [] - # matches[file].append(new_path) - - print( - f'[LIBRARY] MATCH: {file} \n\t-> {os.path.normpath(self.library_dir + "/" + new_path + "/" + tail)}\n') - - if not matches: - print(f'[LIBRARY] No matches found for: {file}') - - return matches - - # print(f'╡ {os.path.normpath(os.path.relpath(file, self.library_dir))} ╞'.center( - # os.get_terminal_size()[0], "═")) - # print('↓ ↓ ↓'.center(os.get_terminal_size()[0], " ")) - # print( - # f'╡ {os.path.normpath(new_path + "/" + tail)} ╞'.center(os.get_terminal_size()[0], "═")) - # print(self.entries[self.file_to_entry_index_map[str( - # os.path.normpath(os.path.relpath(file, self.library_dir)))]]) - - # # print( - # # f'{file} -> {os.path.normpath(self.library_dir + "/" + new_path + "/" + tail)}') - # # # TODO: Update the Entry path with the 'new_path' variable via a completed update_entry() method. - - # if (str(os.path.normpath(new_path + "/" + tail))) in self.file_to_entry_index_map.keys(): - # print( - # 'Existing Entry ->'.center(os.get_terminal_size()[0], " ")) - # print(self.entries[self.file_to_entry_index_map[str( - # os.path.normpath(new_path + "/" + tail))]]) - - # print(f''.center(os.get_terminal_size()[0], "─")) - # print('') - - # for match in matches.keys(): - # self.fixed_files.append(match) - # # print(match) - # # print(f'\t{matches[match]}') - - with open(os.path.normpath(f'{self.library_dir}/{TS_FOLDER_NAME}/missing_matched.json'), "w") as outfile: - outfile.flush() - json.dump(matches, outfile, indent=4) - print( - f'[LIBRARY] Saved to disk at {os.path.normpath(self.library_dir + "/" + TS_FOLDER_NAME + "/missing_matched.json")}') - - def count_tag_entry_refs(self) -> None: - """ - Counts the number of entry references for each tag. Stores results - in `tag_entry_ref_map`. - """ - self._tag_entry_ref_map.clear() - self.tag_entry_refs.clear() - local_hits: set = set() - - for entry in self.entries: - local_hits.clear() - if entry.fields: - for field in entry.fields: - if self.get_field_attr(field, 'type') == 'tag_box': - for tag_id in self.get_field_attr(field, 'content'): - local_hits.add(tag_id) - - for hit in list(local_hits): - try: - _ = self._tag_entry_ref_map[hit] - except KeyError: - self._tag_entry_ref_map[hit] = 0 - self._tag_entry_ref_map[hit] += 1 - - # keys = list(self.tag_entry_ref_map.keys()) - # values = list(self.tag_entry_ref_map.values()) - self.tag_entry_refs = sorted(self._tag_entry_ref_map.items(), key=lambda x: x[1], reverse=True) - - def add_entry_to_library(self, entry: Entry): - """Adds a new Entry to the Library.""" - self.entries.append(entry) - self._map_entry_id_to_index(entry, -1) - - def add_new_files_as_entries(self) -> list[int]: - """Adds files from the `files_not_in_library` list to the Library as Entries. Returns list of added indices.""" - new_ids: list[int] = [] - for file in self.files_not_in_library: - path, filename = os.path.split(file) - # print(os.path.split(file)) - entry = Entry(id=self._next_entry_id, filename=filename, path=path, fields=[]) - self._next_entry_id += 1 - self.add_entry_to_library(entry) - new_ids.append(entry.id) - self._map_filenames_to_entry_ids() - self.files_not_in_library.clear() - return new_ids - - self.files_not_in_library.clear() - - def get_entry(self, entry_id: int) -> Entry: - """Returns an Entry object given an Entry ID.""" - return self.entries[self._entry_id_to_index_map[int(entry_id)]] - - def get_collation(self, collation_id: int) -> Collation: - """Returns a Collation object given an Collation ID.""" - return self.collations[self._collation_id_to_index_map[int(collation_id)]] - - # @deprecated('Use new Entry ID system.') - def get_entry_from_index(self, index: int) -> Entry: - """Returns a Library Entry object given its index in the unfiltered Entries list.""" - if self.entries: - return self.entries[int(index)] - - # @deprecated('Use new Entry ID system.') - def get_entry_id_from_filepath(self, filename): - """Returns an Entry ID given the full filepath it points to.""" - try: - if self.entries: - if os.name == 'nt': - return self.filename_to_entry_id_map[str(os.path.normpath(os.path.relpath(filename, self.library_dir))).lower()] - return self.filename_to_entry_id_map[str(os.path.normpath(os.path.relpath(filename, self.library_dir)))] - except: - return -1 - - def search_library(self, query:str=None, entries=True, collations=True, - tag_groups=True) -> list[tuple[ItemType, int]]: - """ - Uses a search query to generate a filtered results list. - Returns a list of (str, int) tuples consisting of a result type and ID. - """ - - # self.filtered_entries.clear() - results: list[tuple[ItemType, int]] = [] - collations_added = [] - - if query: - # start_time = time.time() - query: str = query.strip().lower() - query_words: list[str] = query.split(' ') - all_tag_terms: list[str] = [] - 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 = ('no author' in query or 'no artist' in query) - - # Preprocess the Tag terms. - 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: - all_tag_terms.append(" ".join(query_words[i:j+1])) - # This gets rid of any accidental term inclusions because they were words - # in another term. Ex. "3d" getting added in "3d art" - for i, term in enumerate(all_tag_terms): - for j, term2 in enumerate(all_tag_terms): - if i != j and all_tag_terms[i] in all_tag_terms[j]: - # print( - # f'removing {all_tag_terms[i]} because {all_tag_terms[i]} was in {all_tag_terms[j]}') - all_tag_terms.remove(all_tag_terms[i]) - break - - # print(all_tag_terms) - - # 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 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}') - - # 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') - # sys.stdout.flush() - - # except: - # # # Put this here to have new non-registered images show up - # # if query == "untagged" or query == "no author" or query == "no artist": - # # self.filtered_file_list.append(file) - # # non_entry_count = non_entry_count + 1 - # pass - - # end_time = time.time() - # print( - # f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)') - - # if non_entry_count: - # print( - # f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.') - # if not self.filtered_entries: - # print("[INFO][FILTER]: Filter returned no results.") - else: - - for entry in self.entries: - added = False - 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)) - # 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] = None) -> list[int]: - """Returns a list of Tag IDs returned from a string query.""" - # tag_ids: list[int] = [] - # if query: - # query = query.lower() - # query_words = query.split(' ') - # all_tag_terms: list[str] = [] - - # # Preprocess the Tag terms. - # if len(query_words) > 0: - # 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_names_to_tag_id_map: - # all_tag_terms.append(" ".join(query_words[i:j+1])) - # # This gets rid of any accidental term inclusions because they were words - # # in another term. Ex. "3d" getting added in "3d art" - # for i, term in enumerate(all_tag_terms): - # for j, term2 in enumerate(all_tag_terms): - # if i != j and all_tag_terms[i] in all_tag_terms[j]: - # # print( - # # f'removing {all_tag_terms[i]} because {all_tag_terms[i]} was in {all_tag_terms[j]}') - # all_tag_terms.remove(all_tag_terms[i]) - # break - - # for term in all_tag_terms: - # for id in self._tag_names_to_tag_id_map[term]: - # if id not in tag_ids: - # tag_ids.append(id) - # return tag_ids - - # NOTE: I'd expect a blank query to return all with the other implementation, but - # it misses stuff like Archive (id 0) so here's this as a catch-all. - query = query.strip() - if not query: - all: list[int] = [] - for tag in self.tags: - if ignore_builtin and tag.id >= 1000: - all.append(tag.id) - elif not ignore_builtin: - all.append(tag.id) - return all - - # Direct port from Version 8 =========================================== - # TODO: Make this more efficient (if needed) - # ids: list[int] = [] - id_weights: list[tuple[int, int]] = [] - # partial_id_weights: list[int] = [] - priority_ids: list[int] = [] - # print(f'Query: \"{query}\" -------------------------------------') - for string in self._tag_strings_to_id_map: # O(n), n = tags - exact_match: bool = False - partial_match: bool = False - query = strip_punctuation(query).lower() - string = strip_punctuation(string).lower() - - if query == string: - exact_match = True - elif string.startswith(query): - if len(query) >= (len(string) // (len(string) if threshold == 1 else threshold)): - partial_match = True - - if exact_match or partial_match: - # Avg O(1), usually 1 item - for tag_id in self._tag_strings_to_id_map[string]: - proceed: bool = False - if ignore_builtin and tag_id >= 1000: - proceed = True - elif not ignore_builtin: - proceed = True - - if proceed: - if tag_id not in [x[0] for x in id_weights]: - if exact_match: - # print(f'[{query}] EXACT MATCH:') - # print(self.get_tag_from_id(tag_id).display_name(self)) - # print('') - # time.sleep(0.1) - priority_ids.append(tag_id) - id_weights.append((tag_id, 100000000)) - else: - # print(f'[{query}] Partial Match:') - # print(self.get_tag_from_id(tag_id).display_name(self)) - # print('') - # time.sleep(0.1) - # ids.append(id) - id_weights.append((tag_id, 0)) - # O(m), m = # of references - if include_cluster: - for id in self.get_tag_cluster(tag_id): - if (id, 0) not in id_weights: - id_weights.append((id, 0)) - - # Contextual Weighing - if context and ((len(id_weights) > 1 and len(priority_ids) > 1) or (len(priority_ids) > 1)): - context_strings: list[str] = [s.replace(' ', '').replace('_', '').replace('-', '').replace( - "'", '').replace('(', '').replace(')', '').replace('[', '').replace(']', '').lower() for s in context] - for term in context: - if len(term.split(' ')) > 1: - context_strings += term.split(' ') - if len(term.split('_')) > 1: - context_strings += term.split('_') - if len(term.split('-')) > 1: - context_strings += term.split('-') - context_strings = list(set(context_strings)) - # context_strings.sort() # NOTE: TEMP!!!!!!!!!!!!!!!!!! - # print(f'Context Strings: {context_strings}') - # time.sleep(3) - # for term in context: - # context_ids += self.filter_tags(query=term, include_cluster=True, ignore_builtin=ignore_builtin) - for i, idw in enumerate(id_weights, start=0): - weight: int = 0 - tag_strings: list[str] = [] - subtag_ids: list[int] = self.get_all_child_tag_ids(idw[0]) - for id in self.get_tag_cluster(idw[0]): - subtag_ids += self.get_all_child_tag_ids(id) - subtag_ids = list(set(subtag_ids)) - - for sub_id in subtag_ids: - tag_strings += [self.get_tag(sub_id).name] + [self.get_tag( - sub_id).shorthand] + self.get_tag(sub_id).aliases - - # for id in self.get_tag_cluster(idw[0]): - # tag_strings += [self.get_tag_from_id(id).name] + [self.get_tag_from_id(id).shorthand] + self.get_tag_from_id(id).aliases - split: list[str] = [] - for ts in tag_strings: - if len(ts.split(' ')) > 1: - split += ts.split(' ') - tag_strings += split - tag_strings = [s.replace(' ', '').replace('_', '').replace( - '-', '').replace("'", '').lower() for s in tag_strings] - while '' in tag_strings: - tag_strings.remove('') - tag_strings = list(set(tag_strings)) - # tag_strings.sort() # NOTE: TEMP!!!!!!!!!!!!!!!!!! - for ts in tag_strings: - weight += context_strings.count(ts) - id_weights[i] = (idw[0], idw[1]+weight) - - # print(f'Tag Strings for {self.get_tag_from_id(idw[0]).display_name(self)}: {tag_strings}') - # time.sleep(3) - id_weights = sorted(id_weights, key=lambda id: id[1], reverse=True) - - # if len(id_weights) > 1: - # print(f'Context Weights: \"{id_weights}\"') - - final: list[int] = [] - - # if context and id_weights: - # time.sleep(3) - [final.append(idw[0]) for idw in id_weights if idw[0] not in final] - # print(f'Final IDs: \"{[self.get_tag_from_id(id).display_name(self) for id in final]}\"') - # print('') - return final - - def get_all_child_tag_ids(self, tag_id: int) -> list[int]: - """Recursively traverse a Tag's subtags and return a list of all children tags.""" - subtag_ids: list[int] = [] - if self.get_tag(tag_id).subtag_ids: - for sub_id in self.get_tag(tag_id).subtag_ids: - if sub_id not in subtag_ids: - subtag_ids.append(sub_id) - subtag_ids += self.get_all_child_tag_ids(sub_id) - else: - return [tag_id] - - return subtag_ids - - def filter_field_templates(self: str, query) -> list[int]: - """Returns a list of Field Template IDs returned from a string query.""" - - matches: list[int] = [] - for ft in self.default_fields: - if ft['name'].lower().startswith(query.lower()): - matches.append(ft['id']) - - return matches - - def update_tag(self, tag: Tag) -> None: - """ - Edits a Tag in the Library. - This function undoes and redos the following parts of the 'add_tag_to_library()' process:\n - - Un-maps the old Tag name, shorthand, and aliases from the Tag ID - and re-maps the new strings to its ID via '_map_tag_names_to_tag_id()'.\n - - Un - """ - tag.subtag_ids = [x for x in tag.subtag_ids if x != tag.id] - - # Since the ID stays the same when editing, only the Tag object is needed. - # Merging Tags is handled in a different function. - old_tag: Tag = self.get_tag(tag.id) - - # Undo and Redo 'self._map_tag_names_to_tag_id(tag)' =========================================================== - # got to map[old names] and remove reference to this id. - # Remember that _tag_names_to_tag_id_map maps strings to a LIST of ids. - # print( - # f'Removing connection from "{old_tag.name.lower()}" to {old_tag.id} in {self._tag_names_to_tag_id_map[old_tag.name.lower()]}') - old_name: str = strip_punctuation(old_tag.name).lower() - self._tag_strings_to_id_map[old_name].remove(old_tag.id) - # Delete the map key if it doesn't point to any other IDs. - if not self._tag_strings_to_id_map[old_name]: - del self._tag_strings_to_id_map[old_name] - if old_tag.shorthand: - old_sh: str = strip_punctuation(old_tag.shorthand).lower() - # print( - # f'Removing connection from "{old_tag.shorthand.lower()}" to {old_tag.id} in {self._tag_names_to_tag_id_map[old_tag.shorthand.lower()]}') - self._tag_strings_to_id_map[old_sh].remove(old_tag.id) - # Delete the map key if it doesn't point to any other IDs. - if not self._tag_strings_to_id_map[old_sh]: - del self._tag_strings_to_id_map[old_sh] - if old_tag.aliases: - for alias in old_tag.aliases: - old_a: str = strip_punctuation(alias).lower() - # print( - # f'Removing connection from "{alias.lower()}" to {old_tag.id} in {self._tag_names_to_tag_id_map[alias.lower()]}') - self._tag_strings_to_id_map[old_a].remove(old_tag.id) - # Delete the map key if it doesn't point to any other IDs. - if not self._tag_strings_to_id_map[old_a]: - del self._tag_strings_to_id_map[old_a] - # then add new reference to this id at map[new names] - # print(f'Mapping new names for "{tag.name.lower()}" (ID: {tag.id})') - self._map_tag_strings_to_tag_id(tag) - - # Redo 'self.tags.append(tag)' ================================================================================= - # then swap out the tag in the tags list to this one - # print(f'Swapping {self.tags[self._tag_id_to_index_map[old_tag.id]]} *FOR* {tag} in tags list.') - self.tags[self._tag_id_to_index_map[old_tag.id]] = tag - print(f'Edited Tag: {tag}') - - # Undo and Redo 'self._map_tag_id_to_cluster(tag)' ============================================================= - # NOTE: Currently the tag is getting updated outside of this due to python - # entanglement shenanigans so for now this method will always update the cluster maps. - # if old_tag.subtag_ids != tag.subtag_ids: - # TODO: Optimize this by 1,000,000% buy building an inverse recursive map function - # instead of literally just deleting the whole map and building it again - # print('Reticulating Splines...') - self._tag_id_to_cluster_map.clear() - for tag in self.tags: - self._map_tag_id_to_cluster(tag) - # print('Splines Reticulated.') - - self._map_tag_id_to_cluster(tag) - - def remove_tag(self, tag_id: int) -> None: - """ - Removes a Tag from the Library. - Disconnects it from all internal lists and maps, then remaps others as needed. - """ - tag = self.get_tag(tag_id) - - # Step [1/7]: - # Remove from Entries. - for e in self.entries: - if e.fields: - for f in e.fields: - if self.get_field_attr(f, 'type') == 'tag_box': - if tag_id in self.get_field_attr(f, 'content'): - self.get_field_attr(f, 'content').remove(tag.id) - - # Step [2/7]: - # Remove from Subtags. - for t in self.tags: - if t.subtag_ids: - if tag_id in t.subtag_ids: - t.subtag_ids.remove(tag.id) - - # Step [3/7]: - # Remove ID -> cluster reference. - 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, 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: - del self._tag_id_to_index_map[tag.id] - - # Step [5/7]: - # Remove this Tag from the tags list. - self.tags.remove(tag) - - # Step [6/7]: - # Remap the other Tag IDs to their new indices in the tags list. - self._tag_id_to_index_map.clear() - for i, t in enumerate(self.tags): - self._map_tag_id_to_index(t, i) - - # Step [7/7]: - # Remap all existing Tag names. - self._tag_strings_to_id_map.clear() - for t in self.tags: - self._map_tag_strings_to_tag_id(t) - - def get_tag_ref_count(self, tag_id: int) -> tuple[int, int]: - """Returns an int tuple (entry_ref_count, subtag_ref_count) of Tag reference counts.""" - entry_ref_count: int = 0 - subtag_ref_count: int = 0 - - for e in self.entries: - if e.fields: - for f in e.fields: - if self.get_field_attr(f, 'type') == 'tag_box': - if tag_id in self.get_field_attr(f, 'content'): - entry_ref_count += 1 - break - - for t in self.tags: - if t.subtag_ids: - if tag_id in t.subtag_ids: - subtag_ref_count += 1 - - # input() - return (entry_ref_count, subtag_ref_count) - - def update_entry_path(self, entry_id: int, path: str) -> None: - """Updates an Entry's path.""" - self.get_entry(entry_id).path = path - - def update_entry_filename(self, entry_id: int, filename: str) -> None: - """Updates an Entry's filename.""" - self.get_entry(entry_id).filename = filename - - def update_entry_field(self, entry_id: int, field_index: int, content, mode: str): - """Updates an Entry's specific field. Modes: append, remove, replace.""" - - field_id: int = list(self.get_entry(entry_id).fields[field_index].keys())[0] - if mode.lower() == 'append' or mode.lower() == 'extend': - for i in content: - if i not in self.get_entry(entry_id).fields[field_index][field_id]: - self.get_entry(entry_id).fields[field_index][field_id].append( - i) - elif mode.lower() == 'replace': - self.get_entry(entry_id).fields[field_index][field_id] = content - elif mode.lower() == 'remove': - for i in content: - self.get_entry(entry_id).fields[field_index][field_id].remove( - i) - - def does_field_content_exist(self, entry_id: int, field_id: int, content) -> bool: - """Returns whether or not content exists in a specific entry field type.""" - # entry = self.entries[entry_index] - entry = self.get_entry(entry_id) - indices = self.get_field_index_in_entry(entry, field_id) - for i in indices: - if self.get_field_attr(entry.fields[i], 'content') == content: - return True - return False - - def add_generic_data_to_entry(self, data, entry_id: int): - """Adds generic data to an Entry on a "best guess" basis. Used in adding scraped data.""" - if data: - - # Add a Title Field if the data doesn't already exist. - 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) - self.update_entry_field( - entry_id, -1, data["title"], 'replace') - - # Add an Author Field if the data doesn't already exist. - 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) - self.update_entry_field( - entry_id, -1, data["author"], 'replace') - - # Add an Artist Field if the data doesn't already exist. - 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) - self.update_entry_field( - entry_id, -1, data["artist"], 'replace') - - # Add a Date Published Field if the data doesn't already exist. - 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')) - if not self.does_field_content_exist(entry_id, field_id, date): - self.add_field_to_entry(entry_id, field_id) - # entry = self.entries[entry_id] - self.update_entry_field(entry_id, -1, date, 'replace') - - # Process String Tags if the data doesn't already exist. - 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 - notes_field_id = 5 # Notes Field ID - tags: list[str] = data['tags'] - # extra: list[str] = [] - # for tag in tags: - # if len(tag.split(' ')) > 1: - # extra += tag.split(' ') - # if len(tag.split('_')) > 1: - # extra += tag.split('_') - # if len(tag.split('-')) > 1: - # extra += tag.split('-') - # tags = tags + extra - # tags = list(set(tags)) - extra: list[str] = [] - for tag in tags: - if len(tag.split('_(')) > 1: - extra += tag.replace(')', '').split('_(') - tags += extra - tags = list(set(tags)) - tags.sort() - - while '' in tags: - tags.remove('') - - # # If the tags were a single string (space delimitated), split them into a list. - # if isinstance(data["tags"], str): - # tags.clear() - # tags = data["tags"].split(' ') - - # Try to add matching tags in library. - for tag in tags: - matching: list[int] = self.search_tags( - tag.replace('_', ' ').replace('-', ' '), include_cluster=False, ignore_builtin=True, threshold=2, context=tags) - priority_field_index = -1 - if matching: - - # NOTE: The following commented-out code enables the ability - # to prefer an existing built-in tag_box field to add to - # rather than preferring or creating a 'Content Tags' felid. - # In my experience, this feature isn't actually what I want, - # but the idea behind it isn't bad. Maybe this could be - # user configurable and scale with custom fields. - - # tag_field_indices = self.get_field_index_in_entry( - # entry_index, tags_field_id) - content_tags_field_indices = self.get_field_index_in_entry( - self.get_entry(entry_id), content_tags_field_id) - # meta_tags_field_indices = self.get_field_index_in_entry( - # entry_index, meta_tags_field_id) - - if content_tags_field_indices: - priority_field_index = content_tags_field_indices[0] - # elif tag_field_indices: - # priority_field_index = tag_field_indices[0] - # elif meta_tags_field_indices: - # priority_field_index = meta_tags_field_indices[0] - - if priority_field_index > 0: - self.update_entry_field(entry_id, priority_field_index, [ - matching[0]], 'append') - else: - self.add_field_to_entry( - entry_id, content_tags_field_id) - self.update_entry_field( - entry_id, -1, [matching[0]], 'append') - - # Add all original string tags as a note. - str_tags = f'Original Tags: {tags}' - if not self.does_field_content_exist(entry_id, notes_field_id, str_tags): - self.add_field_to_entry(entry_id, notes_field_id) - self.update_entry_field( - entry_id, -1, str_tags, 'replace') - - # Add a Description Field if the data doesn't already exist. - if "description" in data.keys() and data["description"]: - field_id = 4 # Description Field ID - if not self.does_field_content_exist(entry_id, field_id, data['description']): - self.add_field_to_entry(entry_id, field_id) - self.update_entry_field( - entry_id, -1, data["description"], 'replace') - if "content" in data.keys() and data["content"]: - field_id = 4 # Description Field ID - if not self.does_field_content_exist(entry_id, field_id, data['content']): - self.add_field_to_entry(entry_id, field_id) - self.update_entry_field( - entry_id, -1, data["content"], 'replace') - if "source" in data.keys() and data["source"]: - field_id = 21 # Source Field ID - for source in data['source'].split(' '): - if source and source != ' ': - source = strip_web_protocol(string=source) - if not self.does_field_content_exist(entry_id, field_id, source): - self.add_field_to_entry(entry_id, field_id) - self.update_entry_field( - entry_id, -1, source, 'replace') - - def add_field_to_entry(self, entry_id: int, field_id: int) -> None: - """Adds an empty Field, specified by Field ID, to an Entry via its index.""" - # entry = self.entries[entry_index] - entry = self.get_entry(entry_id) - field_type = self.get_field_obj(field_id)['type'] - if field_type in ts_core.TEXT_FIELDS: - entry.fields.append({int(field_id): ''}) - elif field_type == 'tag_box': - entry.fields.append({int(field_id): []}) - elif field_type == 'datetime': - entry.fields.append({int(field_id): ''}) - else: - logging.info(f'[LIBRARY][ERROR]: Unknown field id attempted to be added to entry: {field_id}') - - def mirror_entry_fields(self, entry_ids: list[int]) -> None: - """Combines and mirrors all fields across a list of given Entry IDs.""" - - all_fields = [] - all_ids = [] # Parallel to all_fields - # Extract and merge all fields from all given Entries. - for id in entry_ids: - if id: - entry: Entry = self.get_entry(id) - if entry and entry.fields: - for field in entry.fields: - # First checks if their are matching tag_boxes to append to - if self.get_field_attr(field, 'type') == 'tag_box' and self.get_field_attr(field, 'id') in all_ids: - content = self.get_field_attr(field, 'content') - for i in content: - id = int(self.get_field_attr(field, 'id')) - field_index = all_ids.index(id) - if i not in all_fields[field_index][id]: - all_fields[field_index][id].append(i) - # If not, go ahead and whichever new field. - elif field not in all_fields: - all_fields.append(field) - all_ids.append( - int(self.get_field_attr(field, 'id'))) - - # Replace each Entry's fields with the new merged ones. - for id in entry_ids: - entry: Entry = self.get_entry(id) - if entry: - entry.fields = all_fields - - # TODO: Replace this and any in CLI with a proper user-defined - # field storing method. - order: list[int] = [0] + [1, 2] + [9, 17, 18, 19, 20] + \ - [10, 14, 11, 12, 13, 22] + [4, 5] + [8, 7, 6] + [3, 21] - - # NOTE: This code is copied from the sort_fields() method. - entry.fields = sorted(entry.fields, key=lambda x: order.index( - self.get_field_attr(x, 'id'))) - - - # def move_entry_field(self, entry_index, old_index, new_index) -> None: - # """Moves a field in entry[entry_index] from position entry.fields[old_index] to entry.fields[new_index]""" - # entry = self.entries[entry_index] - # pass - # # TODO: Implement. - - def get_field_attr(self, entry_field, attribute: str): - """Returns the value of a specified attribute inside an Entry field.""" - if attribute.lower() == 'id': - return list(entry_field.keys())[0] - elif attribute.lower() == 'content': - return entry_field[self.get_field_attr(entry_field, 'id')] - else: - return self.get_field_obj(self.get_field_attr(entry_field, 'id'))[attribute.lower()] - - def get_field_obj(self, field_id: int) -> dict: - """ - Returns a field template object associated with a field ID. - The objects have "id", "name", and "type" fields. - """ - if int(field_id) < len(self.default_fields): - return self.default_fields[int(field_id)] - else: - return {'id': -1, 'name': 'Unknown Field', 'type': 'unknown'} - - def get_field_index_in_entry(self, entry: Entry, field_id: int) -> list[int]: - """ - Returns matched indices for the field type in an entry.\n - Returns an empty list of no field of that type is found in the entry. - """ - matched = [] - # entry: Entry = self.entries[entry_index] - # entry = self.get_entry(entry_id) - if entry.fields: - for i, field in enumerate(entry.fields): - if self.get_field_attr(field, 'id') == int(field_id): - matched.append(i) - - return matched - - def _map_tag_strings_to_tag_id(self, tag: Tag) -> None: - """ - Maps a Tag's name, shorthand, and aliases to their ID's (in the form of a list).\n - ⚠️DO NOT USE FOR CONFIDENT DATA REFERENCES!⚠️\n - This is intended to be used for quick search queries.\n - Uses name_and_alias_to_tag_id_map. - """ - # tag_id: int, tag_name: str, tag_aliases: list[str] = [] - name: str = strip_punctuation(tag.name).lower() - if name not in self._tag_strings_to_id_map: - self._tag_strings_to_id_map[name] = [] - self._tag_strings_to_id_map[name].append(tag.id) - - shorthand: str = strip_punctuation(tag.shorthand).lower() - if shorthand not in self._tag_strings_to_id_map: - self._tag_strings_to_id_map[shorthand] = [] - self._tag_strings_to_id_map[shorthand].append(tag.id) - - for alias in tag.aliases: - alias: str = strip_punctuation(alias).lower() - if alias not in self._tag_strings_to_id_map: - self._tag_strings_to_id_map[alias] = [] - self._tag_strings_to_id_map[alias].append(tag.id) - # print(f'{alias.lower()} -> {tag.id}') - - def _map_tag_id_to_cluster(self, tag: Tag, subtags: list[Tag] = None) -> None: - """ - Maps a Tag's subtag's ID's back to it's parent Tag's ID (in the form of a list). - Uses tag_id_to_cluster_map.\n - EX: Tag: "Johnny Bravo", Subtags: "Cartoon Network (TV)", "Character".\n - Maps "Cartoon Network" -> Johnny Bravo, "Character" -> "Johnny Bravo", and "TV" -> Johnny Bravo." - """ - # If a list of subtags is not provided, the method will revert to a level 1-depth - # mapping based on the given Tag's own subtags. - if not subtags: - subtags = [self.get_tag(sub_id) - for sub_id in tag.subtag_ids] - for subtag in subtags: - if subtag.id not in self._tag_id_to_cluster_map.keys(): - self._tag_id_to_cluster_map[subtag.id] = [] - # Stops circular references - if tag.id not in self._tag_id_to_cluster_map[subtag.id]: - self._tag_id_to_cluster_map[subtag.id].append(tag.id) - # If the subtag has subtags of it own, recursively link those to the original Tag. - if subtag.subtag_ids: - self._map_tag_id_to_cluster(tag, [self.get_tag( - sub_id) for sub_id in subtag.subtag_ids if sub_id != tag.id]) - - def _map_tag_id_to_index(self, tag: Tag, index: int) -> None: - """ - Maps a Tag's ID to the Tag's Index in self.tags. - Uses _tag_id_to_index_map. - """ - # self._tag_id_to_index_map[tag.id_] = self.tags.index(tag) - if index < 0: - index = len(self.tags) + index - self._tag_id_to_index_map[tag.id] = index - # print(f'{tag.id} - {self._tag_id_to_index_map[tag.id]}') - - def _map_entry_id_to_index(self, entry: Entry, index: int) -> None: - """ - Maps an Entry's ID to the Entry's Index in self.entries. - Uses _entry_id_to_index_map. - """ - # if index != None: - if index < 0: - index = len(self.entries) + index - self._entry_id_to_index_map[entry.id] = index - # else: - # self._entry_id_to_index_map[entry.id_] = self.entries.index(entry) - - def _map_collation_id_to_index(self, collation: Collation, index: int) -> None: - """ - Maps a Collation's ID to the Collation's Index in self.collations. - Uses _entry_id_to_index_map. - """ - # if index != None: - if index < 0: - index = len(self.collations) + index - self._collation_id_to_index_map[collation.id] = index - - def add_tag_to_library(self, tag: Tag) -> int: - """ - Adds a Tag to the Library. ⚠️Only use at runtime! (Cannot reference tags that are not loaded yet)⚠️\n - For adding Tags from the Library save file, append Tags to the Tags list - and then map them using map_library_tags(). - """ - tag.subtag_ids = [x for x in tag.subtag_ids if x != tag.id] - tag.id = self._next_tag_id - self._next_tag_id += 1 - - self._map_tag_strings_to_tag_id(tag) - self.tags.append(tag) # Must be appended before mapping the index! - self._map_tag_id_to_index(tag, -1) - self._map_tag_id_to_cluster(tag) - - return tag.id - - def get_tag(self, tag_id: int) -> Tag: - """Returns a Tag object given a Tag ID.""" - return self.tags[self._tag_id_to_index_map[int(tag_id)]] - - def get_tag_cluster(self, tag_id: int) -> list[int]: - """Returns a list of Tag IDs that reference this Tag.""" - if tag_id in self._tag_id_to_cluster_map: - return self._tag_id_to_cluster_map[int(tag_id)] - return [] - - def sort_fields(self, entry_id: int, order: list[int]) -> None: - """Sorts an Entry's Fields given an ordered list of Field IDs.""" - entry = self.get_entry(entry_id) - entry.fields = sorted(entry.fields, key=lambda x: order.index( - self.get_field_attr(x, 'id'))) + """Class for the Library object, and all CRUD operations made upon it.""" + + def __init__(self) -> None: + # Library Info ========================================================= + self.library_dir: str = None + + # Entries ============================================================== + # List of every Entry object. + self.entries: list[Entry] = [] + self._next_entry_id: int = 0 + # Map of every Entry ID to the index of the Entry in self.entries. + self._entry_id_to_index_map: dict[int, int] = {} + # # List of filtered Entry indexes generated by the filter_entries() method. + # self.filtered_entries: list[int] = [] + # Duplicate Entries + # Defined by Entries that point to files that one or more other Entries are also pointing to. + # tuple(int, list[int]) + self.dupe_entries: list[tuple[int, list[int]]] = [] + + # Collations =========================================================== + # List of every Collation object. + self.collations: list[Collation] = [] + self._next_collation_id: int = 0 + self._collation_id_to_index_map: dict[int, int] = {} + + # File Interfacing ===================================================== + self.dir_file_count: int = -1 + self.files_not_in_library: list[str] = [] + self.missing_files: list[str] = [] + self.fixed_files: list[str] = [] # TODO: Get rid of this. + self.missing_matches = {} + # Duplicate Files + # Defined by files that are exact or similar copies to others. Generated by DupeGuru. + # (Filepath, Matched Filepath, Match Percentage) + self.dupe_files: list[tuple[str, str, int]] = [] + # Maps the filenames of entries in the Library to their entry's index in the self.entries list. + # Used for O(1) lookup of a file based on the current index (page number - 1) of the image being looked at. + # 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). + self.tags: list[Tag] = [] + self._next_tag_id: int = 1000 + # Map of each Tag ID with its entry reference count. + self._tag_entry_ref_map: dict[int, int] = {} + self.tag_entry_refs: list[tuple[int, int]] = [] + # Map of every Tag name and alias to the ID(s) of its associated Tag(s). + # Used for O(1) lookup of Tag IDs based on search terms. + # NOTE: While it is recommended to keep Tag aliases unique to each Tag, + # there may be circumstances where this is not possible or elegant. + # Because of this, names and aliases are mapped to a list of IDs rather than a + # singular ID to handle potential alias collision. + self._tag_strings_to_id_map: dict[str, list[int]] = {} + # Map of every Tag ID to an array of Tag IDs that make up the Tag's "cluster", aka a list + # of references from other Tags that specify this Tag as one of its subtags. + # This in effect is like a reverse subtag map. + # Used for O(1) lookup of the Tags to return in a query given a Tag ID. + self._tag_id_to_cluster_map: dict[int, list[int]] = {} + # Map of every Tag ID to the index of the Tag in self.tags. + self._tag_id_to_index_map: dict[int, int] = {} + + self.default_tags: list[JsonTag] = [ + {"id": 0, "name": "Archived", "aliases": ["Archive"], "color": "Red"}, + { + "id": 1, + "name": "Favorite", + "aliases": ["Favorited", "Favorites"], + "color": "Yellow", + }, + ] + + # self.default_tags = [ + # Tag(id=0, name='Archived', shorthand='', aliases=['Archive'], subtags_ids=[], color='red'), + # Tag(id=1, name='Favorite', shorthand='', aliases=['Favorited, Favorites, Likes, Liked, Loved'], subtags_ids=[], color='yellow'), + # ] + + self.default_fields = [ + {"id": 0, "name": "Title", "type": "text_line"}, + {"id": 1, "name": "Author", "type": "text_line"}, + {"id": 2, "name": "Artist", "type": "text_line"}, + {"id": 3, "name": "URL", "type": "text_line"}, + {"id": 4, "name": "Description", "type": "text_box"}, + {"id": 5, "name": "Notes", "type": "text_box"}, + {"id": 6, "name": "Tags", "type": "tag_box"}, + {"id": 7, "name": "Content Tags", "type": "tag_box"}, + {"id": 8, "name": "Meta Tags", "type": "tag_box"}, + {"id": 9, "name": "Collation", "type": "collation"}, + {"id": 10, "name": "Date", "type": "datetime"}, + {"id": 11, "name": "Date Created", "type": "datetime"}, + {"id": 12, "name": "Date Modified", "type": "datetime"}, + {"id": 13, "name": "Date Taken", "type": "datetime"}, + {"id": 14, "name": "Date Published", "type": "datetime"}, + {"id": 15, "name": "Archived", "type": "checkbox"}, + {"id": 16, "name": "Favorite", "type": "checkbox"}, + {"id": 17, "name": "Book", "type": "collation"}, + {"id": 18, "name": "Comic", "type": "collation"}, + {"id": 19, "name": "Series", "type": "collation"}, + {"id": 20, "name": "Manga", "type": "collation"}, + {"id": 21, "name": "Source", "type": "text_line"}, + {"id": 22, "name": "Date Uploaded", "type": "datetime"}, + {"id": 23, "name": "Date Released", "type": "datetime"}, + {"id": 24, "name": "Volume", "type": "collation"}, + {"id": 25, "name": "Anthology", "type": "collation"}, + {"id": 26, "name": "Magazine", "type": "collation"}, + {"id": 27, "name": "Publisher", "type": "text_line"}, + {"id": 28, "name": "Guest Artist", "type": "text_line"}, + {"id": 29, "name": "Composer", "type": "text_line"}, + {"id": 30, "name": "Comments", "type": "text_box"}, + ] + + def create_library(self, path) -> int: + """ + Creates a TagStudio library in the given directory.\n + Return Codes:\n + 0: Library Successfully Created\n + 2: File creation error + """ + + path = os.path.normpath(path).rstrip("\\") + + # If '.TagStudio' is included in the path, trim the path up to it. + if ts_core.TS_FOLDER_NAME in path: + path = path.split(ts_core.TS_FOLDER_NAME)[0] + + try: + self.clear_internal_vars() + self.library_dir = path + self.verify_ts_folders() + self.save_library_to_disk() + self.open_library(self.library_dir) + except: + traceback.print_exc() + return 2 + + return 0 + + def verify_ts_folders(self) -> None: + """Verifies/creates folders required by TagStudio.""" + + full_ts_path = os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}") + full_backup_path = os.path.normpath( + f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}" + ) + full_collage_path = os.path.normpath( + f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.COLLAGE_FOLDER_NAME}" + ) + + if not os.path.isdir(full_ts_path): + os.mkdir(full_ts_path) + + if not os.path.isdir(full_backup_path): + os.mkdir(full_backup_path) + + if not os.path.isdir(full_collage_path): + os.mkdir(full_collage_path) + + def verify_default_tags(self, tag_list: list[JsonTag]) -> list[JsonTag]: + """ + 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. + """ + missing: list[JsonTag] = [] + + for dt in self.default_tags: + if dt["id"] not in [t["id"] for t in tag_list]: + missing.append(dt) + + for m in missing: + tag_list.append(m) + + return tag_list + + def open_library(self, path: str) -> int: + """ + Opens a TagStudio v9+ Library. + Returns 0 if library does not exist, 1 if successfully opened, 2 if corrupted. + """ + + return_code: int = 2 + path = os.path.normpath(path).rstrip("\\") + + # If '.TagStudio' is included in the path, trim the path up to it. + if ts_core.TS_FOLDER_NAME in path: + path = path.split(ts_core.TS_FOLDER_NAME)[0] + + if os.path.exists( + os.path.normpath(f"{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json") + ): + try: + with open( + os.path.normpath( + f"{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json" + ), + "r", + encoding="utf-8", + ) as f: + json_dump: JsonLibary = ujson.load(f) + self.library_dir = str(path) + 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() + + # Step 1: Verify default built-in tags are present. + json_dump["tags"] = self.verify_default_tags(json_dump["tags"]) + + for tag in json_dump["tags"]: + # 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 = int(tag.get("id", 0)) + + # 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 = tag.get("name", "") + shorthand = tag.get("shorthand", "") + aliases = tag.get("aliases", []) + subtag_ids = tag.get("subtag_ids", []) + color = tag.get("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) + 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: + self._map_tag_id_to_cluster(t) + + end_time = time.time() + logging.info( + f"[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds" + ) + + # Parse Entries ------------------------------------------------ + if entries := json_dump.get("entries"): + start_time = time.time() + for entry in entries: + if "id" in entry: + id = int(entry["id"]) + if id >= self._next_entry_id: + self._next_entry_id = id + 1 + else: + # Version 9.1.x+ Compatibility + id = self._next_entry_id + self._next_entry_id += 1 + + filename = entry.get("filename", "") + e_path = entry.get("path", "") + fields = [] + if "fields" in entry: + # Cast JSON str keys to ints + for f in entry["fields"]: + f[int(list(f.keys())[0])] = f[list(f.keys())[0]] + del f[list(f.keys())[0]] + fields = entry["fields"] + + # Look through fields for legacy Collation data -------- + if int(major) >= 9 and int(minor) < 1: + for f in fields: + if self.get_field_attr(f, "type") == "collation": + # NOTE: This legacy support will be removed in + # a later version, probably 9.2. + # Legacy Collation data present in v9.0.x + # DATA SHAPE: {name: str, page: int} + + # We'll do an inefficient linear search each + # time to convert the legacy data. + matched = False + collation_id = -1 + for c in self.collations: + if ( + c.title + == self.get_field_attr(f, "content")[ + "name" + ] + ): + c.e_ids_and_pages.append( + ( + id, + int( + self.get_field_attr( + f, "content" + )["page"] + ), + ) + ) + matched = True + collation_id = c.id + if not matched: + c = Collation( + id=self._next_collation_id, + title=self.get_field_attr(f, "content")[ + "name" + ], + e_ids_and_pages=[], + sort_order="", + ) + collation_id = self._next_collation_id + self._next_collation_id += 1 + c.e_ids_and_pages.append( + ( + id, + int( + self.get_field_attr( + f, "content" + )["page"] + ), + ) + ) + self.collations.append(c) + self._map_collation_id_to_index(c, -1) + f_id = self.get_field_attr(f, "id") + f.clear() + f[int(f_id)] = collation_id + # Collation Field data present in v9.1.x+ + # DATA SHAPE: int + elif int(major) >= 9 and int(minor) >= 1: + pass + + e = Entry( + id=int(id), + filename=filename, + path=e_path, + fields=fields, + ) + self.entries.append(e) + self._map_entry_id_to_index(e, -1) + end_time = time.time() + logging.info( + f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds" + ) + + # Parse Collations --------------------------------------------------- + if "collations" in json_dump.keys(): + start_time = time.time() + for collation in json_dump["collations"]: + # Step 1: Create a Collation object and append it to + # the internal Collations list, then map that + # Collation's ID to its index in the Collations list. + + id = int(collation.get("id", 0)) + if id >= self._next_collation_id: + self._next_collation_id = id + 1 + + 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=id, + title=title, + e_ids_and_pages=e_ids_and_pages, + sort_order=sort_order, + cover_id=cover_id, + ) + + # NOTE: This does NOT use the add_collation_to_library() method + # which is intended to be used at runtime. However, there is + # currently no reason why it couldn't be used here, and is + # instead not used for consistency. + self.collations.append(c) + self._map_collation_id_to_index(c, -1) + end_time = time.time() + logging.info( + f"[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds" + ) + + return_code = 1 + except ujson.JSONDecodeError: + logging.info("[LIBRARY][ERROR]: Empty JSON file!") + + # If the Library is loaded, continue other processes. + if return_code == 1: + if not os.path.exists( + os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}") + ): + os.makedirs( + os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}") + ) + + self._map_filenames_to_entry_ids() + + return return_code + + # @deprecated('Use new Entry ID system.') + def _map_filenames_to_entry_ids(self): + """Maps a full filepath to its corresponding Entry's ID.""" + self.filename_to_entry_id_map.clear() + for entry in self.entries: + if os.name == "nt": + # print(str(os.path.normpath( + # f'{entry.path}/{entry.filename}')).lower().lstrip('\\').lstrip('/')) + self.filename_to_entry_id_map[ + str(os.path.normpath(f"{entry.path}/{entry.filename}")) + .lower() + .lstrip("\\") + .lstrip("/") + ] = entry.id + else: + self.filename_to_entry_id_map[ + str(os.path.normpath(f"{entry.path}/{entry.filename}")).lstrip("/") + ] = entry.id + + # def _map_filenames_to_entry_ids(self): + # """Maps the file paths of entries to their index in the library list.""" + # self.file_to_entry_index_map.clear() + # for i, entry in enumerate(self.entries): + # if os.name == 'nt': + # self.file_to_entry_index_map[str(os.path.normpath( + # f'{entry.path}/{entry.filename}')).lower()] = i + # else: + # self.file_to_entry_index_map[str( + # os.path.normpath(f'{entry.path}/{entry.filename}'))] = i + + # def close_library(self, save: bool = True): + # """Closes the open TagStudio Library.""" + # self.clear_internal_vars() + + def to_json(self): + """ + Creates a JSON serialized string from the Library object. + Used in saving the library to disk. + """ + + file_to_save: JsonLibary = { + "ts-version": ts_core.VERSION, + "ignored_extensions": [], + "tags": [], + "collations": [], + "fields": [], + "macros": [], + "entries": [], + } + + print("[LIBRARY] Formatting Tags to JSON...") + + file_to_save["ignored_extensions"] = [i for i in self.ignored_extensions if i] + + 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()) + + print("[LIBRARY] Formatting Collations to JSON...") + for collation in self.collations: + file_to_save["collations"].append(collation.compressed_dict()) + + print("[LIBRARY] Done Formatting to JSON!") + return file_to_save + + def save_library_to_disk(self): + """Saves the Library to disk at the default TagStudio folder location.""" + + logging.info(f"[LIBRARY] Saving Library to Disk...") + start_time = time.time() + filename = "ts_library.json" + + self.verify_ts_folders() + + with open( + os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{filename}"), + "w", + encoding="utf-8", + ) as outfile: + outfile.flush() + ujson.dump( + self.to_json(), + outfile, + ensure_ascii=False, + escape_forward_slashes=False, + ) + # , indent=4 <-- How to prettyprint dump + end_time = time.time() + logging.info( + f"[LIBRARY] Library saved to disk in {(end_time - start_time):.3f} seconds" + ) + + def save_library_backup_to_disk(self) -> str: + """ + Saves a backup file of the Library to disk at the default TagStudio folder location. + Returns the filename used, including the date and time.""" + + logging.info(f"[LIBRARY] Saving Library Backup to Disk...") + start_time = time.time() + filename = f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json' + + self.verify_ts_folders() + with open( + os.path.normpath( + f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}/{filename}" + ), + "w", + encoding="utf-8", + ) as outfile: + outfile.flush() + ujson.dump( + self.to_json(), + outfile, + ensure_ascii=False, + escape_forward_slashes=False, + ) + end_time = time.time() + logging.info( + f"[LIBRARY] Library backup saved to disk in {(end_time - start_time):.3f} seconds" + ) + return filename + # , indent=4 <-- How to prettyprint dump + + def clear_internal_vars(self): + """Clears the internal variables of the Library object.""" + self.library_dir = None + self.is_legacy_library = False + + self.entries.clear() + self._next_entry_id: int = 0 + # self.filtered_entries.clear() + self._entry_id_to_index_map.clear() + + self._collation_id_to_index_map.clear() + + self.missing_matches = {} + self.dir_file_count: int = -1 + self.files_not_in_library.clear() + 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 + self._tag_strings_to_id_map: dict[str, list[int]] = {} + self._tag_id_to_cluster_map: dict[int, list[int]] = {} + self._tag_id_to_index_map: dict[int, int] = {} + self._tag_entry_ref_map.clear() + + def refresh_dir(self): + """Scans a directory for files, and adds those relative filenames to internal variables.""" + + # Reset file interfacing variables. + # -1 means uninitialized, aka a scan like this was never attempted before. + self.dir_file_count: int = 0 + self.files_not_in_library.clear() + + # Scans the directory for files, keeping track of: + # - Total file count + # - Files without library entries + # for type in ts_core.TYPES: + start_time = time.time() + for f in glob.glob(self.library_dir + "/**/*", recursive=True): + # p = Path(os.path.normpath(f)) + if ( + "$RECYCLE.BIN" not in f + and ts_core.TS_FOLDER_NAME not in f + and "tagstudio_thumbs" not in f + and not os.path.isdir(f) + ): + if os.path.splitext(f)[1][1:].lower() not in self.ignored_extensions: + self.dir_file_count += 1 + file = str(os.path.relpath(f, self.library_dir)) + + try: + if os.name == "nt": + _ = self.filename_to_entry_id_map[file.lower()] + else: + _ = self.filename_to_entry_id_map[file] + except KeyError: + # print(file) + self.files_not_in_library.append(file) + + # sys.stdout.write(f'\r[LIBRARY] {self.dir_file_count} files found in "{self.library_dir}"...') + # sys.stdout.flush() + end_time = time.time() + # Yield output every 1/30 of a second + if (end_time - start_time) > 0.034: + yield self.dir_file_count + start_time = time.time() + # print('') + + # Sorts the files by date modified, descending. + if len(self.files_not_in_library) <= 100000: + try: + self.files_not_in_library = sorted( + self.files_not_in_library, + key=lambda t: -os.stat( + os.path.normpath(self.library_dir + "/" + t) + ).st_ctime, + ) + except (FileExistsError, FileNotFoundError): + print( + f"[LIBRARY][ERROR] Couldn't sort files, some were moved during the scanning/sorting process." + ) + pass + else: + print( + f"[LIBRARY][INFO] Not bothering to sort files because there's OVER 100,000! Better sorting methods will be added in the future." + ) + + def refresh_missing_files(self): + """Tracks the number of Entries that point to an invalid file path.""" + self.missing_files.clear() + for i, entry in enumerate(self.entries): + full_path = os.path.normpath( + f"{self.library_dir}/{entry.path}/{entry.filename}" + ) + if not os.path.isfile(full_path): + self.missing_files.append(full_path) + yield i + + def remove_entry(self, entry_id: int) -> None: + """Removes an Entry from the Library.""" + # del self.entries[entry_index] + # self._map_filenames_to_entry_indices() + + # Step [1/2]: + # Remove this Entry from the Entries list. + entry = self.get_entry(entry_id) + path = ( + str(os.path.normpath(f"{entry.path}/{entry.filename}")) + .lstrip("\\") + .lstrip("/") + ) + path = path.lower() if os.name == "nt" else path + # logging.info(f'Removing path: {path}') + del self.filename_to_entry_id_map[path] + + del self.entries[self._entry_id_to_index_map[entry_id]] + + # self.entries.remove(self.entries[self._entry_id_to_index_map[entry_id]]) + + # Step [2/2]: + # Remap the other Entry IDs to their new indices in the Entries list. + self._entry_id_to_index_map.clear() + for i, e in enumerate(self.entries): + self._map_entry_id_to_index(e, i) + + # # Step [3/3]: + # # Remap filenames to new indices. + # self._map_filenames_to_entry_ids() + + def refresh_dupe_entries(self): + """ + Refreshes the list of duplicate Entries. + A duplicate Entry is defined as an Entry pointing to a file that one or more + other Entries are also pointing to.\n + `dupe_entries = tuple(int, list[int])` + """ + + # self.dupe_entries.clear() + # known_files: set = set() + # for entry in self.entries: + # full_path = os.path.normpath(f'{self.library_dir}/{entry.path}/{entry.filename}') + # if full_path in known_files: + # self.dupe_entries.append(full_path) + # else: + # known_files.add(full_path) + + self.dupe_entries.clear() + checked = set() + remaining: list[Entry] = list(self.entries) + for p, entry_p in enumerate(self.entries, start=0): + if p not in checked: + matched: list[int] = [] + for c, entry_c in enumerate(remaining, start=0): + if os.name == "nt": + if ( + entry_p.path.lower() == entry_c.path.lower() + and entry_p.filename.lower() == entry_c.filename.lower() + and c != p + ): + matched.append(c) + checked.add(c) + else: + if ( + entry_p.path == entry_c.path + and entry_p.filename == entry_c.filename + and c != p + ): + matched.append(c) + checked.add(c) + if matched: + self.dupe_entries.append((p, matched)) + sys.stdout.write( + f"\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has Duplicate(s): {matched}" + ) + sys.stdout.flush() + else: + sys.stdout.write( + f"\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has No Duplicates" + ) + sys.stdout.flush() + checked.add(p) + print("") + + def merge_dupe_entries(self): + """ + Merges duplicate Entries. + A duplicate Entry is defined as an Entry pointing to a file that one or more + other Entries are also pointing to.\n + `dupe_entries = tuple(int, list[int])` + """ + + print("[LIBRARY] Mirroring Duplicate Entries...") + for dupe in self.dupe_entries: + self.mirror_entry_fields([dupe[0]] + dupe[1]) + + # print('Consolidating Entries...') + # for dupe in self.dupe_entries: + # for index in dupe[1]: + # print(f'Consolidating Duplicate: {(self.entries[index].path + os.pathsep + self.entries[index].filename)}') + # self.entries.remove(self.entries[index]) + # self._map_filenames_to_entry_indices() + + print( + "[LIBRARY] Consolidating Entries... (This may take a while for larger libraries)" + ) + unique: list[Entry] = [] + for i, e in enumerate(self.entries): + if e not in unique: + unique.append(e) + # print(f'[{i}/{len(self.entries)}] Appending: {(e.path + os.pathsep + e.filename)[0:32]}...') + sys.stdout.write( + f"\r[LIBRARY] [{i}/{len(self.entries)}] Appending Unique Entry..." + ) + else: + sys.stdout.write( + f"\r[LIBRARY] [{i}/{len(self.entries)}] Consolidating Duplicate: {(e.path + os.pathsep + e.filename)[0:]}..." + ) + print("") + # [unique.append(x) for x in self.entries if x not in unique] + self.entries = unique + self._map_filenames_to_entry_ids() + + def refresh_dupe_files(self, results_filepath): + """ + Refreshes the list of duplicate files. + A duplicate file is defined as an identical or near-identical file as determined + by a DupeGuru results file. + """ + full_results_path = ( + os.path.normpath(f"{self.library_dir}/{results_filepath}") + if self.library_dir not in results_filepath + else os.path.normpath(f"{results_filepath}") + ) + if os.path.exists(full_results_path): + self.dupe_files.clear() + self._map_filenames_to_entry_ids() + tree = ET.parse(full_results_path) + root = tree.getroot() + for i, group in enumerate(root): + # print(f'-------------------- Match Group {i}---------------------') + files: list[str] = [] + # (File Index, Matched File Index, Match Percentage) + matches: list[tuple[int, int, int]] = [] + for element in group: + if element.tag == "file": + file = element.attrib.get("path") + files.append(file) + if element.tag == "match": + matches.append( + ( + int(element.attrib.get("first")), + int(element.attrib.get("second")), + int(element.attrib.get("percentage")), + ) + ) + for match in matches: + # print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}') + if os.name == "nt": + file_1 = str(os.path.relpath(files[match[0]], self.library_dir)) + file_2 = str(os.path.relpath(files[match[1]], self.library_dir)) + if ( + file_1.lower() in self.filename_to_entry_id_map.keys() + and file_2.lower() in self.filename_to_entry_id_map.keys() + ): + self.dupe_files.append( + (files[match[0]], files[match[1]], match[2]) + ) + else: + if ( + file_1 in self.filename_to_entry_id_map.keys() + and file_2 in self.filename_to_entry_id_map.keys() + ): + self.dupe_files.append( + (files[match[0]], files[match[1]], match[2]) + ) + # self.dupe_files.append((files[match[0]], files[match[1]], match[2])) + + print("") + + for dupe in self.dupe_files: + print( + f"[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}", + end="\n", + ) + # self.dupe_files.append(full_path) + + def remove_missing_files(self): + deleted = [] + for i, missing in enumerate(self.missing_files): + # pb.setValue(i) + # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') + try: + id = self.get_entry_id_from_filepath(missing) + logging.info(f"Removing Entry ID {id}:\n\t{missing}") + self.remove_entry(id) + # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) + deleted.append(missing) + except KeyError: + logging.info( + f'[LIBRARY][ERROR]: "{id}" was reported as missing, but is not in the file_to_entry_id map.' + ) + yield (i, id) + for d in deleted: + self.missing_files.remove(d) + + def remove_missing_matches(self, fixed_indices: list[int]): + """Removes a list of fixed Entry indices from the internal missing_matches list.""" + for i in fixed_indices: + del self.missing_matches[i] + + def fix_missing_files(self): + """ + Attempts to repair Entries that point to invalid file paths. + """ + + # self.refresh_missing_files() + + # matched_json_filepath = os.path.normpath( + # f'{self.library_dir}/{TS_FOLDER_NAME}/missing_matched.json') + # # if not os.path.exists(matched_json_filepath): + # # self.match_missing_files() + + self.missing_matches.clear() + + fixed_indices = [] + # if os.path.exists(matched_json_filepath): + # with open(matched_json_filepath, "r", encoding="utf8") as f: + # self.missing_matches = json.load(f) + + # self.refresh_missing_files() + for i, missing in enumerate(self.missing_files): + print(missing) + if missing not in self.missing_matches.keys(): + matches = self._match_missing_file(missing) + if matches: + print(f"[LIBRARY] Adding key {missing} with matches {matches}") + self.missing_matches[missing] = matches + yield (i, True) + else: + yield (i, False) + + # self._purge_empty_missing_entries() + + for i, matches in enumerate(self.missing_matches): + if len(self.missing_matches[matches]) == 1: + id = self.get_entry_id_from_filepath(matches) + self.update_entry_path(id, self.missing_matches[matches][0]) + fixed_indices.append(matches) + # print(f'Fixed {self.entries[self.get_entry_index_from_filename(i)].filename}') + print(f"[LIBRARY] Fixed {self.get_entry(id).filename}") + # (int, str) + + self._map_filenames_to_entry_ids() + self.remove_missing_matches(fixed_indices) + + # for i in fixed_indices: + # # print(json_dump[i]) + # del self.missing_matches[i] + + # with open(matched_json_filepath, "w") as outfile: + # outfile.flush() + # json.dump({}, outfile, indent=4) + # print(f'Re-saved to disk at {matched_json_filepath}') + + def _match_missing_file(self, file: str) -> list[str]: + """ + Tries to find missing entry files within the library directory. + Works if files were just moved to different subfolders and don't have duplicate names. + """ + + # self.refresh_missing_files() + + matches = [] + + # for file in self.missing_files: + head, tail = os.path.split(file) + for root, dirs, files in os.walk(self.library_dir, topdown=True): + for f in files: + # print(f'{tail} --- {f}') + if tail == f and "$recycle.bin" not in root.lower(): + # self.fixed_files.append(tail) + + new_path = str(os.path.relpath(root, self.library_dir)) + + matches.append(new_path) + + # if file not in matches.keys(): + # matches[file] = [] + # matches[file].append(new_path) + + print( + f'[LIBRARY] MATCH: {file} \n\t-> {os.path.normpath(self.library_dir + "/" + new_path + "/" + tail)}\n' + ) + + if not matches: + print(f"[LIBRARY] No matches found for: {file}") + + return matches + + # with open( + # os.path.normpath( + # f"{self.library_dir}/{TS_FOLDER_NAME}/missing_matched.json" + # ), + # "w", + # ) as outfile: + # outfile.flush() + # json.dump(matches, outfile, indent=4) + # print( + # f'[LIBRARY] Saved to disk at {os.path.normpath(self.library_dir + "/" + TS_FOLDER_NAME + "/missing_matched.json")}' + # ) + + def count_tag_entry_refs(self) -> None: + """ + Counts the number of entry references for each tag. Stores results + in `tag_entry_ref_map`. + """ + self._tag_entry_ref_map.clear() + self.tag_entry_refs.clear() + local_hits: set = set() + + for entry in self.entries: + local_hits.clear() + if entry.fields: + for field in entry.fields: + if self.get_field_attr(field, "type") == "tag_box": + for tag_id in self.get_field_attr(field, "content"): + local_hits.add(tag_id) + + for hit in list(local_hits): + try: + _ = self._tag_entry_ref_map[hit] + except KeyError: + self._tag_entry_ref_map[hit] = 0 + self._tag_entry_ref_map[hit] += 1 + + # keys = list(self.tag_entry_ref_map.keys()) + # values = list(self.tag_entry_ref_map.values()) + self.tag_entry_refs = sorted( + self._tag_entry_ref_map.items(), key=lambda x: x[1], reverse=True + ) + + def add_entry_to_library(self, entry: Entry): + """Adds a new Entry to the Library.""" + self.entries.append(entry) + self._map_entry_id_to_index(entry, -1) + + def add_new_files_as_entries(self) -> list[int]: + """Adds files from the `files_not_in_library` list to the Library as Entries. Returns list of added indices.""" + new_ids: list[int] = [] + for file in self.files_not_in_library: + path, filename = os.path.split(file) + # print(os.path.split(file)) + entry = Entry( + id=self._next_entry_id, filename=filename, path=path, fields=[] + ) + self._next_entry_id += 1 + self.add_entry_to_library(entry) + new_ids.append(entry.id) + self._map_filenames_to_entry_ids() + self.files_not_in_library.clear() + return new_ids + + self.files_not_in_library.clear() + + def get_entry(self, entry_id: int) -> Entry: + """Returns an Entry object given an Entry ID.""" + return self.entries[self._entry_id_to_index_map[int(entry_id)]] + + def get_collation(self, collation_id: int) -> Collation: + """Returns a Collation object given an Collation ID.""" + return self.collations[self._collation_id_to_index_map[int(collation_id)]] + + # @deprecated('Use new Entry ID system.') + def get_entry_from_index(self, index: int) -> Entry: + """Returns a Library Entry object given its index in the unfiltered Entries list.""" + if self.entries: + return self.entries[int(index)] + + # @deprecated('Use new Entry ID system.') + def get_entry_id_from_filepath(self, filename): + """Returns an Entry ID given the full filepath it points to.""" + try: + if self.entries: + if os.name == "nt": + return self.filename_to_entry_id_map[ + str( + os.path.normpath( + os.path.relpath(filename, self.library_dir) + ) + ).lower() + ] + return self.filename_to_entry_id_map[ + str(os.path.normpath(os.path.relpath(filename, self.library_dir))) + ] + except: + return -1 + + def search_library( + self, query: str = None, entries=True, collations=True, tag_groups=True + ) -> list[tuple[ItemType, int]]: + """ + Uses a search query to generate a filtered results list. + Returns a list of (str, int) tuples consisting of a result type and ID. + """ + + # self.filtered_entries.clear() + results: list[tuple[ItemType, int]] = [] + collations_added = [] + + if query: + # start_time = time.time() + query: str = query.strip().lower() + query_words: list[str] = query.split(" ") + all_tag_terms: list[str] = [] + 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 = "no author" in query or "no artist" in query + + # Preprocess the Tag terms. + 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 + ): + all_tag_terms.append(" ".join(query_words[i : j + 1])) + # This gets rid of any accidental term inclusions because they were words + # in another term. Ex. "3d" getting added in "3d art" + for i, term in enumerate(all_tag_terms): + for j, term2 in enumerate(all_tag_terms): + if i != j and all_tag_terms[i] in all_tag_terms[j]: + # print( + # f'removing {all_tag_terms[i]} because {all_tag_terms[i]} was in {all_tag_terms[j]}') + all_tag_terms.remove(all_tag_terms[i]) + break + + # print(all_tag_terms) + + # 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 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}') + + # 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') + # sys.stdout.flush() + + # except: + # # # Put this here to have new non-registered images show up + # # if query == "untagged" or query == "no author" or query == "no artist": + # # self.filtered_file_list.append(file) + # # non_entry_count = non_entry_count + 1 + # pass + + # end_time = time.time() + # print( + # f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)') + + # if non_entry_count: + # print( + # f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.') + # if not self.filtered_entries: + # print("[INFO][FILTER]: Filter returned no results.") + else: + for entry in self.entries: + added = False + 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)) + # 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] = None, + ) -> list[int]: + """Returns a list of Tag IDs returned from a string query.""" + # tag_ids: list[int] = [] + # if query: + # query = query.lower() + # query_words = query.split(' ') + # all_tag_terms: list[str] = [] + + # # Preprocess the Tag terms. + # if len(query_words) > 0: + # 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_names_to_tag_id_map: + # all_tag_terms.append(" ".join(query_words[i:j+1])) + # # This gets rid of any accidental term inclusions because they were words + # # in another term. Ex. "3d" getting added in "3d art" + # for i, term in enumerate(all_tag_terms): + # for j, term2 in enumerate(all_tag_terms): + # if i != j and all_tag_terms[i] in all_tag_terms[j]: + # # print( + # # f'removing {all_tag_terms[i]} because {all_tag_terms[i]} was in {all_tag_terms[j]}') + # all_tag_terms.remove(all_tag_terms[i]) + # break + + # for term in all_tag_terms: + # for id in self._tag_names_to_tag_id_map[term]: + # if id not in tag_ids: + # tag_ids.append(id) + # return tag_ids + + # NOTE: I'd expect a blank query to return all with the other implementation, but + # it misses stuff like Archive (id 0) so here's this as a catch-all. + query = query.strip() + if not query: + all: list[int] = [] + for tag in self.tags: + if ignore_builtin and tag.id >= 1000: + all.append(tag.id) + elif not ignore_builtin: + all.append(tag.id) + return all + + # Direct port from Version 8 =========================================== + # TODO: Make this more efficient (if needed) + # ids: list[int] = [] + id_weights: list[tuple[int, int]] = [] + # partial_id_weights: list[int] = [] + priority_ids: list[int] = [] + # print(f'Query: \"{query}\" -------------------------------------') + for string in self._tag_strings_to_id_map: # O(n), n = tags + exact_match: bool = False + partial_match: bool = False + query = strip_punctuation(query).lower() + string = strip_punctuation(string).lower() + + if query == string: + exact_match = True + elif string.startswith(query): + if len(query) >= ( + len(string) // (len(string) if threshold == 1 else threshold) + ): + partial_match = True + + if exact_match or partial_match: + # Avg O(1), usually 1 item + for tag_id in self._tag_strings_to_id_map[string]: + proceed: bool = False + if ignore_builtin and tag_id >= 1000: + proceed = True + elif not ignore_builtin: + proceed = True + + if proceed: + if tag_id not in [x[0] for x in id_weights]: + if exact_match: + # print(f'[{query}] EXACT MATCH:') + # print(self.get_tag_from_id(tag_id).display_name(self)) + # print('') + # time.sleep(0.1) + priority_ids.append(tag_id) + id_weights.append((tag_id, 100000000)) + else: + # print(f'[{query}] Partial Match:') + # print(self.get_tag_from_id(tag_id).display_name(self)) + # print('') + # time.sleep(0.1) + # ids.append(id) + id_weights.append((tag_id, 0)) + # O(m), m = # of references + if include_cluster: + for id in self.get_tag_cluster(tag_id): + if (id, 0) not in id_weights: + id_weights.append((id, 0)) + + # Contextual Weighing + if context and ( + (len(id_weights) > 1 and len(priority_ids) > 1) or (len(priority_ids) > 1) + ): + context_strings: list[str] = [ + s.replace(" ", "") + .replace("_", "") + .replace("-", "") + .replace("'", "") + .replace("(", "") + .replace(")", "") + .replace("[", "") + .replace("]", "") + .lower() + for s in context + ] + for term in context: + if len(term.split(" ")) > 1: + context_strings += term.split(" ") + if len(term.split("_")) > 1: + context_strings += term.split("_") + if len(term.split("-")) > 1: + context_strings += term.split("-") + context_strings = list(set(context_strings)) + # context_strings.sort() # NOTE: TEMP!!!!!!!!!!!!!!!!!! + # print(f'Context Strings: {context_strings}') + # time.sleep(3) + # for term in context: + # context_ids += self.filter_tags(query=term, include_cluster=True, ignore_builtin=ignore_builtin) + for i, idw in enumerate(id_weights, start=0): + weight: int = 0 + tag_strings: list[str] = [] + subtag_ids: list[int] = self.get_all_child_tag_ids(idw[0]) + for id in self.get_tag_cluster(idw[0]): + subtag_ids += self.get_all_child_tag_ids(id) + subtag_ids = list(set(subtag_ids)) + + for sub_id in subtag_ids: + tag_strings += ( + [self.get_tag(sub_id).name] + + [self.get_tag(sub_id).shorthand] + + self.get_tag(sub_id).aliases + ) + + # for id in self.get_tag_cluster(idw[0]): + # tag_strings += [self.get_tag_from_id(id).name] + [self.get_tag_from_id(id).shorthand] + self.get_tag_from_id(id).aliases + split: list[str] = [] + for ts in tag_strings: + if len(ts.split(" ")) > 1: + split += ts.split(" ") + tag_strings += split + tag_strings = [ + s.replace(" ", "") + .replace("_", "") + .replace("-", "") + .replace("'", "") + .lower() + for s in tag_strings + ] + while "" in tag_strings: + tag_strings.remove("") + tag_strings = list(set(tag_strings)) + # tag_strings.sort() # NOTE: TEMP!!!!!!!!!!!!!!!!!! + for ts in tag_strings: + weight += context_strings.count(ts) + id_weights[i] = (idw[0], idw[1] + weight) + + # print(f'Tag Strings for {self.get_tag_from_id(idw[0]).display_name(self)}: {tag_strings}') + # time.sleep(3) + id_weights = sorted(id_weights, key=lambda id: id[1], reverse=True) + + # if len(id_weights) > 1: + # print(f'Context Weights: \"{id_weights}\"') + + final: list[int] = [] + + # if context and id_weights: + # time.sleep(3) + [final.append(idw[0]) for idw in id_weights if idw[0] not in final] + # print(f'Final IDs: \"{[self.get_tag_from_id(id).display_name(self) for id in final]}\"') + # print('') + return final + + def get_all_child_tag_ids(self, tag_id: int) -> list[int]: + """Recursively traverse a Tag's subtags and return a list of all children tags.""" + subtag_ids: list[int] = [] + if self.get_tag(tag_id).subtag_ids: + for sub_id in self.get_tag(tag_id).subtag_ids: + if sub_id not in subtag_ids: + subtag_ids.append(sub_id) + subtag_ids += self.get_all_child_tag_ids(sub_id) + else: + return [tag_id] + + return subtag_ids + + def filter_field_templates(self: str, query) -> list[int]: + """Returns a list of Field Template IDs returned from a string query.""" + + matches: list[int] = [] + for ft in self.default_fields: + if ft["name"].lower().startswith(query.lower()): + matches.append(ft["id"]) + + return matches + + def update_tag(self, tag: Tag) -> None: + """ + Edits a Tag in the Library. + This function undoes and redos the following parts of the 'add_tag_to_library()' process:\n + - Un-maps the old Tag name, shorthand, and aliases from the Tag ID + and re-maps the new strings to its ID via '_map_tag_names_to_tag_id()'.\n + - Un + """ + tag.subtag_ids = [x for x in tag.subtag_ids if x != tag.id] + + # Since the ID stays the same when editing, only the Tag object is needed. + # Merging Tags is handled in a different function. + old_tag: Tag = self.get_tag(tag.id) + + # Undo and Redo 'self._map_tag_names_to_tag_id(tag)' =========================================================== + # got to map[old names] and remove reference to this id. + # Remember that _tag_names_to_tag_id_map maps strings to a LIST of ids. + # print( + # f'Removing connection from "{old_tag.name.lower()}" to {old_tag.id} in {self._tag_names_to_tag_id_map[old_tag.name.lower()]}') + old_name: str = strip_punctuation(old_tag.name).lower() + self._tag_strings_to_id_map[old_name].remove(old_tag.id) + # Delete the map key if it doesn't point to any other IDs. + if not self._tag_strings_to_id_map[old_name]: + del self._tag_strings_to_id_map[old_name] + if old_tag.shorthand: + old_sh: str = strip_punctuation(old_tag.shorthand).lower() + # print( + # f'Removing connection from "{old_tag.shorthand.lower()}" to {old_tag.id} in {self._tag_names_to_tag_id_map[old_tag.shorthand.lower()]}') + self._tag_strings_to_id_map[old_sh].remove(old_tag.id) + # Delete the map key if it doesn't point to any other IDs. + if not self._tag_strings_to_id_map[old_sh]: + del self._tag_strings_to_id_map[old_sh] + if old_tag.aliases: + for alias in old_tag.aliases: + old_a: str = strip_punctuation(alias).lower() + # print( + # f'Removing connection from "{alias.lower()}" to {old_tag.id} in {self._tag_names_to_tag_id_map[alias.lower()]}') + self._tag_strings_to_id_map[old_a].remove(old_tag.id) + # Delete the map key if it doesn't point to any other IDs. + if not self._tag_strings_to_id_map[old_a]: + del self._tag_strings_to_id_map[old_a] + # then add new reference to this id at map[new names] + # print(f'Mapping new names for "{tag.name.lower()}" (ID: {tag.id})') + self._map_tag_strings_to_tag_id(tag) + + # Redo 'self.tags.append(tag)' ================================================================================= + # then swap out the tag in the tags list to this one + # print(f'Swapping {self.tags[self._tag_id_to_index_map[old_tag.id]]} *FOR* {tag} in tags list.') + self.tags[self._tag_id_to_index_map[old_tag.id]] = tag + print(f"Edited Tag: {tag}") + + # Undo and Redo 'self._map_tag_id_to_cluster(tag)' ============================================================= + # NOTE: Currently the tag is getting updated outside of this due to python + # entanglement shenanigans so for now this method will always update the cluster maps. + # if old_tag.subtag_ids != tag.subtag_ids: + # TODO: Optimize this by 1,000,000% buy building an inverse recursive map function + # instead of literally just deleting the whole map and building it again + # print('Reticulating Splines...') + self._tag_id_to_cluster_map.clear() + for tag in self.tags: + self._map_tag_id_to_cluster(tag) + # print('Splines Reticulated.') + + self._map_tag_id_to_cluster(tag) + + def remove_tag(self, tag_id: int) -> None: + """ + Removes a Tag from the Library. + Disconnects it from all internal lists and maps, then remaps others as needed. + """ + tag = self.get_tag(tag_id) + + # Step [1/7]: + # Remove from Entries. + for e in self.entries: + if e.fields: + for f in e.fields: + if self.get_field_attr(f, "type") == "tag_box": + if tag_id in self.get_field_attr(f, "content"): + self.get_field_attr(f, "content").remove(tag.id) + + # Step [2/7]: + # Remove from Subtags. + for t in self.tags: + if t.subtag_ids: + if tag_id in t.subtag_ids: + t.subtag_ids.remove(tag.id) + + # Step [3/7]: + # Remove ID -> cluster reference. + 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, 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: + del self._tag_id_to_index_map[tag.id] + + # Step [5/7]: + # Remove this Tag from the tags list. + self.tags.remove(tag) + + # Step [6/7]: + # Remap the other Tag IDs to their new indices in the tags list. + self._tag_id_to_index_map.clear() + for i, t in enumerate(self.tags): + self._map_tag_id_to_index(t, i) + + # Step [7/7]: + # Remap all existing Tag names. + self._tag_strings_to_id_map.clear() + for t in self.tags: + self._map_tag_strings_to_tag_id(t) + + def get_tag_ref_count(self, tag_id: int) -> tuple[int, int]: + """Returns an int tuple (entry_ref_count, subtag_ref_count) of Tag reference counts.""" + entry_ref_count: int = 0 + subtag_ref_count: int = 0 + + for e in self.entries: + if e.fields: + for f in e.fields: + if self.get_field_attr(f, "type") == "tag_box": + if tag_id in self.get_field_attr(f, "content"): + entry_ref_count += 1 + break + + for t in self.tags: + if t.subtag_ids: + if tag_id in t.subtag_ids: + subtag_ref_count += 1 + + # input() + return (entry_ref_count, subtag_ref_count) + + def update_entry_path(self, entry_id: int, path: str) -> None: + """Updates an Entry's path.""" + self.get_entry(entry_id).path = path + + def update_entry_filename(self, entry_id: int, filename: str) -> None: + """Updates an Entry's filename.""" + self.get_entry(entry_id).filename = filename + + def update_entry_field(self, entry_id: int, field_index: int, content, mode: str): + """Updates an Entry's specific field. Modes: append, remove, replace.""" + + field_id: int = list(self.get_entry(entry_id).fields[field_index].keys())[0] + if mode.lower() == "append" or mode.lower() == "extend": + for i in content: + if i not in self.get_entry(entry_id).fields[field_index][field_id]: + self.get_entry(entry_id).fields[field_index][field_id].append(i) + elif mode.lower() == "replace": + self.get_entry(entry_id).fields[field_index][field_id] = content + elif mode.lower() == "remove": + for i in content: + self.get_entry(entry_id).fields[field_index][field_id].remove(i) + + def does_field_content_exist(self, entry_id: int, field_id: int, content) -> bool: + """Returns whether or not content exists in a specific entry field type.""" + # entry = self.entries[entry_index] + entry = self.get_entry(entry_id) + indices = self.get_field_index_in_entry(entry, field_id) + for i in indices: + if self.get_field_attr(entry.fields[i], "content") == content: + return True + return False + + def add_generic_data_to_entry(self, data, entry_id: int): + """Adds generic data to an Entry on a "best guess" basis. Used in adding scraped data.""" + if data: + # Add a Title Field if the data doesn't already exist. + 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) + self.update_entry_field(entry_id, -1, data["title"], "replace") + + # Add an Author Field if the data doesn't already exist. + 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) + self.update_entry_field(entry_id, -1, data["author"], "replace") + + # Add an Artist Field if the data doesn't already exist. + 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) + self.update_entry_field(entry_id, -1, data["artist"], "replace") + + # Add a Date Published Field if the data doesn't already exist. + 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" + ) + ) + if not self.does_field_content_exist(entry_id, field_id, date): + self.add_field_to_entry(entry_id, field_id) + # entry = self.entries[entry_id] + self.update_entry_field(entry_id, -1, date, "replace") + + # Process String Tags if the data doesn't already exist. + 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 + notes_field_id = 5 # Notes Field ID + tags: list[str] = data["tags"] + # extra: list[str] = [] + # for tag in tags: + # if len(tag.split(' ')) > 1: + # extra += tag.split(' ') + # if len(tag.split('_')) > 1: + # extra += tag.split('_') + # if len(tag.split('-')) > 1: + # extra += tag.split('-') + # tags = tags + extra + # tags = list(set(tags)) + extra: list[str] = [] + for tag in tags: + if len(tag.split("_(")) > 1: + extra += tag.replace(")", "").split("_(") + tags += extra + tags = list(set(tags)) + tags.sort() + + while "" in tags: + tags.remove("") + + # # If the tags were a single string (space delimitated), split them into a list. + # if isinstance(data["tags"], str): + # tags.clear() + # tags = data["tags"].split(' ') + + # Try to add matching tags in library. + for tag in tags: + matching: list[int] = self.search_tags( + tag.replace("_", " ").replace("-", " "), + include_cluster=False, + ignore_builtin=True, + threshold=2, + context=tags, + ) + priority_field_index = -1 + if matching: + # NOTE: The following commented-out code enables the ability + # to prefer an existing built-in tag_box field to add to + # rather than preferring or creating a 'Content Tags' felid. + # In my experience, this feature isn't actually what I want, + # but the idea behind it isn't bad. Maybe this could be + # user configurable and scale with custom fields. + + # tag_field_indices = self.get_field_index_in_entry( + # entry_index, tags_field_id) + content_tags_field_indices = self.get_field_index_in_entry( + self.get_entry(entry_id), content_tags_field_id + ) + # meta_tags_field_indices = self.get_field_index_in_entry( + # entry_index, meta_tags_field_id) + + if content_tags_field_indices: + priority_field_index = content_tags_field_indices[0] + # elif tag_field_indices: + # priority_field_index = tag_field_indices[0] + # elif meta_tags_field_indices: + # priority_field_index = meta_tags_field_indices[0] + + if priority_field_index > 0: + self.update_entry_field( + entry_id, priority_field_index, [matching[0]], "append" + ) + else: + self.add_field_to_entry(entry_id, content_tags_field_id) + self.update_entry_field( + entry_id, -1, [matching[0]], "append" + ) + + # Add all original string tags as a note. + str_tags = f"Original Tags: {tags}" + if not self.does_field_content_exist( + entry_id, notes_field_id, str_tags + ): + self.add_field_to_entry(entry_id, notes_field_id) + self.update_entry_field(entry_id, -1, str_tags, "replace") + + # Add a Description Field if the data doesn't already exist. + if "description" in data.keys() and data["description"]: + field_id = 4 # Description Field ID + if not self.does_field_content_exist( + entry_id, field_id, data["description"] + ): + self.add_field_to_entry(entry_id, field_id) + self.update_entry_field( + entry_id, -1, data["description"], "replace" + ) + if "content" in data.keys() and data["content"]: + field_id = 4 # Description Field ID + if not self.does_field_content_exist( + entry_id, field_id, data["content"] + ): + self.add_field_to_entry(entry_id, field_id) + self.update_entry_field(entry_id, -1, data["content"], "replace") + if "source" in data.keys() and data["source"]: + field_id = 21 # Source Field ID + for source in data["source"].split(" "): + if source and source != " ": + source = strip_web_protocol(string=source) + if not self.does_field_content_exist( + entry_id, field_id, source + ): + self.add_field_to_entry(entry_id, field_id) + self.update_entry_field(entry_id, -1, source, "replace") + + def add_field_to_entry(self, entry_id: int, field_id: int) -> None: + """Adds an empty Field, specified by Field ID, to an Entry via its index.""" + # entry = self.entries[entry_index] + entry = self.get_entry(entry_id) + field_type = self.get_field_obj(field_id)["type"] + if field_type in ts_core.TEXT_FIELDS: + entry.fields.append({int(field_id): ""}) + elif field_type == "tag_box": + entry.fields.append({int(field_id): []}) + elif field_type == "datetime": + entry.fields.append({int(field_id): ""}) + else: + logging.info( + f"[LIBRARY][ERROR]: Unknown field id attempted to be added to entry: {field_id}" + ) + + def mirror_entry_fields(self, entry_ids: list[int]) -> None: + """Combines and mirrors all fields across a list of given Entry IDs.""" + + all_fields = [] + all_ids = [] # Parallel to all_fields + # Extract and merge all fields from all given Entries. + for id in entry_ids: + if id: + entry: Entry = self.get_entry(id) + if entry and entry.fields: + for field in entry.fields: + # First checks if their are matching tag_boxes to append to + if ( + self.get_field_attr(field, "type") == "tag_box" + and self.get_field_attr(field, "id") in all_ids + ): + content = self.get_field_attr(field, "content") + for i in content: + id = int(self.get_field_attr(field, "id")) + field_index = all_ids.index(id) + if i not in all_fields[field_index][id]: + all_fields[field_index][id].append(i) + # If not, go ahead and whichever new field. + elif field not in all_fields: + all_fields.append(field) + all_ids.append(int(self.get_field_attr(field, "id"))) + + # Replace each Entry's fields with the new merged ones. + for id in entry_ids: + entry: Entry = self.get_entry(id) + if entry: + entry.fields = all_fields + + # TODO: Replace this and any in CLI with a proper user-defined + # field storing method. + order: list[int] = ( + [0] + + [1, 2] + + [9, 17, 18, 19, 20] + + [10, 14, 11, 12, 13, 22] + + [4, 5] + + [8, 7, 6] + + [3, 21] + ) + + # NOTE: This code is copied from the sort_fields() method. + entry.fields = sorted( + entry.fields, + key=lambda x: order.index(self.get_field_attr(x, "id")), + ) + + # def move_entry_field(self, entry_index, old_index, new_index) -> None: + # """Moves a field in entry[entry_index] from position entry.fields[old_index] to entry.fields[new_index]""" + # entry = self.entries[entry_index] + # pass + # # TODO: Implement. + + def get_field_attr(self, entry_field, attribute: str): + """Returns the value of a specified attribute inside an Entry field.""" + if attribute.lower() == "id": + return list(entry_field.keys())[0] + elif attribute.lower() == "content": + return entry_field[self.get_field_attr(entry_field, "id")] + else: + return self.get_field_obj(self.get_field_attr(entry_field, "id"))[ + attribute.lower() + ] + + def get_field_obj(self, field_id: int) -> dict: + """ + Returns a field template object associated with a field ID. + The objects have "id", "name", and "type" fields. + """ + if int(field_id) < len(self.default_fields): + return self.default_fields[int(field_id)] + else: + return {"id": -1, "name": "Unknown Field", "type": "unknown"} + + def get_field_index_in_entry(self, entry: Entry, field_id: int) -> list[int]: + """ + Returns matched indices for the field type in an entry.\n + Returns an empty list of no field of that type is found in the entry. + """ + matched = [] + # entry: Entry = self.entries[entry_index] + # entry = self.get_entry(entry_id) + if entry.fields: + for i, field in enumerate(entry.fields): + if self.get_field_attr(field, "id") == int(field_id): + matched.append(i) + + return matched + + def _map_tag_strings_to_tag_id(self, tag: Tag) -> None: + """ + Maps a Tag's name, shorthand, and aliases to their ID's (in the form of a list).\n + ⚠️DO NOT USE FOR CONFIDENT DATA REFERENCES!⚠️\n + This is intended to be used for quick search queries.\n + Uses name_and_alias_to_tag_id_map. + """ + # tag_id: int, tag_name: str, tag_aliases: list[str] = [] + name: str = strip_punctuation(tag.name).lower() + if name not in self._tag_strings_to_id_map: + self._tag_strings_to_id_map[name] = [] + self._tag_strings_to_id_map[name].append(tag.id) + + shorthand: str = strip_punctuation(tag.shorthand).lower() + if shorthand not in self._tag_strings_to_id_map: + self._tag_strings_to_id_map[shorthand] = [] + self._tag_strings_to_id_map[shorthand].append(tag.id) + + for alias in tag.aliases: + alias: str = strip_punctuation(alias).lower() + if alias not in self._tag_strings_to_id_map: + self._tag_strings_to_id_map[alias] = [] + self._tag_strings_to_id_map[alias].append(tag.id) + # print(f'{alias.lower()} -> {tag.id}') + + def _map_tag_id_to_cluster(self, tag: Tag, subtags: list[Tag] = None) -> None: + """ + Maps a Tag's subtag's ID's back to it's parent Tag's ID (in the form of a list). + Uses tag_id_to_cluster_map.\n + EX: Tag: "Johnny Bravo", Subtags: "Cartoon Network (TV)", "Character".\n + Maps "Cartoon Network" -> Johnny Bravo, "Character" -> "Johnny Bravo", and "TV" -> Johnny Bravo." + """ + # If a list of subtags is not provided, the method will revert to a level 1-depth + # mapping based on the given Tag's own subtags. + if not subtags: + subtags = [self.get_tag(sub_id) for sub_id in tag.subtag_ids] + for subtag in subtags: + if subtag.id not in self._tag_id_to_cluster_map.keys(): + self._tag_id_to_cluster_map[subtag.id] = [] + # Stops circular references + if tag.id not in self._tag_id_to_cluster_map[subtag.id]: + self._tag_id_to_cluster_map[subtag.id].append(tag.id) + # If the subtag has subtags of it own, recursively link those to the original Tag. + if subtag.subtag_ids: + self._map_tag_id_to_cluster( + tag, + [ + self.get_tag(sub_id) + for sub_id in subtag.subtag_ids + if sub_id != tag.id + ], + ) + + def _map_tag_id_to_index(self, tag: Tag, index: int) -> None: + """ + Maps a Tag's ID to the Tag's Index in self.tags. + Uses _tag_id_to_index_map. + """ + # self._tag_id_to_index_map[tag.id_] = self.tags.index(tag) + if index < 0: + index = len(self.tags) + index + self._tag_id_to_index_map[tag.id] = index + # print(f'{tag.id} - {self._tag_id_to_index_map[tag.id]}') + + def _map_entry_id_to_index(self, entry: Entry, index: int) -> None: + """ + Maps an Entry's ID to the Entry's Index in self.entries. + Uses _entry_id_to_index_map. + """ + # if index != None: + if index < 0: + index = len(self.entries) + index + self._entry_id_to_index_map[entry.id] = index + # else: + # self._entry_id_to_index_map[entry.id_] = self.entries.index(entry) + + def _map_collation_id_to_index(self, collation: Collation, index: int) -> None: + """ + Maps a Collation's ID to the Collation's Index in self.collations. + Uses _entry_id_to_index_map. + """ + # if index != None: + if index < 0: + index = len(self.collations) + index + self._collation_id_to_index_map[collation.id] = index + + def add_tag_to_library(self, tag: Tag) -> int: + """ + Adds a Tag to the Library. ⚠️Only use at runtime! (Cannot reference tags that are not loaded yet)⚠️\n + For adding Tags from the Library save file, append Tags to the Tags list + and then map them using map_library_tags(). + """ + tag.subtag_ids = [x for x in tag.subtag_ids if x != tag.id] + tag.id = self._next_tag_id + self._next_tag_id += 1 + + self._map_tag_strings_to_tag_id(tag) + self.tags.append(tag) # Must be appended before mapping the index! + self._map_tag_id_to_index(tag, -1) + self._map_tag_id_to_cluster(tag) + + return tag.id + + def get_tag(self, tag_id: int) -> Tag: + """Returns a Tag object given a Tag ID.""" + return self.tags[self._tag_id_to_index_map[int(tag_id)]] + + def get_tag_cluster(self, tag_id: int) -> list[int]: + """Returns a list of Tag IDs that reference this Tag.""" + if tag_id in self._tag_id_to_cluster_map: + return self._tag_id_to_cluster_map[int(tag_id)] + return [] + + def sort_fields(self, entry_id: int, order: list[int]) -> None: + """Sorts an Entry's Fields given an ordered list of Field IDs.""" + entry = self.get_entry(entry_id) + entry.fields = sorted( + entry.fields, key=lambda x: order.index(self.get_field_attr(x, "id")) + ) diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index 062918f..0faf327 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -14,230 +14,267 @@ class ColorType(Enum): _TAG_COLORS = { - '': {ColorType.PRIMARY: '#1E1A33', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#2B2547', - ColorType.LIGHT_ACCENT: '#CDA7F7', - ColorType.DARK_ACCENT: '#1E1A33', - }, - 'black': {ColorType.PRIMARY: '#111018', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#18171e', - ColorType.LIGHT_ACCENT: '#b7b6be', - ColorType.DARK_ACCENT: '#03020a', - }, - 'dark gray': {ColorType.PRIMARY: '#24232a', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#2a2930', - ColorType.LIGHT_ACCENT: '#bdbcc4', - ColorType.DARK_ACCENT: '#07060e', - }, - 'gray': {ColorType.PRIMARY: '#53525a', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#5b5a62', - ColorType.LIGHT_ACCENT: '#cbcad2', - ColorType.DARK_ACCENT: '#191820', - }, - 'light gray': {ColorType.PRIMARY: '#aaa9b0', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#b6b4bc', - ColorType.LIGHT_ACCENT: '#cbcad2', - ColorType.DARK_ACCENT: '#191820', - }, - 'white': {ColorType.PRIMARY: '#f2f1f8', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#fefeff', - ColorType.LIGHT_ACCENT: '#ffffff', - ColorType.DARK_ACCENT: '#302f36', - }, - 'light pink': {ColorType.PRIMARY: '#ff99c4', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#ffaad0', - ColorType.LIGHT_ACCENT: '#ffcbe7', - ColorType.DARK_ACCENT: '#6c2e3b', - }, - 'pink': {ColorType.PRIMARY: '#ff99c4', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#ffaad0', - ColorType.LIGHT_ACCENT: '#ffcbe7', - ColorType.DARK_ACCENT: '#6c2e3b', - }, - 'magenta': {ColorType.PRIMARY: '#f6466f', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#f7587f', - ColorType.LIGHT_ACCENT: '#fba4bf', - ColorType.DARK_ACCENT: '#61152f', - }, - 'red': {ColorType.PRIMARY: '#e22c3c', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#b21f2d', - # ColorType.BORDER: '#e54252', - ColorType.LIGHT_ACCENT: '#f39caa', - ColorType.DARK_ACCENT: '#440d12', - }, - 'red orange': {ColorType.PRIMARY: '#e83726', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#ea4b3b', - ColorType.LIGHT_ACCENT: '#f5a59d', - ColorType.DARK_ACCENT: '#61120b', - }, - 'salmon': {ColorType.PRIMARY: '#f65848', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#f76c5f', - ColorType.LIGHT_ACCENT: '#fcadaa', - ColorType.DARK_ACCENT: '#6f1b16', - }, - 'orange': {ColorType.PRIMARY: '#ed6022', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#ef7038', - ColorType.LIGHT_ACCENT: '#f7b79b', - ColorType.DARK_ACCENT: '#551e0a', - }, - 'yellow orange': {ColorType.PRIMARY: '#fa9a2c', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#fba94b', - ColorType.LIGHT_ACCENT: '#fdd7ab', - ColorType.DARK_ACCENT: '#66330d', - }, - 'yellow': {ColorType.PRIMARY: '#ffd63d', - ColorType.TEXT: ColorType.DARK_ACCENT, - # ColorType.BORDER: '#ffe071', - ColorType.BORDER: '#e8af31', - ColorType.LIGHT_ACCENT: '#fff3c4', - ColorType.DARK_ACCENT: '#754312', - }, - 'mint': {ColorType.PRIMARY: '#4aed90', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#79f2b1', - ColorType.LIGHT_ACCENT: '#c8fbe9', - ColorType.DARK_ACCENT: '#164f3e', - }, - 'lime': {ColorType.PRIMARY: '#92e649', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#b2ed72', - ColorType.LIGHT_ACCENT: '#e9f9b7', - ColorType.DARK_ACCENT: '#405516', - }, - 'light green': {ColorType.PRIMARY: '#85ec76', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#a3f198', - ColorType.LIGHT_ACCENT: '#e7fbe4', - ColorType.DARK_ACCENT: '#2b5524', - }, - 'green': {ColorType.PRIMARY: '#28bb48', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#43c568', - ColorType.LIGHT_ACCENT: '#93e2c8', - ColorType.DARK_ACCENT: '#0d3828', - }, - 'teal': {ColorType.PRIMARY: '#1ad9b2', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#4de3c7', - ColorType.LIGHT_ACCENT: '#a0f3e8', - ColorType.DARK_ACCENT: '#08424b', - }, - 'cyan': {ColorType.PRIMARY: '#49e4d5', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#76ebdf', - ColorType.LIGHT_ACCENT: '#bff5f0', - ColorType.DARK_ACCENT: '#0f4246', - }, - 'light blue': {ColorType.PRIMARY: '#55bbf6', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#70c6f7', - ColorType.LIGHT_ACCENT: '#bbe4fb', - ColorType.DARK_ACCENT: '#122541', - }, - 'blue': {ColorType.PRIMARY: '#3b87f0', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#4e95f2', - ColorType.LIGHT_ACCENT: '#aedbfa', - ColorType.DARK_ACCENT: '#122948', - }, - 'blue violet': {ColorType.PRIMARY: '#5948f2', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#6258f3', - ColorType.LIGHT_ACCENT: '#9cb8fb', - ColorType.DARK_ACCENT: '#1b1649', - }, - 'violet': {ColorType.PRIMARY: '#874ff5', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#9360f6', - ColorType.LIGHT_ACCENT: '#c9b0fa', - ColorType.DARK_ACCENT: '#3a1860', - }, - 'purple': {ColorType.PRIMARY: '#bb4ff0', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#c364f2', - ColorType.LIGHT_ACCENT: '#dda7f7', - ColorType.DARK_ACCENT: '#531862', - }, - 'peach': {ColorType.PRIMARY: '#f1c69c', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#f4d4b4', - ColorType.LIGHT_ACCENT: '#fbeee1', - ColorType.DARK_ACCENT: '#613f2f', - }, - 'brown': {ColorType.PRIMARY: '#823216', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#8a3e22', - ColorType.LIGHT_ACCENT: '#cd9d83', - ColorType.DARK_ACCENT: '#3a1804', - }, - 'lavender': {ColorType.PRIMARY: '#ad8eef', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#b99ef2', - ColorType.LIGHT_ACCENT: '#d5c7fa', - ColorType.DARK_ACCENT: '#492b65', - }, - 'blonde': {ColorType.PRIMARY: '#efc664', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#f3d387', - ColorType.LIGHT_ACCENT: '#faebc6', - ColorType.DARK_ACCENT: '#6d461e', - }, - 'auburn': {ColorType.PRIMARY: '#a13220', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#aa402f', - ColorType.LIGHT_ACCENT: '#d98a7f', - ColorType.DARK_ACCENT: '#3d100a', - }, - 'light brown': {ColorType.PRIMARY: '#be5b2d', - ColorType.TEXT: ColorType.DARK_ACCENT, - ColorType.BORDER: '#c4693d', - ColorType.LIGHT_ACCENT: '#e5b38c', - ColorType.DARK_ACCENT: '#4c290e', - }, - 'dark brown': {ColorType.PRIMARY: '#4c2315', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#542a1c', - ColorType.LIGHT_ACCENT: '#b78171', - ColorType.DARK_ACCENT: '#211006', - }, - 'cool gray': {ColorType.PRIMARY: '#515768', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#5b6174', - ColorType.LIGHT_ACCENT: '#9ea1c3', - ColorType.DARK_ACCENT: '#181a37', - }, - 'warm gray': {ColorType.PRIMARY: '#625550', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#6c5e57', - ColorType.LIGHT_ACCENT: '#c0a392', - ColorType.DARK_ACCENT: '#371d18', - }, - 'olive': {ColorType.PRIMARY: '#4c652e', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#586f36', - ColorType.LIGHT_ACCENT: '#b4c17a', - ColorType.DARK_ACCENT: '#23300e', - }, - 'berry': {ColorType.PRIMARY: '#9f2aa7', - ColorType.TEXT: ColorType.LIGHT_ACCENT, - ColorType.BORDER: '#aa43b4', - ColorType.LIGHT_ACCENT: '#cc8fdc', - ColorType.DARK_ACCENT: '#41114a', - }, + "": { + ColorType.PRIMARY: "#1E1A33", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#2B2547", + ColorType.LIGHT_ACCENT: "#CDA7F7", + ColorType.DARK_ACCENT: "#1E1A33", + }, + "black": { + ColorType.PRIMARY: "#111018", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#18171e", + ColorType.LIGHT_ACCENT: "#b7b6be", + ColorType.DARK_ACCENT: "#03020a", + }, + "dark gray": { + ColorType.PRIMARY: "#24232a", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#2a2930", + ColorType.LIGHT_ACCENT: "#bdbcc4", + ColorType.DARK_ACCENT: "#07060e", + }, + "gray": { + ColorType.PRIMARY: "#53525a", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#5b5a62", + ColorType.LIGHT_ACCENT: "#cbcad2", + ColorType.DARK_ACCENT: "#191820", + }, + "light gray": { + ColorType.PRIMARY: "#aaa9b0", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#b6b4bc", + ColorType.LIGHT_ACCENT: "#cbcad2", + ColorType.DARK_ACCENT: "#191820", + }, + "white": { + ColorType.PRIMARY: "#f2f1f8", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#fefeff", + ColorType.LIGHT_ACCENT: "#ffffff", + ColorType.DARK_ACCENT: "#302f36", + }, + "light pink": { + ColorType.PRIMARY: "#ff99c4", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#ffaad0", + ColorType.LIGHT_ACCENT: "#ffcbe7", + ColorType.DARK_ACCENT: "#6c2e3b", + }, + "pink": { + ColorType.PRIMARY: "#ff99c4", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#ffaad0", + ColorType.LIGHT_ACCENT: "#ffcbe7", + ColorType.DARK_ACCENT: "#6c2e3b", + }, + "magenta": { + ColorType.PRIMARY: "#f6466f", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#f7587f", + ColorType.LIGHT_ACCENT: "#fba4bf", + ColorType.DARK_ACCENT: "#61152f", + }, + "red": { + ColorType.PRIMARY: "#e22c3c", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#b21f2d", + # ColorType.BORDER: '#e54252', + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", + }, + "red orange": { + ColorType.PRIMARY: "#e83726", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#ea4b3b", + ColorType.LIGHT_ACCENT: "#f5a59d", + ColorType.DARK_ACCENT: "#61120b", + }, + "salmon": { + ColorType.PRIMARY: "#f65848", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#f76c5f", + ColorType.LIGHT_ACCENT: "#fcadaa", + ColorType.DARK_ACCENT: "#6f1b16", + }, + "orange": { + ColorType.PRIMARY: "#ed6022", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#ef7038", + ColorType.LIGHT_ACCENT: "#f7b79b", + ColorType.DARK_ACCENT: "#551e0a", + }, + "yellow orange": { + ColorType.PRIMARY: "#fa9a2c", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#fba94b", + ColorType.LIGHT_ACCENT: "#fdd7ab", + ColorType.DARK_ACCENT: "#66330d", + }, + "yellow": { + ColorType.PRIMARY: "#ffd63d", + ColorType.TEXT: ColorType.DARK_ACCENT, + # ColorType.BORDER: '#ffe071', + ColorType.BORDER: "#e8af31", + ColorType.LIGHT_ACCENT: "#fff3c4", + ColorType.DARK_ACCENT: "#754312", + }, + "mint": { + ColorType.PRIMARY: "#4aed90", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#79f2b1", + ColorType.LIGHT_ACCENT: "#c8fbe9", + ColorType.DARK_ACCENT: "#164f3e", + }, + "lime": { + ColorType.PRIMARY: "#92e649", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#b2ed72", + ColorType.LIGHT_ACCENT: "#e9f9b7", + ColorType.DARK_ACCENT: "#405516", + }, + "light green": { + ColorType.PRIMARY: "#85ec76", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#a3f198", + ColorType.LIGHT_ACCENT: "#e7fbe4", + ColorType.DARK_ACCENT: "#2b5524", + }, + "green": { + ColorType.PRIMARY: "#28bb48", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#43c568", + ColorType.LIGHT_ACCENT: "#93e2c8", + ColorType.DARK_ACCENT: "#0d3828", + }, + "teal": { + ColorType.PRIMARY: "#1ad9b2", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#4de3c7", + ColorType.LIGHT_ACCENT: "#a0f3e8", + ColorType.DARK_ACCENT: "#08424b", + }, + "cyan": { + ColorType.PRIMARY: "#49e4d5", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#76ebdf", + ColorType.LIGHT_ACCENT: "#bff5f0", + ColorType.DARK_ACCENT: "#0f4246", + }, + "light blue": { + ColorType.PRIMARY: "#55bbf6", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#70c6f7", + ColorType.LIGHT_ACCENT: "#bbe4fb", + ColorType.DARK_ACCENT: "#122541", + }, + "blue": { + ColorType.PRIMARY: "#3b87f0", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#4e95f2", + ColorType.LIGHT_ACCENT: "#aedbfa", + ColorType.DARK_ACCENT: "#122948", + }, + "blue violet": { + ColorType.PRIMARY: "#5948f2", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#6258f3", + ColorType.LIGHT_ACCENT: "#9cb8fb", + ColorType.DARK_ACCENT: "#1b1649", + }, + "violet": { + ColorType.PRIMARY: "#874ff5", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#9360f6", + ColorType.LIGHT_ACCENT: "#c9b0fa", + ColorType.DARK_ACCENT: "#3a1860", + }, + "purple": { + ColorType.PRIMARY: "#bb4ff0", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#c364f2", + ColorType.LIGHT_ACCENT: "#dda7f7", + ColorType.DARK_ACCENT: "#531862", + }, + "peach": { + ColorType.PRIMARY: "#f1c69c", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#f4d4b4", + ColorType.LIGHT_ACCENT: "#fbeee1", + ColorType.DARK_ACCENT: "#613f2f", + }, + "brown": { + ColorType.PRIMARY: "#823216", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#8a3e22", + ColorType.LIGHT_ACCENT: "#cd9d83", + ColorType.DARK_ACCENT: "#3a1804", + }, + "lavender": { + ColorType.PRIMARY: "#ad8eef", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#b99ef2", + ColorType.LIGHT_ACCENT: "#d5c7fa", + ColorType.DARK_ACCENT: "#492b65", + }, + "blonde": { + ColorType.PRIMARY: "#efc664", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#f3d387", + ColorType.LIGHT_ACCENT: "#faebc6", + ColorType.DARK_ACCENT: "#6d461e", + }, + "auburn": { + ColorType.PRIMARY: "#a13220", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#aa402f", + ColorType.LIGHT_ACCENT: "#d98a7f", + ColorType.DARK_ACCENT: "#3d100a", + }, + "light brown": { + ColorType.PRIMARY: "#be5b2d", + ColorType.TEXT: ColorType.DARK_ACCENT, + ColorType.BORDER: "#c4693d", + ColorType.LIGHT_ACCENT: "#e5b38c", + ColorType.DARK_ACCENT: "#4c290e", + }, + "dark brown": { + ColorType.PRIMARY: "#4c2315", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#542a1c", + ColorType.LIGHT_ACCENT: "#b78171", + ColorType.DARK_ACCENT: "#211006", + }, + "cool gray": { + ColorType.PRIMARY: "#515768", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#5b6174", + ColorType.LIGHT_ACCENT: "#9ea1c3", + ColorType.DARK_ACCENT: "#181a37", + }, + "warm gray": { + ColorType.PRIMARY: "#625550", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#6c5e57", + ColorType.LIGHT_ACCENT: "#c0a392", + ColorType.DARK_ACCENT: "#371d18", + }, + "olive": { + ColorType.PRIMARY: "#4c652e", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#586f36", + ColorType.LIGHT_ACCENT: "#b4c17a", + ColorType.DARK_ACCENT: "#23300e", + }, + "berry": { + ColorType.PRIMARY: "#9f2aa7", + ColorType.TEXT: ColorType.LIGHT_ACCENT, + ColorType.BORDER: "#aa43b4", + ColorType.LIGHT_ACCENT: "#cc8fdc", + ColorType.DARK_ACCENT: "#41114a", + }, } @@ -249,4 +286,4 @@ def get_tag_color(type: ColorType, color: str): else: return _TAG_COLORS[color][type] except KeyError: - return '#FF00FF' + return "#FF00FF" diff --git a/tagstudio/src/core/ts_core.py b/tagstudio/src/core/ts_core.py index f888a11..abbfdda 100644 --- a/tagstudio/src/core/ts_core.py +++ b/tagstudio/src/core/ts_core.py @@ -9,223 +9,336 @@ import os 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 +VERSION: str = "9.2.0" # Major.Minor.Patch +VERSION_BRANCH: str = "Alpha" # 'Alpha', 'Beta', or '' for Full Release # The folder & file names where TagStudio keeps its data relative to a library. -TS_FOLDER_NAME: str = '.TagStudio' -BACKUP_FOLDER_NAME: str = 'backups' -COLLAGE_FOLDER_NAME: str = 'collages' -LIBRARY_FILENAME: str = 'ts_library.json' +TS_FOLDER_NAME: str = ".TagStudio" +BACKUP_FOLDER_NAME: str = "backups" +COLLAGE_FOLDER_NAME: str = "collages" +LIBRARY_FILENAME: str = "ts_library.json" # TODO: Turn this whitelist into a user-configurable blacklist. -IMAGE_TYPES: list[str] = ['png', 'jpg', 'jpeg', 'jpg_large', 'jpeg_large', - 'jfif', 'gif', 'tif', 'tiff', 'heic', 'heif', 'webp', - 'bmp', 'svg', 'avif', 'apng', 'jp2', 'j2k', 'jpg2'] -VIDEO_TYPES: list[str] = ['mp4', 'webm', 'mov', 'hevc', 'mkv', 'avi', 'wmv', - 'flv', 'gifv', 'm4p', 'm4v', '3gp'] -AUDIO_TYPES: list[str] = ['mp3', 'mp4', 'mpeg4', 'm4a', 'aac', 'wav', 'flac', - 'alac', 'wma', 'ogg', 'aiff'] -DOC_TYPES: list[str] = ['txt', 'rtf', 'md', - 'doc', 'docx', 'pdf', 'tex', 'odt', 'pages'] -PLAINTEXT_TYPES: list[str] = ['txt', 'md', 'css', 'html', 'xml', 'json', 'js', - 'ts', 'ini', 'htm', 'csv', 'php', 'sh', 'bat'] -SPREADSHEET_TYPES: list[str] = ['csv', 'xls', 'xlsx', 'numbers', 'ods'] -PRESENTATION_TYPES: list[str] = ['ppt', 'pptx', 'key', 'odp'] -ARCHIVE_TYPES: list[str] = ['zip', 'rar', 'tar', 'tar.gz', 'tgz', '7z'] -PROGRAM_TYPES: list[str] = ['exe', 'app'] -SHORTCUT_TYPES: list[str] = ['lnk', 'desktop', 'url'] +IMAGE_TYPES: list[str] = [ + "png", + "jpg", + "jpeg", + "jpg_large", + "jpeg_large", + "jfif", + "gif", + "tif", + "tiff", + "heic", + "heif", + "webp", + "bmp", + "svg", + "avif", + "apng", + "jp2", + "j2k", + "jpg2", +] +VIDEO_TYPES: list[str] = [ + "mp4", + "webm", + "mov", + "hevc", + "mkv", + "avi", + "wmv", + "flv", + "gifv", + "m4p", + "m4v", + "3gp", +] +AUDIO_TYPES: list[str] = [ + "mp3", + "mp4", + "mpeg4", + "m4a", + "aac", + "wav", + "flac", + "alac", + "wma", + "ogg", + "aiff", +] +DOC_TYPES: list[str] = ["txt", "rtf", "md", "doc", "docx", "pdf", "tex", "odt", "pages"] +PLAINTEXT_TYPES: list[str] = [ + "txt", + "md", + "css", + "html", + "xml", + "json", + "js", + "ts", + "ini", + "htm", + "csv", + "php", + "sh", + "bat", +] +SPREADSHEET_TYPES: list[str] = ["csv", "xls", "xlsx", "numbers", "ods"] +PRESENTATION_TYPES: list[str] = ["ppt", "pptx", "key", "odp"] +ARCHIVE_TYPES: list[str] = ["zip", "rar", "tar", "tar.gz", "tgz", "7z"] +PROGRAM_TYPES: list[str] = ["exe", "app"] +SHORTCUT_TYPES: list[str] = ["lnk", "desktop", "url"] -ALL_FILE_TYPES: list[str] = IMAGE_TYPES + VIDEO_TYPES + AUDIO_TYPES + \ - DOC_TYPES + SPREADSHEET_TYPES + PRESENTATION_TYPES + \ - ARCHIVE_TYPES + PROGRAM_TYPES + SHORTCUT_TYPES +ALL_FILE_TYPES: list[str] = ( + IMAGE_TYPES + + VIDEO_TYPES + + AUDIO_TYPES + + DOC_TYPES + + SPREADSHEET_TYPES + + PRESENTATION_TYPES + + ARCHIVE_TYPES + + PROGRAM_TYPES + + SHORTCUT_TYPES +) -BOX_FIELDS = ['tag_box', 'text_box'] -TEXT_FIELDS = ['text_line', 'text_box'] -DATE_FIELDS = ['datetime'] +BOX_FIELDS = ["tag_box", "text_box"] +TEXT_FIELDS = ["text_line", "text_box"] +DATE_FIELDS = ["datetime"] -TAG_COLORS = ['', 'black', 'dark gray', 'gray', 'light gray', 'white', 'light pink', - 'pink', 'red', 'red orange', 'orange', 'yellow orange', 'yellow', - 'lime', 'light green', 'mint', 'green','teal', 'cyan', 'light blue', - 'blue', 'blue violet', 'violet', 'purple', 'lavender', 'berry', - 'magenta', 'salmon', 'auburn', 'dark brown', 'brown', 'light brown', - 'blonde', 'peach', 'warm gray', 'cool gray', 'olive'] +TAG_COLORS = [ + "", + "black", + "dark gray", + "gray", + "light gray", + "white", + "light pink", + "pink", + "red", + "red orange", + "orange", + "yellow orange", + "yellow", + "lime", + "light green", + "mint", + "green", + "teal", + "cyan", + "light blue", + "blue", + "blue violet", + "violet", + "purple", + "lavender", + "berry", + "magenta", + "salmon", + "auburn", + "dark brown", + "brown", + "light brown", + "blonde", + "peach", + "warm gray", + "cool gray", + "olive", +] class TagStudioCore: - """ - Instantiate this to establish a TagStudio session. - Holds all TagStudio session data and provides methods to manage it. - """ + """ + Instantiate this to establish a TagStudio session. + Holds all TagStudio session data and provides methods to manage it. + """ - def __init__(self): - self.lib: Library = Library() + def __init__(self): + self.lib: Library = Library() + def get_gdl_sidecar(self, filepath: str, source: str = "") -> dict: + """ + Attempts to open and dump a Gallery-DL Sidecar sidecar file for + the filepath.\n Returns a formatted object with notable values or an + empty object if none is found. + """ + json_dump = {} + info = {} - def get_gdl_sidecar(self, filepath: str, source: str = '') -> dict: - """ - Attempts to open and dump a Gallery-DL Sidecar sidecar file for - the filepath.\n Returns a formatted object with notable values or an - empty object if none is found. - """ - json_dump = {} - info = {} + # NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar + # files may be downloaded with indices starting at 1 rather than 0, unlike the posts. + # This may only occur with sidecar files that are downloaded separate from posts. + if source == "instagram": + if not os.path.isfile(os.path.normpath(filepath + ".json")): + filepath = filepath[:-16] + "1" + filepath[-15:] - # NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar - # files may be downloaded with indices starting at 1 rather than 0, unlike the posts. - # This may only occur with sidecar files that are downloaded separate from posts. - if source == 'instagram': - if not os.path.isfile(os.path.normpath(filepath + ".json")): - filepath = filepath[:-16] + '1' + filepath[-15:] + try: + with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f: + json_dump = json.load(f) - try: - with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f: - json_dump = json.load(f) + if json_dump: + if source == "twitter": + info["content"] = json_dump["content"].strip() + info["date_published"] = json_dump["date"] + elif source == "instagram": + info["description"] = json_dump["description"].strip() + info["date_published"] = json_dump["date"] + elif source == "artstation": + info["title"] = json_dump["title"].strip() + info["artist"] = json_dump["user"]["full_name"].strip() + info["description"] = json_dump["description"].strip() + info["tags"] = json_dump["tags"] + # info["tags"] = [x for x in json_dump["mediums"]["name"]] + info["date_published"] = json_dump["date"] + elif source == "newgrounds": + # info["title"] = json_dump["title"] + # info["artist"] = json_dump["artist"] + # info["description"] = json_dump["description"] + info["tags"] = json_dump["tags"] + info["date_published"] = json_dump["date"] + info["artist"] = json_dump["user"].strip() + info["description"] = json_dump["description"].strip() + info["source"] = json_dump["post_url"].strip() + # else: + # print( + # f'[INFO]: TagStudio does not currently support sidecar files for "{source}"') - if json_dump: - if source == "twitter": - info["content"] = json_dump["content"].strip() - info["date_published"] = json_dump["date"] - elif source == "instagram": - info["description"] = json_dump["description"].strip() - info["date_published"] = json_dump["date"] - elif source == "artstation": - info["title"] = json_dump["title"].strip() - info["artist"] = json_dump["user"]["full_name"].strip() - info["description"] = json_dump["description"].strip() - info["tags"] = json_dump["tags"] - # info["tags"] = [x for x in json_dump["mediums"]["name"]] - info["date_published"] = json_dump["date"] - elif source == "newgrounds": - # info["title"] = json_dump["title"] - # info["artist"] = json_dump["artist"] - # info["description"] = json_dump["description"] - info["tags"] = json_dump["tags"] - info["date_published"] = json_dump["date"] - info["artist"] = json_dump["user"].strip() - info["description"] = json_dump["description"].strip() - info["source"] = json_dump["post_url"].strip() - # else: - # print( - # f'[INFO]: TagStudio does not currently support sidecar files for "{source}"') + # except FileNotFoundError: + except: + # print( + # f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"') + pass - # except FileNotFoundError: - except: - # print( - # f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"') - pass + return info - return info + # def scrape(self, entry_id): + # entry = self.lib.get_entry(entry_id) + # if entry.fields: + # urls: list[str] = [] + # if self.lib.get_field_index_in_entry(entry, 21): + # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') + # for x in self.lib.get_field_index_in_entry(entry, 21)]) + # if self.lib.get_field_index_in_entry(entry, 3): + # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') + # for x in self.lib.get_field_index_in_entry(entry, 3)]) + # # try: + # if urls: + # for url in urls: + # url = "https://" + url if 'https://' not in url else url + # html_doc = requests.get(url).text + # soup = bs(html_doc, "html.parser") + # print(soup) + # input() - # def scrape(self, entry_id): - # entry = self.lib.get_entry(entry_id) - # if entry.fields: - # urls: list[str] = [] - # if self.lib.get_field_index_in_entry(entry, 21): - # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') - # for x in self.lib.get_field_index_in_entry(entry, 21)]) - # if self.lib.get_field_index_in_entry(entry, 3): - # urls.extend([self.lib.get_field_attr(entry.fields[x], 'content') - # for x in self.lib.get_field_index_in_entry(entry, 3)]) - # # try: - # if urls: - # for url in urls: - # url = "https://" + url if 'https://' not in url else url - # html_doc = requests.get(url).text - # soup = bs(html_doc, "html.parser") - # print(soup) - # input() + # # except: + # # # print("Could not resolve URL.") + # # pass - # # except: - # # # print("Could not resolve URL.") - # # pass + def match_conditions(self, entry_id: int) -> None: + """Matches defined conditions against a file to add Entry data.""" - 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. + entry: Entry = self.lib.get_entry(entry_id) + try: + if os.path.isfile(cond_file): + with open(cond_file, "r", encoding="utf8") as f: + json_dump = json.load(f) + for c in json_dump["conditions"]: + match: bool = False + for path_c in c["path_conditions"]: + if os.path.normpath(path_c) in entry.path: + match = True + break + if match: + if fields := c.get("fields"): + for field in fields: + field_id = self.lib.get_field_attr(field, "id") + content = field[field_id] - 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. - entry: Entry = self.lib.get_entry(entry_id) - try: - if os.path.isfile(cond_file): - with open(cond_file, "r", encoding="utf8") as f: - json_dump = json.load(f) - for c in json_dump['conditions']: - match: bool = False - for path_c in c['path_conditions']: - if os.path.normpath(path_c) in entry.path: - match = True - break - if match: - if fields := c.get('fields'): - for field in fields: + if ( + self.lib.get_field_obj(int(field_id))["type"] + == "tag_box" + ): + existing_fields: list[int] = ( + self.lib.get_field_index_in_entry( + entry, field_id + ) + ) + if existing_fields: + self.lib.update_entry_field( + entry_id, + existing_fields[0], + content, + "append", + ) + else: + self.lib.add_field_to_entry( + entry_id, field_id + ) + self.lib.update_entry_field( + entry_id, -1, content, "append" + ) - field_id = self.lib.get_field_attr( - field, 'id') - content = field[field_id] + if ( + self.lib.get_field_obj(int(field_id))["type"] + in TEXT_FIELDS + ): + if not self.lib.does_field_content_exist( + entry_id, field_id, content + ): + self.lib.add_field_to_entry( + entry_id, field_id + ) + self.lib.update_entry_field( + entry_id, -1, content, "replace" + ) + except: + print("Error in match_conditions...") + # input() + pass - if self.lib.get_field_obj(int(field_id))['type'] == 'tag_box': - existing_fields: list[int] = self.lib.get_field_index_in_entry( - entry, field_id) - if existing_fields: - self.lib.update_entry_field( - entry_id, existing_fields[0], content, 'append') - else: - self.lib.add_field_to_entry( - entry_id, field_id) - self.lib.update_entry_field( - entry_id, -1, content, 'append') + def build_url(self, entry_id: int, source: str) -> str: + """Tries to rebuild a source URL given a specific filename structure.""" - if self.lib.get_field_obj(int(field_id))['type'] in TEXT_FIELDS: - if not self.lib.does_field_content_exist(entry_id, field_id, content): - self.lib.add_field_to_entry( - entry_id, field_id) - self.lib.update_entry_field( - entry_id, -1, content, 'replace') - except: - print('Error in match_conditions...') - # input() - pass + source = source.lower().replace("-", " ").replace("_", " ") + if "twitter" in source: + return self._build_twitter_url(entry_id) + elif "instagram" in source: + return self._build_instagram_url(entry_id) - def build_url(self, entry_id: int, source: str) -> str: - """Tries to rebuild a source URL given a specific filename structure.""" + def _build_twitter_url(self, entry_id: int): + """ + Builds an Twitter URL given a specific filename structure. + Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD' + """ + try: + entry = self.lib.get_entry(entry_id) + stubs = entry.filename.rsplit("_", 3) + # print(stubs) + # source, author = os.path.split(entry.path) + url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}" + return url + except: + return "" - source = source.lower().replace('-', ' ').replace('_', ' ') - if 'twitter' in source: - return self._build_twitter_url(entry_id) - elif 'instagram' in source: - return self._build_instagram_url(entry_id) - - def _build_twitter_url(self, entry_id: int): - """ - Builds an Twitter URL given a specific filename structure. - Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD' - """ - try: - entry = self.lib.get_entry(entry_id) - stubs = entry.filename.rsplit('_', 3) - # print(stubs) - # source, author = os.path.split(entry.path) - url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}" - return url - except: - return '' - - def _build_instagram_url(self, entry_id: int): - """ - Builds an Instagram URL given a specific filename structure. - Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD' - """ - try: - entry = self.lib.get_entry(entry_id) - stubs = entry.filename.rsplit('_', 2) - # stubs[0] = stubs[0].replace(f"{author}_", '', 1) - # print(stubs) - # NOTE: Both Instagram usernames AND their ID can have underscores in them, - # so unless you have the exact username (which can change) on hand to remove, - # your other best bet is to hope that the ID is only 11 characters long, which - # seems to more or less be the case... for now... - url = f"www.instagram.com/p/{stubs[-3][-11:]}" - return url - except: - return '' + def _build_instagram_url(self, entry_id: int): + """ + Builds an Instagram URL given a specific filename structure. + Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD' + """ + try: + entry = self.lib.get_entry(entry_id) + stubs = entry.filename.rsplit("_", 2) + # stubs[0] = stubs[0].replace(f"{author}_", '', 1) + # print(stubs) + # NOTE: Both Instagram usernames AND their ID can have underscores in them, + # so unless you have the exact username (which can change) on hand to remove, + # your other best bet is to hope that the ID is only 11 characters long, which + # seems to more or less be the case... for now... + url = f"www.instagram.com/p/{stubs[-3][-11:]}" + return url + except: + return "" diff --git a/tagstudio/src/core/utils/fs.py b/tagstudio/src/core/utils/fs.py index 7c1052a..44ba1e8 100644 --- a/tagstudio/src/core/utils/fs.py +++ b/tagstudio/src/core/utils/fs.py @@ -2,9 +2,10 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio + def clean_folder_name(folder_name: str) -> str: cleaned_name = folder_name - invalid_chars = "<>:\"/\\|?*." + invalid_chars = '<>:"/\\|?*.' for char in invalid_chars: - cleaned_name = cleaned_name.replace(char, '_') + cleaned_name = cleaned_name.replace(char, "_") return cleaned_name diff --git a/tagstudio/src/core/utils/str.py b/tagstudio/src/core/utils/str.py index 338ee9f..11c0105 100644 --- a/tagstudio/src/core/utils/str.py +++ b/tagstudio/src/core/utils/str.py @@ -2,10 +2,25 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio + def strip_punctuation(string: str) -> str: """Returns a given string stripped of all punctuation characters.""" - return string.replace('(', '').replace(')', '').replace('[', '') \ - .replace(']', '').replace('{', '').replace('}', '').replace("'", '') \ - .replace('`', '').replace('’', '').replace('‘', '').replace('"', '') \ - .replace('“', '').replace('”', '').replace('_', '').replace('-', '') \ - .replace(' ', '').replace(' ', '') + return ( + string.replace("(", "") + .replace(")", "") + .replace("[", "") + .replace("]", "") + .replace("{", "") + .replace("}", "") + .replace("'", "") + .replace("`", "") + .replace("’", "") + .replace("‘", "") + .replace('"', "") + .replace("“", "") + .replace("”", "") + .replace("_", "") + .replace("-", "") + .replace(" ", "") + .replace(" ", "") + ) diff --git a/tagstudio/src/core/utils/web.py b/tagstudio/src/core/utils/web.py index f176769..ec4f483 100644 --- a/tagstudio/src/core/utils/web.py +++ b/tagstudio/src/core/utils/web.py @@ -2,9 +2,10 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio + def strip_web_protocol(string: str) -> str: """Strips a leading web protocol (ex. \"https://\") as well as \"www.\" from a string.""" - prefixes = ['https://','http://','www.','www2.'] + prefixes = ["https://", "http://", "www.", "www2."] for prefix in prefixes: string = string.removeprefix(prefix) return string diff --git a/tagstudio/src/qt/flowlayout.py b/tagstudio/src/qt/flowlayout.py index b71c962..7e36622 100644 --- a/tagstudio/src/qt/flowlayout.py +++ b/tagstudio/src/qt/flowlayout.py @@ -21,143 +21,153 @@ from PySide6.QtWidgets import QLayout, QSizePolicy, QWidget # self.setWindowTitle("Flow Layout") + class FlowWidget(QWidget): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.ignore_size: bool = False + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.ignore_size: bool = False + class FlowLayout(QLayout): - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None): + super().__init__(parent) - if parent is not None: - self.setContentsMargins(QMargins(0, 0, 0, 0)) + if parent is not None: + self.setContentsMargins(QMargins(0, 0, 0, 0)) - self._item_list = [] - self.grid_efficiency = False + self._item_list = [] + self.grid_efficiency = False - def __del__(self): - item = self.takeAt(0) - while item: - item = self.takeAt(0) + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) - def addItem(self, item): - self._item_list.append(item) + def addItem(self, item): + self._item_list.append(item) - def count(self): - return len(self._item_list) + def count(self): + return len(self._item_list) - def itemAt(self, index): - if 0 <= index < len(self._item_list): - return self._item_list[index] + def itemAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list[index] - return None + return None - def takeAt(self, index): - if 0 <= index < len(self._item_list): - return self._item_list.pop(index) + def takeAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list.pop(index) - return None + return None - def expandingDirections(self): - return Qt.Orientation(0) + def expandingDirections(self): + return Qt.Orientation(0) - def hasHeightForWidth(self): - return True + def hasHeightForWidth(self): + return True - def heightForWidth(self, width): - height = self._do_layout(QRect(0, 0, width, 0), True) - return height + def heightForWidth(self, width): + height = self._do_layout(QRect(0, 0, width, 0), True) + return height - def setGeometry(self, rect): - super(FlowLayout, self).setGeometry(rect) - self._do_layout(rect, False) - - def setGridEfficiency(self, bool): - """ - Enables or Disables efficiencies when all objects are equally sized. - """ - self.grid_efficiency = bool + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self._do_layout(rect, False) - def sizeHint(self): - return self.minimumSize() + def setGridEfficiency(self, bool): + """ + Enables or Disables efficiencies when all objects are equally sized. + """ + self.grid_efficiency = bool - def minimumSize(self): - if self.grid_efficiency: - if self._item_list: - return self._item_list[0].minimumSize() - else: - return QSize() - else: - size = QSize() + def sizeHint(self): + return self.minimumSize() - for item in self._item_list: - size = size.expandedTo(item.minimumSize()) + def minimumSize(self): + if self.grid_efficiency: + if self._item_list: + return self._item_list[0].minimumSize() + else: + return QSize() + else: + size = QSize() - size += QSize(2 * self.contentsMargins().top(), 2 * self.contentsMargins().top()) - return size - - + for item in self._item_list: + size = size.expandedTo(item.minimumSize()) - def _do_layout(self, rect, test_only): - x = rect.x() - y = rect.y() - line_height = 0 - spacing = self.spacing() - item = None - style = None - layout_spacing_x = None - layout_spacing_y = None + size += QSize( + 2 * self.contentsMargins().top(), 2 * self.contentsMargins().top() + ) + return size - if self.grid_efficiency: - if self._item_list: - item = self._item_list[0] - style = item.widget().style() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical - ) - for i, item in enumerate(self._item_list): - # print(issubclass(type(item.widget()), FlowWidget)) - # print(item.widget().ignore_size) - skip_count = 0 - if (issubclass(type(item.widget()), FlowWidget) and item.widget().ignore_size): - skip_count += 1 + def _do_layout(self, rect, test_only): + x = rect.x() + y = rect.y() + line_height = 0 + spacing = self.spacing() + item = None + style = None + layout_spacing_x = None + layout_spacing_y = None - if (issubclass(type(item.widget()), FlowWidget) and not item.widget().ignore_size) or (not issubclass(type(item.widget()), FlowWidget)): - # print(f'Item {i}') - if not self.grid_efficiency: - style = item.widget().style() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical - ) - space_x = spacing + layout_spacing_x - space_y = spacing + layout_spacing_y - next_x = x + item.sizeHint().width() + space_x - if next_x - space_x > rect.right() and line_height > 0: - x = rect.x() - y = y + line_height + space_y - next_x = x + item.sizeHint().width() + space_x - line_height = 0 + if self.grid_efficiency: + if self._item_list: + item = self._item_list[0] + style = item.widget().style() + layout_spacing_x = style.layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + layout_spacing_y = style.layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + ) + for i, item in enumerate(self._item_list): + # print(issubclass(type(item.widget()), FlowWidget)) + # print(item.widget().ignore_size) + skip_count = 0 + if ( + issubclass(type(item.widget()), FlowWidget) + and item.widget().ignore_size + ): + skip_count += 1 - if not test_only: - item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + if ( + issubclass(type(item.widget()), FlowWidget) + and not item.widget().ignore_size + ) or (not issubclass(type(item.widget()), FlowWidget)): + # print(f'Item {i}') + if not self.grid_efficiency: + style = item.widget().style() + layout_spacing_x = style.layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + layout_spacing_y = style.layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + ) + space_x = spacing + layout_spacing_x + space_y = spacing + layout_spacing_y + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > rect.right() and line_height > 0: + x = rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 - x = next_x - line_height = max(line_height, item.sizeHint().height()) + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) - # print(y + line_height - rect.y() * ((len(self._item_list) - skip_count) / len(self._item_list))) - # print(y + line_height - rect.y()) * ((len(self._item_list) - skip_count) / len(self._item_list)) - return y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list)) + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + # print(y + line_height - rect.y() * ((len(self._item_list) - skip_count) / len(self._item_list))) + # print(y + line_height - rect.y()) * ((len(self._item_list) - skip_count) / len(self._item_list)) + return ( + y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list)) + ) # if __name__ == "__main__": # app = QApplication(sys.argv) # main_win = Window() # main_win.show() -# sys.exit(app.exec()) \ No newline at end of file +# sys.exit(app.exec()) diff --git a/tagstudio/src/qt/helpers/custom_runnable.py b/tagstudio/src/qt/helpers/custom_runnable.py index dff96ae..0e8adb4 100644 --- a/tagstudio/src/qt/helpers/custom_runnable.py +++ b/tagstudio/src/qt/helpers/custom_runnable.py @@ -16,4 +16,4 @@ class CustomRunnable(QRunnable, QObject): def run(self): self.function() - self.done.emit() \ No newline at end of file + self.done.emit() diff --git a/tagstudio/src/qt/helpers/file_opener.py b/tagstudio/src/qt/helpers/file_opener.py index 313db09..e60323a 100644 --- a/tagstudio/src/qt/helpers/file_opener.py +++ b/tagstudio/src/qt/helpers/file_opener.py @@ -12,121 +12,139 @@ import traceback from PySide6.QtWidgets import QLabel from PySide6.QtCore import Qt -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) def open_file(path: str, file_manager: bool = False): - """Open a file in the default application or file explorer. + """Open a file in the default application or file explorer. - Args: - path (str): The path to the file to open. - file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS). - Defaults to False. - """ - logging.info(f'Opening file: {path}') - if not os.path.exists(path): - logging.error(f'File not found: {path}') - return - try: - if sys.platform == "win32": - normpath = os.path.normpath(path) - if file_manager: - command_name = "explorer" - command_args = '/select,"' + normpath + '"' - # For some reason, if the args are passed in a list, this will error when the path has spaces, even while surrounded in double quotes - subprocess.Popen(command_name + command_args, shell=True, close_fds=True, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_BREAKAWAY_FROM_JOB) - else: - command_name = "start" - # first parameter is for title, NOT filepath - command_args = ["", normpath] - subprocess.Popen([command_name] + command_args, shell=True, close_fds=True, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_BREAKAWAY_FROM_JOB) - else: - if sys.platform == "darwin": - command_name = "open" - command_args = [path] - if file_manager: - # will reveal in Finder - command_args.append("-R") - else: - if file_manager: - command_name = "dbus-send" - # might not be guaranteed to launch default? - command_args = ["--session", "--dest=org.freedesktop.FileManager1", "--type=method_call", - "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", - f"array:string:file://{path}", "string:"] - else: - command_name = "xdg-open" - command_args = [path] - command = shutil.which(command_name) - if command is not None: - subprocess.Popen([command] + command_args, close_fds=True) - else: - logging.info(f"Could not find {command_name} on system PATH") - except: - traceback.print_exc() + Args: + path (str): The path to the file to open. + file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS). + Defaults to False. + """ + logging.info(f"Opening file: {path}") + if not os.path.exists(path): + logging.error(f"File not found: {path}") + return + try: + if sys.platform == "win32": + normpath = os.path.normpath(path) + if file_manager: + command_name = "explorer" + command_args = '/select,"' + normpath + '"' + # For some reason, if the args are passed in a list, this will error when the path has spaces, even while surrounded in double quotes + subprocess.Popen( + command_name + command_args, + shell=True, + close_fds=True, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_BREAKAWAY_FROM_JOB, + ) + else: + command_name = "start" + # first parameter is for title, NOT filepath + command_args = ["", normpath] + subprocess.Popen( + [command_name] + command_args, + shell=True, + close_fds=True, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_BREAKAWAY_FROM_JOB, + ) + else: + if sys.platform == "darwin": + command_name = "open" + command_args = [path] + if file_manager: + # will reveal in Finder + command_args.append("-R") + else: + if file_manager: + command_name = "dbus-send" + # might not be guaranteed to launch default? + command_args = [ + "--session", + "--dest=org.freedesktop.FileManager1", + "--type=method_call", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + f"array:string:file://{path}", + "string:", + ] + else: + command_name = "xdg-open" + command_args = [path] + command = shutil.which(command_name) + if command is not None: + subprocess.Popen([command] + command_args, close_fds=True) + else: + logging.info(f"Could not find {command_name} on system PATH") + except: + traceback.print_exc() class FileOpenerHelper: - def __init__(self, filepath: str): - """Initialize the FileOpenerHelper. + def __init__(self, filepath: str): + """Initialize the FileOpenerHelper. - Args: - filepath (str): The path to the file to open. - """ - self.filepath = filepath + Args: + filepath (str): The path to the file to open. + """ + self.filepath = filepath - def set_filepath(self, filepath: str): - """Set the filepath to open. + def set_filepath(self, filepath: str): + """Set the filepath to open. - Args: - filepath (str): The path to the file to open. - """ - self.filepath = filepath + Args: + filepath (str): The path to the file to open. + """ + self.filepath = filepath - def open_file(self): - """Open the file in the default application.""" - open_file(self.filepath) + def open_file(self): + """Open the file in the default application.""" + open_file(self.filepath) - def open_explorer(self): - """Open the file in the default file explorer.""" - open_file(self.filepath, file_manager=True) + def open_explorer(self): + """Open the file in the default file explorer.""" + open_file(self.filepath, file_manager=True) class FileOpenerLabel(QLabel): - def __init__(self, text, parent=None): - """Initialize the FileOpenerLabel. + def __init__(self, text, parent=None): + """Initialize the FileOpenerLabel. - Args: - text (str): The text to display. - parent (QWidget, optional): The parent widget. Defaults to None. - """ - super().__init__(text, parent) + Args: + text (str): The text to display. + parent (QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(text, parent) - def setFilePath(self, filepath): - """Set the filepath to open. + def setFilePath(self, filepath): + """Set the filepath to open. - Args: - filepath (str): The path to the file to open. - """ - self.filepath = filepath + Args: + filepath (str): The path to the file to open. + """ + self.filepath = filepath - def mousePressEvent(self, event): - """Handle mouse press events. + def mousePressEvent(self, event): + """Handle mouse press events. - On a left click, open the file in the default file explorer. On a right click, show a context menu. - - Args: - event (QMouseEvent): The mouse press event. - """ - super().mousePressEvent(event) + On a left click, open the file in the default file explorer. On a right click, show a context menu. - if event.button() == Qt.LeftButton: - opener = FileOpenerHelper(self.filepath) - opener.open_explorer() - elif event.button() == Qt.RightButton: - # Show context menu - pass + Args: + event (QMouseEvent): The mouse press event. + """ + super().mousePressEvent(event) + + if event.button() == Qt.LeftButton: + opener = FileOpenerHelper(self.filepath) + opener.open_explorer() + elif event.button() == Qt.RightButton: + # Show context menu + pass diff --git a/tagstudio/src/qt/helpers/function_iterator.py b/tagstudio/src/qt/helpers/function_iterator.py index 4e2e6d0..ff214f2 100644 --- a/tagstudio/src/qt/helpers/function_iterator.py +++ b/tagstudio/src/qt/helpers/function_iterator.py @@ -10,6 +10,7 @@ from PySide6.QtCore import Signal, QObject class FunctionIterator(QObject): """Iterates over a yielding function and emits progress as the 'value' signal.\n\nThread-Safe Guarantee™""" + value = Signal(object) def __init__(self, function: FunctionType): diff --git a/tagstudio/src/qt/modals/__init__.py b/tagstudio/src/qt/modals/__init__.py index e089ce9..a4fe31b 100644 --- a/tagstudio/src/qt/modals/__init__.py +++ b/tagstudio/src/qt/modals/__init__.py @@ -8,4 +8,4 @@ from .relink_unlinked import RelinkUnlinkedEntries from .fix_unlinked import FixUnlinkedEntriesModal from .mirror_entities import MirrorEntriesModal from .fix_dupes import FixDupeFilesModal -from .folders_to_tags import FoldersToTagsModal \ No newline at end of file +from .folders_to_tags import FoldersToTagsModal diff --git a/tagstudio/src/qt/modals/add_field.py b/tagstudio/src/qt/modals/add_field.py index f2745b1..f315b86 100644 --- a/tagstudio/src/qt/modals/add_field.py +++ b/tagstudio/src/qt/modals/add_field.py @@ -4,75 +4,87 @@ from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QComboBox, +) from src.core.library import Library class AddFieldModal(QWidget): - done = Signal(int) - def __init__(self, library:'Library'): - # [Done] - # - OR - - # [Cancel] [Save] - super().__init__() - self.lib = library - self.setWindowTitle(f'Add Field') - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(400, 300) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,6,6,6) + done = Signal(int) - self.title_widget = QLabel() - self.title_widget.setObjectName('fieldTitle') - self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet( - # 'background:blue;' - # 'text-align:center;' - 'font-weight:bold;' - 'font-size:14px;' - 'padding-top: 6px' - '') - self.title_widget.setText('Add Field') - self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.combo_box = QComboBox() - self.combo_box.setEditable(False) - # self.combo_box.setMaxVisibleItems(5) - self.combo_box.setStyleSheet('combobox-popup:0;') - self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - for df in self.lib.default_fields: - self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})') + def __init__(self, library: "Library"): + # [Done] + # - OR - + # [Cancel] [Save] + super().__init__() + self.lib = library + self.setWindowTitle(f"Add Field") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(400, 300) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 6, 6, 6) - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6,6,6,6) - self.button_layout.addStretch(1) + self.title_widget = QLabel() + self.title_widget.setObjectName("fieldTitle") + self.title_widget.setWordWrap(True) + self.title_widget.setStyleSheet( + # 'background:blue;' + # 'text-align:center;' + "font-weight:bold;" "font-size:14px;" "padding-top: 6px" "" + ) + self.title_widget.setText("Add Field") + self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - # self.cancel_button = QPushButton() - # self.cancel_button.setText('Cancel') + self.combo_box = QComboBox() + self.combo_box.setEditable(False) + # self.combo_box.setMaxVisibleItems(5) + self.combo_box.setStyleSheet("combobox-popup:0;") + self.combo_box.view().setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + for df in self.lib.default_fields: + self.combo_box.addItem( + f'{df["name"]} ({df["type"].replace("_", " ").title()})' + ) - self.cancel_button = QPushButton() - self.cancel_button.setText('Cancel') - self.cancel_button.clicked.connect(self.hide) - # self.cancel_button.clicked.connect(widget.reset) - self.button_layout.addWidget(self.cancel_button) - - self.save_button = QPushButton() - self.save_button.setText('Add') - # self.save_button.setAutoDefault(True) - self.save_button.setDefault(True) - self.save_button.clicked.connect(self.hide) - self.save_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) - self.button_layout.addWidget(self.save_button) + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) - # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - - # self.done.connect(lambda x: callback(x)) - - self.root_layout.addWidget(self.title_widget) - self.root_layout.addWidget(self.combo_box) - # self.root_layout.setStretch(1,2) - self.root_layout.addStretch(1) - self.root_layout.addWidget(self.button_container) + # self.cancel_button = QPushButton() + # self.cancel_button.setText('Cancel') + + self.cancel_button = QPushButton() + self.cancel_button.setText("Cancel") + self.cancel_button.clicked.connect(self.hide) + # self.cancel_button.clicked.connect(widget.reset) + self.button_layout.addWidget(self.cancel_button) + + self.save_button = QPushButton() + self.save_button.setText("Add") + # self.save_button.setAutoDefault(True) + self.save_button.setDefault(True) + self.save_button.clicked.connect(self.hide) + self.save_button.clicked.connect( + lambda: self.done.emit(self.combo_box.currentIndex()) + ) + # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) + self.button_layout.addWidget(self.save_button) + + # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) + + # self.done.connect(lambda x: callback(x)) + + self.root_layout.addWidget(self.title_widget) + self.root_layout.addWidget(self.combo_box) + # self.root_layout.setStretch(1,2) + self.root_layout.addStretch(1) + self.root_layout.addWidget(self.button_container) diff --git a/tagstudio/src/qt/modals/build_tag.py b/tagstudio/src/qt/modals/build_tag.py index b5c43c3..579ffe1 100644 --- a/tagstudio/src/qt/modals/build_tag.py +++ b/tagstudio/src/qt/modals/build_tag.py @@ -6,7 +6,17 @@ import logging from PySide6.QtCore import Signal, Qt -from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QScrollArea, QFrame, QTextEdit, QComboBox) +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QLabel, + QPushButton, + QLineEdit, + QScrollArea, + QFrame, + QTextEdit, + QComboBox, +) from src.core.library import Library, Tag from src.core.palette import ColorType, get_tag_color @@ -15,216 +25,220 @@ from src.qt.widgets import PanelWidget, PanelModal, TagWidget from src.qt.modals import TagSearchPanel -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class BuildTagPanel(PanelWidget): - on_edit = Signal(Tag) - def __init__(self, library, tag_id:int=-1): - super().__init__() - self.lib: Library = library - # self.callback = callback - # self.tag_id = tag_id - self.tag = None - self.setMinimumSize(300, 400) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,0,6,0) - self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + on_edit = Signal(Tag) - # Name ----------------------------------------------------------------- - self.name_widget = QWidget() - self.name_layout = QVBoxLayout(self.name_widget) - self.name_layout.setStretch(1, 1) - self.name_layout.setContentsMargins(0,0,0,0) - self.name_layout.setSpacing(0) - self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.name_title = QLabel() - self.name_title.setText('Name') - self.name_layout.addWidget(self.name_title) - self.name_field = QLineEdit() - self.name_layout.addWidget(self.name_field) + def __init__(self, library, tag_id: int = -1): + super().__init__() + self.lib: Library = library + # self.callback = callback + # self.tag_id = tag_id + self.tag = None + self.setMinimumSize(300, 400) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # Shorthand ------------------------------------------------------------ - self.shorthand_widget = QWidget() - self.shorthand_layout = QVBoxLayout(self.shorthand_widget) - self.shorthand_layout.setStretch(1, 1) - self.shorthand_layout.setContentsMargins(0,0,0,0) - self.shorthand_layout.setSpacing(0) - self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.shorthand_title = QLabel() - self.shorthand_title.setText('Shorthand') - self.shorthand_layout.addWidget(self.shorthand_title) - self.shorthand_field = QLineEdit() - self.shorthand_layout.addWidget(self.shorthand_field) + # Name ----------------------------------------------------------------- + self.name_widget = QWidget() + self.name_layout = QVBoxLayout(self.name_widget) + self.name_layout.setStretch(1, 1) + self.name_layout.setContentsMargins(0, 0, 0, 0) + self.name_layout.setSpacing(0) + self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.name_title = QLabel() + self.name_title.setText("Name") + self.name_layout.addWidget(self.name_title) + self.name_field = QLineEdit() + self.name_layout.addWidget(self.name_field) - # Aliases -------------------------------------------------------------- - self.aliases_widget = QWidget() - self.aliases_layout = QVBoxLayout(self.aliases_widget) - self.aliases_layout.setStretch(1, 1) - self.aliases_layout.setContentsMargins(0,0,0,0) - self.aliases_layout.setSpacing(0) - self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.aliases_title = QLabel() - self.aliases_title.setText('Aliases') - self.aliases_layout.addWidget(self.aliases_title) - self.aliases_field = QTextEdit() - self.aliases_field.setAcceptRichText(False) - self.aliases_field.setMinimumHeight(40) - self.aliases_layout.addWidget(self.aliases_field) + # Shorthand ------------------------------------------------------------ + self.shorthand_widget = QWidget() + self.shorthand_layout = QVBoxLayout(self.shorthand_widget) + self.shorthand_layout.setStretch(1, 1) + self.shorthand_layout.setContentsMargins(0, 0, 0, 0) + self.shorthand_layout.setSpacing(0) + self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.shorthand_title = QLabel() + self.shorthand_title.setText("Shorthand") + self.shorthand_layout.addWidget(self.shorthand_title) + self.shorthand_field = QLineEdit() + self.shorthand_layout.addWidget(self.shorthand_field) - # Subtags ------------------------------------------------------------ - self.subtags_widget = QWidget() - self.subtags_layout = QVBoxLayout(self.subtags_widget) - self.subtags_layout.setStretch(1, 1) - self.subtags_layout.setContentsMargins(0,0,0,0) - self.subtags_layout.setSpacing(0) - self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.subtags_title = QLabel() - self.subtags_title.setText('Subtags') - self.subtags_layout.addWidget(self.subtags_title) + # Aliases -------------------------------------------------------------- + self.aliases_widget = QWidget() + self.aliases_layout = QVBoxLayout(self.aliases_widget) + self.aliases_layout.setStretch(1, 1) + self.aliases_layout.setContentsMargins(0, 0, 0, 0) + self.aliases_layout.setSpacing(0) + self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.aliases_title = QLabel() + self.aliases_title.setText("Aliases") + self.aliases_layout.addWidget(self.aliases_title) + self.aliases_field = QTextEdit() + self.aliases_field.setAcceptRichText(False) + self.aliases_field.setMinimumHeight(40) + self.aliases_layout.addWidget(self.aliases_field) - 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) + # Subtags ------------------------------------------------------------ + self.subtags_widget = QWidget() + self.subtags_layout = QVBoxLayout(self.subtags_widget) + self.subtags_layout.setStretch(1, 1) + self.subtags_layout.setContentsMargins(0, 0, 0, 0) + self.subtags_layout.setSpacing(0) + self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.subtags_title = QLabel() + self.subtags_title.setText("Subtags") + self.subtags_layout.addWidget(self.subtags_title) - 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.scroll_area.setMinimumHeight(60) + 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.subtags_layout.addWidget(self.scroll_area) + 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.scroll_area.setMinimumHeight(60) - self.subtags_add_button = QPushButton() - self.subtags_add_button.setText('+') - tsp = TagSearchPanel(self.lib) - tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x)) - self.add_tag_modal = PanelModal(tsp, 'Add Subtags', 'Add Subtags') - self.subtags_add_button.clicked.connect(self.add_tag_modal.show) - self.subtags_layout.addWidget(self.subtags_add_button) + self.subtags_layout.addWidget(self.scroll_area) - # self.subtags_field = TagBoxWidget() - # self.subtags_field.setMinimumHeight(60) - # self.subtags_layout.addWidget(self.subtags_field) + self.subtags_add_button = QPushButton() + self.subtags_add_button.setText("+") + tsp = TagSearchPanel(self.lib) + tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x)) + self.add_tag_modal = PanelModal(tsp, "Add Subtags", "Add Subtags") + self.subtags_add_button.clicked.connect(self.add_tag_modal.show) + self.subtags_layout.addWidget(self.subtags_add_button) - # Shorthand ------------------------------------------------------------ - self.color_widget = QWidget() - self.color_layout = QVBoxLayout(self.color_widget) - self.color_layout.setStretch(1, 1) - self.color_layout.setContentsMargins(0,0,0,0) - self.color_layout.setSpacing(0) - self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.color_title = QLabel() - self.color_title.setText('Color') - self.color_layout.addWidget(self.color_title) - self.color_field = QComboBox() - self.color_field.setEditable(False) - self.color_field.setMaxVisibleItems(10) - self.color_field.setStyleSheet('combobox-popup:0;') - for color in TAG_COLORS: - self.color_field.addItem(color.title()) - # self.color_field.setProperty("appearance", "flat") - self.color_field.currentTextChanged.connect(lambda c: self.color_field.setStyleSheet(f'''combobox-popup:0; + # self.subtags_field = TagBoxWidget() + # self.subtags_field.setMinimumHeight(60) + # self.subtags_layout.addWidget(self.subtags_field) + + # Shorthand ------------------------------------------------------------ + self.color_widget = QWidget() + self.color_layout = QVBoxLayout(self.color_widget) + self.color_layout.setStretch(1, 1) + self.color_layout.setContentsMargins(0, 0, 0, 0) + self.color_layout.setSpacing(0) + self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.color_title = QLabel() + self.color_title.setText("Color") + self.color_layout.addWidget(self.color_title) + self.color_field = QComboBox() + self.color_field.setEditable(False) + self.color_field.setMaxVisibleItems(10) + self.color_field.setStyleSheet("combobox-popup:0;") + for color in TAG_COLORS: + self.color_field.addItem(color.title()) + # self.color_field.setProperty("appearance", "flat") + self.color_field.currentTextChanged.connect( + lambda c: self.color_field.setStyleSheet(f"""combobox-popup:0; font-weight:600; color:{get_tag_color(ColorType.TEXT, c.lower())}; background-color:{get_tag_color(ColorType.PRIMARY, c.lower())}; - ''')) - self.color_layout.addWidget(self.color_field) + """) + ) + self.color_layout.addWidget(self.color_field) + # Add Widgets to Layout ================================================ + self.root_layout.addWidget(self.name_widget) + self.root_layout.addWidget(self.shorthand_widget) + self.root_layout.addWidget(self.aliases_widget) + self.root_layout.addWidget(self.subtags_widget) + self.root_layout.addWidget(self.color_widget) + # self.parent().done.connect(self.update_tag) - # Add Widgets to Layout ================================================ - self.root_layout.addWidget(self.name_widget) - self.root_layout.addWidget(self.shorthand_widget) - self.root_layout.addWidget(self.aliases_widget) - self.root_layout.addWidget(self.subtags_widget) - self.root_layout.addWidget(self.color_widget) - # self.parent().done.connect(self.update_tag) + if tag_id >= 0: + self.tag = self.lib.get_tag(tag_id) + else: + self.tag = Tag(-1, "New Tag", "", [], [], "") + self.set_tag(self.tag) - if tag_id >= 0: - self.tag = self.lib.get_tag(tag_id) - else: - self.tag = Tag(-1, 'New Tag', '', [], [], '') - self.set_tag(self.tag) - - - def add_subtag_callback(self, tag_id:int): - logging.info(f'adding {tag_id}') - # tag = self.lib.get_tag(self.tag_id) - # TODO: Create a single way to update tags and refresh library data - # new = self.build_tag() - self.tag.add_subtag(tag_id) - # self.tag = new - # self.lib.update_tag(new) - self.set_subtags() - # self.on_edit.emit(self.build_tag()) - - def remove_subtag_callback(self, tag_id:int): - logging.info(f'removing {tag_id}') - # tag = self.lib.get_tag(self.tag_id) - # TODO: Create a single way to update tags and refresh library data - # new = self.build_tag() - self.tag.remove_subtag(tag_id) - # self.tag = new - # self.lib.update_tag(new) - self.set_subtags() - # self.on_edit.emit(self.build_tag()) - - def set_subtags(self): - while self.scroll_layout.itemAt(0): - self.scroll_layout.takeAt(0).widget().deleteLater() - logging.info(f'Setting {self.tag.subtag_ids}') - c = QWidget() - l = QVBoxLayout(c) - l.setContentsMargins(0,0,0,0) - l.setSpacing(3) - for tag_id in self.tag.subtag_ids: - tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, True) - tw.on_remove.connect(lambda checked=False, t=tag_id: self.remove_subtag_callback(t)) - l.addWidget(tw) - self.scroll_layout.addWidget(c) + def add_subtag_callback(self, tag_id: int): + logging.info(f"adding {tag_id}") + # tag = self.lib.get_tag(self.tag_id) + # TODO: Create a single way to update tags and refresh library data + # new = self.build_tag() + self.tag.add_subtag(tag_id) + # self.tag = new + # self.lib.update_tag(new) + self.set_subtags() + # self.on_edit.emit(self.build_tag()) - def set_tag(self, tag:Tag): - # tag = self.lib.get_tag(tag_id) - self.name_field.setText(tag.name) - self.shorthand_field.setText(tag.shorthand) - self.aliases_field.setText('\n'.join(tag.aliases)) - self.set_subtags() - self.color_field.setCurrentIndex(TAG_COLORS.index(tag.color.lower())) - # self.tag_id = tag.id - - def build_tag(self) -> Tag: - # tag: Tag = self.tag - # if self.tag_id >= 0: - # tag = self.lib.get_tag(self.tag_id) - # else: - # tag = Tag(-1, '', '', [], [], '') - new_tag: Tag = Tag( - id=self.tag.id, - name=self.name_field.text(), - shorthand=self.shorthand_field.text(), - aliases=self.aliases_field.toPlainText().split('\n'), - subtags_ids=self.tag.subtag_ids, - color=self.color_field.currentText().lower()) - logging.info(f'built {new_tag}') - return new_tag - - # NOTE: The callback and signal do the same thing, I'm currently - # transitioning from using callbacks to the Qt method of using signals. - # self.tag_updated.emit(new_tag) - # self.callback(new_tag) - - # def on_return(self, callback, 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 remove_subtag_callback(self, tag_id: int): + logging.info(f"removing {tag_id}") + # tag = self.lib.get_tag(self.tag_id) + # TODO: Create a single way to update tags and refresh library data + # new = self.build_tag() + self.tag.remove_subtag(tag_id) + # self.tag = new + # self.lib.update_tag(new) + self.set_subtags() + # self.on_edit.emit(self.build_tag()) + + def set_subtags(self): + while self.scroll_layout.itemAt(0): + self.scroll_layout.takeAt(0).widget().deleteLater() + logging.info(f"Setting {self.tag.subtag_ids}") + c = QWidget() + l = QVBoxLayout(c) + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(3) + for tag_id in self.tag.subtag_ids: + tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, True) + tw.on_remove.connect( + lambda checked=False, t=tag_id: self.remove_subtag_callback(t) + ) + l.addWidget(tw) + self.scroll_layout.addWidget(c) + + def set_tag(self, tag: Tag): + # tag = self.lib.get_tag(tag_id) + self.name_field.setText(tag.name) + self.shorthand_field.setText(tag.shorthand) + self.aliases_field.setText("\n".join(tag.aliases)) + self.set_subtags() + self.color_field.setCurrentIndex(TAG_COLORS.index(tag.color.lower())) + # self.tag_id = tag.id + + def build_tag(self) -> Tag: + # tag: Tag = self.tag + # if self.tag_id >= 0: + # tag = self.lib.get_tag(self.tag_id) + # else: + # tag = Tag(-1, '', '', [], [], '') + new_tag: Tag = Tag( + id=self.tag.id, + name=self.name_field.text(), + shorthand=self.shorthand_field.text(), + aliases=self.aliases_field.toPlainText().split("\n"), + subtags_ids=self.tag.subtag_ids, + color=self.color_field.currentText().lower(), + ) + logging.info(f"built {new_tag}") + return new_tag + + # NOTE: The callback and signal do the same thing, I'm currently + # transitioning from using callbacks to the Qt method of using signals. + # self.tag_updated.emit(new_tag) + # self.callback(new_tag) + + # def on_return(self, callback, 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() diff --git a/tagstudio/src/qt/modals/delete_unlinked.py b/tagstudio/src/qt/modals/delete_unlinked.py index bd7a9fc..0df2918 100644 --- a/tagstudio/src/qt/modals/delete_unlinked.py +++ b/tagstudio/src/qt/modals/delete_unlinked.py @@ -6,7 +6,14 @@ import typing from PySide6.QtCore import Signal, Qt, QThreadPool from PySide6.QtGui import QStandardItemModel, QStandardItem -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListView +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QListView, +) from src.core.library import ItemType, Library from src.qt.helpers import CustomRunnable, FunctionIterator @@ -14,115 +21,120 @@ from src.qt.widgets import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver class DeleteUnlinkedEntriesModal(QWidget): - done = Signal() - def __init__(self, library:'Library', driver:'QtDriver'): - super().__init__() - self.lib = library - self.driver = driver - self.setWindowTitle(f'Delete Unlinked Entries') - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(500, 400) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,6,6,6) + done = Signal() - self.desc_widget = QLabel() - self.desc_widget.setObjectName('descriptionLabel') - self.desc_widget.setWordWrap(True) - self.desc_widget.setText(f''' + def __init__(self, library: "Library", driver: "QtDriver"): + super().__init__() + self.lib = library + self.driver = driver + self.setWindowTitle(f"Delete Unlinked Entries") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(500, 400) + 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.setText(f""" Are you sure you want to delete the following {len(self.lib.missing_files)} entries? - ''') - self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + """) + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.list_view = QListView() - self.model = QStandardItemModel() - self.list_view.setModel(self.model) - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6,6,6,6) - self.button_layout.addStretch(1) + self.list_view = QListView() + self.model = QStandardItemModel() + self.list_view.setModel(self.model) - self.cancel_button = QPushButton() - self.cancel_button.setText('&Cancel') - self.cancel_button.setDefault(True) - self.cancel_button.clicked.connect(self.hide) - self.button_layout.addWidget(self.cancel_button) - - self.delete_button = QPushButton() - self.delete_button.setText('&Delete') - self.delete_button.clicked.connect(self.hide) - self.delete_button.clicked.connect(lambda: self.delete_entries()) - self.button_layout.addWidget(self.delete_button) - - self.root_layout.addWidget(self.desc_widget) - self.root_layout.addWidget(self.list_view) - self.root_layout.addWidget(self.button_container) + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) - def refresh_list(self): - self.desc_widget.setText(f''' + self.cancel_button = QPushButton() + self.cancel_button.setText("&Cancel") + self.cancel_button.setDefault(True) + self.cancel_button.clicked.connect(self.hide) + self.button_layout.addWidget(self.cancel_button) + + self.delete_button = QPushButton() + self.delete_button.setText("&Delete") + self.delete_button.clicked.connect(self.hide) + self.delete_button.clicked.connect(lambda: self.delete_entries()) + self.button_layout.addWidget(self.delete_button) + + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.list_view) + self.root_layout.addWidget(self.button_container) + + def refresh_list(self): + self.desc_widget.setText(f""" Are you sure you want to delete the following {len(self.lib.missing_files)} entries? - ''') + """) - self.model.clear() - for i in self.lib.missing_files: - self.model.appendRow(QStandardItem(i)) + self.model.clear() + for i in self.lib.missing_files: + self.model.appendRow(QStandardItem(i)) - def delete_entries(self): - # pb = QProgressDialog('', None, 0, len(self.lib.missing_files)) - # # pb.setMaximum(len(self.lib.missing_files)) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Deleting Entries') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() + def delete_entries(self): + # pb = QProgressDialog('', None, 0, len(self.lib.missing_files)) + # # pb.setMaximum(len(self.lib.missing_files)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Deleting Entries') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() - # r = CustomRunnable(lambda: self.lib.ref(pb)) - # r.done.connect(lambda: self.done.emit()) - # # r.done.connect(lambda: self.model.clear()) - # QThreadPool.globalInstance().start(r) - # # r.run() + # r = CustomRunnable(lambda: self.lib.ref(pb)) + # r.done.connect(lambda: self.done.emit()) + # # r.done.connect(lambda: self.model.clear()) + # QThreadPool.globalInstance().start(r) + # # r.run() + iterator = FunctionIterator(self.lib.remove_missing_files) + pw = ProgressWidget( + window_title="Deleting Entries", + label_text="", + cancel_button_text=None, + minimum=0, + maximum=len(self.lib.missing_files), + ) + pw.show() - iterator = FunctionIterator(self.lib.remove_missing_files) + iterator.value.connect(lambda x: pw.update_progress(x[0] + 1)) + iterator.value.connect( + lambda x: pw.update_label( + f"Deleting {x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries" + ) + ) + iterator.value.connect( + lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]) + ) - pw = ProgressWidget( - window_title='Deleting Entries', - label_text='', - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.missing_files) - ) - pw.show() - - iterator.value.connect(lambda x: pw.update_progress(x[0]+1)) - iterator.value.connect(lambda x: pw.update_label(f'Deleting {x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries')) - iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1])) + r = CustomRunnable(lambda: iterator.run()) + QThreadPool.globalInstance().start(r) + r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) - r = CustomRunnable(lambda:iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) - - # def delete_entries_runnable(self): - # deleted = [] - # for i, missing in enumerate(self.lib.missing_files): - # # pb.setValue(i) - # # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') - # try: - # id = self.lib.get_entry_id_from_filepath(missing) - # logging.info(f'Removing Entry ID {id}:\n\t{missing}') - # self.lib.remove_entry(id) - # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) - # deleted.append(missing) - # except KeyError: - # logging.info( - # f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') - # yield i - # for d in deleted: - # self.lib.missing_files.remove(d) - # # self.driver.filter_items('') - # # self.done.emit() + # def delete_entries_runnable(self): + # deleted = [] + # for i, missing in enumerate(self.lib.missing_files): + # # pb.setValue(i) + # # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') + # try: + # id = self.lib.get_entry_id_from_filepath(missing) + # logging.info(f'Removing Entry ID {id}:\n\t{missing}') + # self.lib.remove_entry(id) + # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) + # deleted.append(missing) + # except KeyError: + # logging.info( + # f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') + # yield i + # for d in deleted: + # self.lib.missing_files.remove(d) + # # self.driver.filter_items('') + # # self.done.emit() diff --git a/tagstudio/src/qt/modals/file_extension.py b/tagstudio/src/qt/modals/file_extension.py index c4ab499..db9d000 100644 --- a/tagstudio/src/qt/modals/file_extension.py +++ b/tagstudio/src/qt/modals/file_extension.py @@ -11,41 +11,44 @@ from src.qt.widgets import PanelWidget 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) + done = Signal() - self.table = QTableWidget(len(self.lib.ignored_extensions), 1) - self.table.horizontalHeader().setVisible(False) - self.table.verticalHeader().setVisible(False) - self.table.horizontalHeader().setStretchLastSection(True) + 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.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.table = QTableWidget(len(self.lib.ignored_extensions), 1) + self.table.horizontalHeader().setVisible(False) + self.table.verticalHeader().setVisible(False) + self.table.horizontalHeader().setStretchLastSection(True) - 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()) + 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()) diff --git a/tagstudio/src/qt/modals/fix_dupes.py b/tagstudio/src/qt/modals/fix_dupes.py index a6f1c56..9c18590 100644 --- a/tagstudio/src/qt/modals/fix_dupes.py +++ b/tagstudio/src/qt/modals/fix_dupes.py @@ -7,153 +7,167 @@ import os import typing from PySide6.QtCore import Qt -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QFileDialog, +) from src.core.library import Library from src.qt.modals import MirrorEntriesModal # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver class FixDupeFilesModal(QWidget): - # done = Signal(int) - def __init__(self, library:'Library', driver:'QtDriver'): - super().__init__() - self.lib = library - self.driver = driver - self.count = -1 - self.filename = '' - self.setWindowTitle(f'Fix Duplicate Files') - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(400, 300) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,6,6,6) + # done = Signal(int) + def __init__(self, library: "Library", driver: "QtDriver"): + super().__init__() + self.lib = library + self.driver = driver + self.count = -1 + self.filename = "" + self.setWindowTitle(f"Fix Duplicate Files") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(400, 300) + 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:14px;' - # 'padding-top: 6px' - '') - self.desc_widget.setText('''TagStudio supports importing DupeGuru results to manage duplicate files.''') - self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + 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:14px;' + # 'padding-top: 6px' + "" + ) + self.desc_widget.setText( + """TagStudio supports importing DupeGuru results to manage duplicate files.""" + ) + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.dupe_count = QLabel() - self.dupe_count.setObjectName('dupeCountLabel') - self.dupe_count.setStyleSheet( - # 'background:blue;' - # 'text-align:center;' - 'font-weight:bold;' - 'font-size:14px;' - # 'padding-top: 6px' - '') - self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.dupe_count = QLabel() + self.dupe_count.setObjectName("dupeCountLabel") + self.dupe_count.setStyleSheet( + # 'background:blue;' + # 'text-align:center;' + "font-weight:bold;" + "font-size:14px;" + # 'padding-top: 6px' + "" + ) + self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.file_label = QLabel() - self.file_label.setObjectName('fileLabel') - # self.file_label.setStyleSheet( - # # 'background:blue;' - # # 'text-align:center;' - # 'font-weight:bold;' - # 'font-size:14px;' - # # 'padding-top: 6px' - # '') - # self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.file_label.setText('No DupeGuru File Selected') - + self.file_label = QLabel() + self.file_label.setObjectName("fileLabel") + # self.file_label.setStyleSheet( + # # 'background:blue;' + # # 'text-align:center;' + # 'font-weight:bold;' + # 'font-size:14px;' + # # 'padding-top: 6px' + # '') + # self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.file_label.setText("No DupeGuru File Selected") - self.open_button = QPushButton() - self.open_button.setText('&Load DupeGuru File') - self.open_button.clicked.connect(lambda: self.select_file()) + self.open_button = QPushButton() + self.open_button.setText("&Load DupeGuru File") + self.open_button.clicked.connect(lambda: self.select_file()) - self.mirror_button = QPushButton() - self.mirror_modal = MirrorEntriesModal(self.lib, self.driver) - self.mirror_modal.done.connect(lambda: self.refresh_dupes()) - self.mirror_button.setText('&Mirror Entries') - self.mirror_button.clicked.connect(lambda: self.mirror_modal.show()) - self.mirror_desc = QLabel() - self.mirror_desc.setWordWrap(True) - self.mirror_desc.setText("""Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.""") + self.mirror_button = QPushButton() + self.mirror_modal = MirrorEntriesModal(self.lib, self.driver) + self.mirror_modal.done.connect(lambda: self.refresh_dupes()) + self.mirror_button.setText("&Mirror Entries") + self.mirror_button.clicked.connect(lambda: self.mirror_modal.show()) + self.mirror_desc = QLabel() + self.mirror_desc.setWordWrap(True) + self.mirror_desc.setText( + """Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data.""" + ) - # self.mirror_delete_button = QPushButton() - # self.mirror_delete_button.setText('Mirror && Delete') + # self.mirror_delete_button = QPushButton() + # self.mirror_delete_button.setText('Mirror && Delete') - self.advice_label = QLabel() - self.advice_label.setWordWrap(True) - self.advice_label.setText("""After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's "Fix Unlinked Entries" feature in the Tools menu in order to delete the unlinked Entries.""") - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6,6,6,6) - self.button_layout.addStretch(1) - - self.done_button = QPushButton() - self.done_button.setText('&Done') - # self.save_button.setAutoDefault(True) - self.done_button.setDefault(True) - self.done_button.clicked.connect(self.hide) - # self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) - self.button_layout.addWidget(self.done_button) + self.advice_label = QLabel() + self.advice_label.setWordWrap(True) + self.advice_label.setText( + """After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's "Fix Unlinked Entries" feature in the Tools menu in order to delete the unlinked Entries.""" + ) - # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - - # self.done.connect(lambda x: callback(x)) - - self.root_layout.addWidget(self.desc_widget) - self.root_layout.addWidget(self.dupe_count) - self.root_layout.addWidget(self.file_label) - self.root_layout.addWidget(self.open_button) - # self.mirror_delete_button.setHidden(True) + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) - self.root_layout.addWidget(self.mirror_button) - self.root_layout.addWidget(self.mirror_desc) - # self.root_layout.addWidget(self.mirror_delete_button) - self.root_layout.addWidget(self.advice_label) - # self.root_layout.setStretch(1,2) - self.root_layout.addStretch(1) - self.root_layout.addWidget(self.button_container) + self.done_button = QPushButton() + self.done_button.setText("&Done") + # self.save_button.setAutoDefault(True) + self.done_button.setDefault(True) + self.done_button.clicked.connect(self.hide) + # self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex())) + # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) + self.button_layout.addWidget(self.done_button) - self.set_dupe_count(self.count) - - def select_file(self): - qfd = QFileDialog(self, - 'Open DupeGuru Results File', - os.path.normpath(self.lib.library_dir)) - qfd.setFileMode(QFileDialog.FileMode.ExistingFile) - qfd.setNameFilter("DupeGuru Files (*.dupeguru)") - if qfd.exec_(): - filename = qfd.selectedFiles() - if filename: - self.set_filename(filename[0]) - - def set_filename(self, filename:str): - if filename: - self.file_label.setText(filename) - else: - self.file_label.setText('No DupeGuru File Selected') - self.filename = filename - self.refresh_dupes() - self.mirror_modal.refresh_list() - - def refresh_dupes(self): - self.lib.refresh_dupe_files(self.filename) - self.set_dupe_count(len(self.lib.dupe_files)) + # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - def set_dupe_count(self, count:int): - self.count = count - if self.count < 0: - self.mirror_button.setDisabled(True) - self.dupe_count.setText(f'Duplicate File Matches: N/A') - elif self.count == 0: - self.mirror_button.setDisabled(True) - self.dupe_count.setText(f'Duplicate File Matches: {count}') - else: - self.mirror_button.setDisabled(False) - self.dupe_count.setText(f'Duplicate File Matches: {count}') + # self.done.connect(lambda x: callback(x)) + + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.dupe_count) + self.root_layout.addWidget(self.file_label) + self.root_layout.addWidget(self.open_button) + # self.mirror_delete_button.setHidden(True) + + self.root_layout.addWidget(self.mirror_button) + self.root_layout.addWidget(self.mirror_desc) + # self.root_layout.addWidget(self.mirror_delete_button) + self.root_layout.addWidget(self.advice_label) + # self.root_layout.setStretch(1,2) + self.root_layout.addStretch(1) + self.root_layout.addWidget(self.button_container) + + self.set_dupe_count(self.count) + + def select_file(self): + qfd = QFileDialog( + self, "Open DupeGuru Results File", os.path.normpath(self.lib.library_dir) + ) + qfd.setFileMode(QFileDialog.FileMode.ExistingFile) + qfd.setNameFilter("DupeGuru Files (*.dupeguru)") + if qfd.exec_(): + filename = qfd.selectedFiles() + if filename: + self.set_filename(filename[0]) + + def set_filename(self, filename: str): + if filename: + self.file_label.setText(filename) + else: + self.file_label.setText("No DupeGuru File Selected") + self.filename = filename + self.refresh_dupes() + self.mirror_modal.refresh_list() + + def refresh_dupes(self): + self.lib.refresh_dupe_files(self.filename) + self.set_dupe_count(len(self.lib.dupe_files)) + + def set_dupe_count(self, count: int): + self.count = count + if self.count < 0: + self.mirror_button.setDisabled(True) + self.dupe_count.setText(f"Duplicate File Matches: N/A") + elif self.count == 0: + self.mirror_button.setDisabled(True) + self.dupe_count.setText(f"Duplicate File Matches: {count}") + else: + self.mirror_button.setDisabled(False) + self.dupe_count.setText(f"Duplicate File Matches: {count}") diff --git a/tagstudio/src/qt/modals/fix_unlinked.py b/tagstudio/src/qt/modals/fix_unlinked.py index 3b5f842..d193fd0 100644 --- a/tagstudio/src/qt/modals/fix_unlinked.py +++ b/tagstudio/src/qt/modals/fix_unlinked.py @@ -12,168 +12,175 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushBu from src.core.library import Library from src.qt.helpers import FunctionIterator, CustomRunnable from src.qt.modals import DeleteUnlinkedEntriesModal, RelinkUnlinkedEntries -from src.qt.widgets import ProgressWidget +from src.qt.widgets import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class FixUnlinkedEntriesModal(QWidget): - # done = Signal(int) - def __init__(self, library:'Library', driver:'QtDriver'): - super().__init__() - self.lib = library - self.driver = driver - self.count = -1 - self.setWindowTitle(f'Fix Unlinked Entries') - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(400, 300) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,6,6,6) + # done = Signal(int) + def __init__(self, library: "Library", driver: "QtDriver"): + super().__init__() + self.lib = library + self.driver = driver + self.count = -1 + self.setWindowTitle(f"Fix Unlinked Entries") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(400, 300) + 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:14px;' - # 'padding-top: 6px' - '') - self.desc_widget.setText('''Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. - Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.''') - self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + 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:14px;' + # 'padding-top: 6px' + "" + ) + self.desc_widget.setText("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. + Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""") + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.missing_count = QLabel() - self.missing_count.setObjectName('missingCountLabel') - self.missing_count.setStyleSheet( - # 'background:blue;' - # 'text-align:center;' - 'font-weight:bold;' - 'font-size:14px;' - # 'padding-top: 6px' - '') - self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter) - # self.missing_count.setText('Missing Files: N/A') - + self.missing_count = QLabel() + self.missing_count.setObjectName("missingCountLabel") + self.missing_count.setStyleSheet( + # 'background:blue;' + # 'text-align:center;' + "font-weight:bold;" + "font-size:14px;" + # 'padding-top: 6px' + "" + ) + self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter) + # self.missing_count.setText('Missing Files: N/A') - self.refresh_button = QPushButton() - self.refresh_button.setText('&Refresh') - self.refresh_button.clicked.connect(lambda: self.refresh_missing_files()) + self.refresh_button = QPushButton() + self.refresh_button.setText("&Refresh") + self.refresh_button.clicked.connect(lambda: self.refresh_missing_files()) - self.search_button = QPushButton() - self.search_button.setText('&Search && Relink') - self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver) - self.relink_class.done.connect(lambda: self.refresh_missing_files()) - self.relink_class.done.connect(lambda: self.driver.update_thumbs()) - self.search_button.clicked.connect(lambda: self.relink_class.repair_entries()) + self.search_button = QPushButton() + self.search_button.setText("&Search && Relink") + self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver) + self.relink_class.done.connect(lambda: self.refresh_missing_files()) + self.relink_class.done.connect(lambda: self.driver.update_thumbs()) + self.search_button.clicked.connect(lambda: self.relink_class.repair_entries()) - self.manual_button = QPushButton() - self.manual_button.setText('&Manual Relink') + self.manual_button = QPushButton() + self.manual_button.setText("&Manual Relink") - self.delete_button = QPushButton() - self.delete_modal = DeleteUnlinkedEntriesModal(self.lib, self.driver) - self.delete_modal.done.connect(lambda: self.set_missing_count(len(self.lib.missing_files))) - self.delete_modal.done.connect(lambda: self.driver.update_thumbs()) - self.delete_button.setText('De&lete Unlinked Entries') - self.delete_button.clicked.connect(lambda: self.delete_modal.show()) - - # self.combo_box = QComboBox() - # self.combo_box.setEditable(False) - # # self.combo_box.setMaxVisibleItems(5) - # self.combo_box.setStyleSheet('combobox-popup:0;') - # self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - # for df in self.lib.default_fields: - # self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})') - + self.delete_button = QPushButton() + self.delete_modal = DeleteUnlinkedEntriesModal(self.lib, self.driver) + self.delete_modal.done.connect( + lambda: self.set_missing_count(len(self.lib.missing_files)) + ) + self.delete_modal.done.connect(lambda: self.driver.update_thumbs()) + self.delete_button.setText("De&lete Unlinked Entries") + self.delete_button.clicked.connect(lambda: self.delete_modal.show()) - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6,6,6,6) - self.button_layout.addStretch(1) - - self.done_button = QPushButton() - self.done_button.setText('&Done') - # self.save_button.setAutoDefault(True) - self.done_button.setDefault(True) - self.done_button.clicked.connect(self.hide) - # self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) - self.button_layout.addWidget(self.done_button) + # self.combo_box = QComboBox() + # self.combo_box.setEditable(False) + # # self.combo_box.setMaxVisibleItems(5) + # self.combo_box.setStyleSheet('combobox-popup:0;') + # self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + # for df in self.lib.default_fields: + # self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})') - # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - - # self.done.connect(lambda x: callback(x)) - - self.root_layout.addWidget(self.desc_widget) - self.root_layout.addWidget(self.missing_count) - self.root_layout.addWidget(self.refresh_button) - self.root_layout.addWidget(self.search_button) - self.manual_button.setHidden(True) - self.root_layout.addWidget(self.manual_button) - self.root_layout.addWidget(self.delete_button) - # self.root_layout.setStretch(1,2) - self.root_layout.addStretch(1) - self.root_layout.addWidget(self.button_container) + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) - self.set_missing_count(self.count) + self.done_button = QPushButton() + self.done_button.setText("&Done") + # self.save_button.setAutoDefault(True) + self.done_button.setDefault(True) + self.done_button.clicked.connect(self.hide) + # self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex())) + # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) + self.button_layout.addWidget(self.done_button) - def refresh_missing_files(self): - logging.info(f'Start RMF: {QThread.currentThread()}') - # pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries)) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Scanning Library') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() + # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) - iterator = FunctionIterator(self.lib.refresh_missing_files) - pw = ProgressWidget( - window_title='Scanning Library', - label_text=f'Scanning Library for Unlinked Entries...', - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.entries) - ) - pw.show() - iterator.value.connect(lambda v: pw.update_progress(v+1)) - # rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}')) - r = CustomRunnable(lambda:iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), - self.set_missing_count(len(self.lib.missing_files)), - self.delete_modal.refresh_list())) + # self.done.connect(lambda x: callback(x)) - # r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v))) - # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list())) - # QThreadPool.globalInstance().start(r) - # # r.run() - # pass + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.missing_count) + self.root_layout.addWidget(self.refresh_button) + self.root_layout.addWidget(self.search_button) + self.manual_button.setHidden(True) + self.root_layout.addWidget(self.manual_button) + self.root_layout.addWidget(self.delete_button) + # self.root_layout.setStretch(1,2) + self.root_layout.addStretch(1) + self.root_layout.addWidget(self.button_container) - # def update_scan_value(self, pb:QProgressDialog, value=int): - # # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...') - # pb.setValue(value) + self.set_missing_count(self.count) - def set_missing_count(self, count:int): - self.count = count - if self.count < 0: - self.search_button.setDisabled(True) - self.delete_button.setDisabled(True) - self.missing_count.setText(f'Unlinked Entries: N/A') - elif self.count == 0: - self.search_button.setDisabled(True) - self.delete_button.setDisabled(True) - self.missing_count.setText(f'Unlinked Entries: {count}') - else: - self.search_button.setDisabled(False) - self.delete_button.setDisabled(False) - self.missing_count.setText(f'Unlinked Entries: {count}') + def refresh_missing_files(self): + logging.info(f"Start RMF: {QThread.currentThread()}") + # pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Scanning Library') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() + + iterator = FunctionIterator(self.lib.refresh_missing_files) + pw = ProgressWidget( + window_title="Scanning Library", + label_text=f"Scanning Library for Unlinked Entries...", + cancel_button_text=None, + minimum=0, + maximum=len(self.lib.entries), + ) + pw.show() + iterator.value.connect(lambda v: pw.update_progress(v + 1)) + # rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}')) + r = CustomRunnable(lambda: iterator.run()) + QThreadPool.globalInstance().start(r) + r.done.connect( + lambda: ( + pw.hide(), + pw.deleteLater(), + self.set_missing_count(len(self.lib.missing_files)), + self.delete_modal.refresh_list(), + ) + ) + + # r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v))) + # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list())) + # QThreadPool.globalInstance().start(r) + # # r.run() + # pass + + # def update_scan_value(self, pb:QProgressDialog, value=int): + # # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...') + # pb.setValue(value) + + def set_missing_count(self, count: int): + self.count = count + if self.count < 0: + self.search_button.setDisabled(True) + self.delete_button.setDisabled(True) + self.missing_count.setText(f"Unlinked Entries: N/A") + elif self.count == 0: + self.search_button.setDisabled(True) + self.delete_button.setDisabled(True) + self.missing_count.setText(f"Unlinked Entries: {count}") + else: + self.search_button.setDisabled(False) + self.delete_button.setDisabled(False) + self.missing_count.setText(f"Unlinked Entries: {count}") diff --git a/tagstudio/src/qt/modals/folders_to_tags.py b/tagstudio/src/qt/modals/folders_to_tags.py index 914a318..bfb0f8d 100644 --- a/tagstudio/src/qt/modals/folders_to_tags.py +++ b/tagstudio/src/qt/modals/folders_to_tags.py @@ -8,7 +8,15 @@ import math import typing from PySide6.QtCore import Qt -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QFrame, +) from src.core.library import Library, Tag from src.core.palette import ColorType, get_tag_color @@ -16,306 +24,329 @@ from src.qt.flowlayout import FlowLayout # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) -def folders_to_tags(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 folders_to_tags(library: Library): + logging.info("Converting folders to Tags") + tree = dict(dirs={}) - 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"] + 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 = reverse_tag(library, 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) + + logging.info("Done") - for tag in library.tags: - reversed_tag = reverse_tag(library,tag,None) - add_tag_to_tree(reversed_tag) +def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]: + if list != None: + list.append(tag) + else: + list = [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) + if len(tag.subtag_ids) == 0: + list.reverse() + return list + else: + for subtag_id in tag.subtag_ids: + subtag = library.get_tag(subtag_id) + return reverse_tag(library, subtag, list) - logging.info("Done") -def reverse_tag(library:Library,tag:Tag,list:list[Tag]) -> list[Tag]: - if list != None: - list.append(tag) - else: - list = [tag] +# =========== UI =========== - if len(tag.subtag_ids) == 0: - list.reverse() - return list - else: - for subtag_id in tag.subtag_ids: - subtag = library.get_tag(subtag_id) - return reverse_tag(library,subtag,list) -#=========== UI =========== +def generate_preview_data(library: Library): + tree = dict(dirs={}, files=[]) -def generate_preview_data(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_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 - 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 = reverse_tag(library,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 - 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) + for tag in library.tags: + reversed_tag = reverse_tag(library, tag, None) + add_tag_to_tree(reversed_tag) - def cut_branches_adding_nothing(branch:dict): - folders = set(branch["dirs"].keys()) - for folder in folders: - cut = cut_branches_adding_nothing(branch["dirs"][folder]) - if cut: - branch['dirs'].pop(folder) + 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) - if not "tag" in branch: return - if branch["tag"].id == -1 or len(branch["files"])>0:#Needs to be first - return False - if len(branch["dirs"].keys()) == 0: - return True + def cut_branches_adding_nothing(branch: dict): + folders = set(branch["dirs"].keys()) + for folder in folders: + cut = cut_branches_adding_nothing(branch["dirs"][folder]) + if cut: + branch["dirs"].pop(folder) - cut_branches_adding_nothing(tree) + if not "tag" in branch: + return + if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first + return False + if len(branch["dirs"].keys()) == 0: + return True - return tree + cut_branches_adding_nothing(tree) + + return tree class FoldersToTagsModal(QWidget): - # done = Signal(int) - def __init__(self, library:'Library', driver:'QtDriver'): - super().__init__() - self.library = library - self.driver = 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.open_close_button_w = QWidget() - self.open_close_button_layout = QHBoxLayout(self.open_close_button_w) - - self.open_all_button = QPushButton() - self.open_all_button.setText("Open All") - self.open_all_button.clicked.connect(lambda:self.set_all_branches(False)) - self.close_all_button = QPushButton() - self.close_all_button.setText("Close All") - self.close_all_button.clicked.connect(lambda:self.set_all_branches(True)) - - self.open_close_button_layout.addWidget(self.open_all_button) - self.open_close_button_layout.addWidget(self.close_all_button) - - 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) + # done = Signal(int) + def __init__(self, library: "Library", driver: "QtDriver"): + super().__init__() + self.library = library + self.driver = driver + self.count = -1 + self.filename = "" - 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.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.open_close_button_w = QWidget() + self.open_close_button_layout = QHBoxLayout(self.open_close_button_w) + + self.open_all_button = QPushButton() + self.open_all_button.setText("Open All") + self.open_all_button.clicked.connect(lambda: self.set_all_branches(False)) + self.close_all_button = QPushButton() + self.close_all_button.setText("Close All") + self.close_all_button.clicked.connect(lambda: self.set_all_branches(True)) + + self.open_close_button_layout.addWidget(self.open_all_button) + self.open_close_button_layout.addWidget(self.close_all_button) + + 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(self.on_apply) + + self.showEvent = self.on_open + + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.open_close_button_w) + self.root_layout.addWidget(self.scroll_area) + self.root_layout.addWidget(self.apply_button) + + def on_apply(self, event): + folders_to_tags(self.library) + self.close() + self.driver.preview_panel.update_widgets() + + def on_open(self, event): + for i in reversed(range(self.scroll_layout.count())): + self.scroll_layout.itemAt(i).widget().setParent(None) + + data = generate_preview_data(self.library) + + for folder in data["dirs"].values(): + test = TreeItem(folder, None) + self.scroll_layout.addWidget(test) + + def set_all_branches(self, hidden: bool): + for i in reversed(range(self.scroll_layout.count())): + child = self.scroll_layout.itemAt(i).widget() + if type(child) == TreeItem: + child.set_all_branches(hidden) - self.apply_button = QPushButton() - self.apply_button.setText('&Apply') - self.apply_button.clicked.connect(self.on_apply) - - self.showEvent = self.on_open - - self.root_layout.addWidget(self.desc_widget) - self.root_layout.addWidget(self.open_close_button_w) - self.root_layout.addWidget(self.scroll_area) - self.root_layout.addWidget(self.apply_button) - - def on_apply(self,event): - folders_to_tags(self.library) - self.close() - self.driver.preview_panel.update_widgets() - - def on_open(self,event): - for i in reversed(range(self.scroll_layout.count())): - self.scroll_layout.itemAt(i).widget().setParent(None) - data = generate_preview_data(self.library) - - for folder in data["dirs"].values(): - test = TreeItem(folder,None) - self.scroll_layout.addWidget(test) - - def set_all_branches(self,hidden:bool): - for i in reversed(range(self.scroll_layout.count())): - child = self.scroll_layout.itemAt(i).widget() - if type(child) == TreeItem: - child.set_all_branches(hidden) - class TreeItem(QWidget): - def __init__(self,data:dict,parentTag:Tag): - super().__init__() - - self.setStyleSheet("QLabel{font-size: 13px}") - - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(20,0,0,0) - self.root_layout.setSpacing(1) + def __init__(self, data: dict, parentTag: Tag): + super().__init__() - self.test = QWidget() - self.root_layout.addWidget(self.test) - - self.tag_layout = FlowLayout(self.test) - - self.label = QLabel() - self.tag_layout.addWidget(self.label) - self.tag_widget = ModifiedTagWidget(data["tag"],parentTag) - self.tag_widget.bg_button.clicked.connect(lambda:self.hide_show()) - self.tag_layout.addWidget(self.tag_widget) + self.setStyleSheet("QLabel{font-size: 13px}") - self.children_widget = QWidget() - self.children_layout = QVBoxLayout(self.children_widget) - self.root_layout.addWidget(self.children_widget) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(20, 0, 0, 0) + self.root_layout.setSpacing(1) - self.populate(data) + self.test = QWidget() + self.root_layout.addWidget(self.test) - def hide_show(self): - self.children_widget.setHidden(not self.children_widget.isHidden()) - self.label.setText(">" if self.children_widget.isHidden() else "v") + self.tag_layout = FlowLayout(self.test) - def populate(self,data:dict): - for folder in data["dirs"].values(): - item = TreeItem(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() - else: - self.label.setText("v") - - def set_all_branches(self,hidden:bool): - for i in reversed(range(self.children_layout.count())): - child = self.children_layout.itemAt(i).widget() - if type(child) == TreeItem: - child.set_all_branches(hidden) - - self.children_widget.setHidden(hidden) - self.label.setText(">" if self.children_widget.isHidden() else "v") - -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.label = QLabel() + self.tag_layout.addWidget(self.label) + self.tag_widget = ModifiedTagWidget(data["tag"], parentTag) + self.tag_widget.bg_button.clicked.connect(lambda: self.hide_show()) + self.tag_layout.addWidget(self.tag_widget) - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.base_layout = QVBoxLayout(self) - self.base_layout.setObjectName('baseLayout') - self.base_layout.setContentsMargins(0, 0, 0, 0) + self.children_widget = QWidget() + self.children_layout = QVBoxLayout(self.children_widget) + self.root_layout.addWidget(self.children_widget) - 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.populate(data) - 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) + def hide_show(self): + self.children_widget.setHidden(not self.children_widget.isHidden()) + self.label.setText(">" if self.children_widget.isHidden() else "v") - 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'}}') + def populate(self, data: dict): + for folder in data["dirs"].values(): + item = TreeItem(folder, data["tag"]) + self.children_layout.addWidget(item) + for file in data["files"]: + label = QLabel() + label.setText(" -> " + file) + self.children_layout.addWidget(label) - self.base_layout.addWidget(self.bg_button) - self.setMinimumSize(50,20) \ No newline at end of file + if len(data["files"]) == 0 and len(data["dirs"].values()) == 0: + self.hide_show() + else: + self.label.setText("v") + + def set_all_branches(self, hidden: bool): + for i in reversed(range(self.children_layout.count())): + child = self.children_layout.itemAt(i).widget() + if type(child) == TreeItem: + child.set_all_branches(hidden) + + self.children_widget.setHidden(hidden) + self.label.setText(">" if self.children_widget.isHidden() else "v") + + +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) diff --git a/tagstudio/src/qt/modals/mirror_entities.py b/tagstudio/src/qt/modals/mirror_entities.py index 10d5f00..29419ea 100644 --- a/tagstudio/src/qt/modals/mirror_entities.py +++ b/tagstudio/src/qt/modals/mirror_entities.py @@ -8,120 +8,132 @@ import typing from PySide6.QtCore import Signal, Qt, QThreadPool from PySide6.QtGui import QStandardItemModel, QStandardItem -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListView +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QListView, +) from src.core.library import Library from src.qt.helpers import FunctionIterator, CustomRunnable -from src.qt.widgets import ProgressWidget +from src.qt.widgets import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver class MirrorEntriesModal(QWidget): - done = Signal() - def __init__(self, library:'Library', driver:'QtDriver'): - super().__init__() - self.lib = library - self.driver = driver - self.setWindowTitle(f'Mirror Entries') - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.setMinimumSize(500, 400) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,6,6,6) + done = Signal() - self.desc_widget = QLabel() - self.desc_widget.setObjectName('descriptionLabel') - self.desc_widget.setWordWrap(True) - self.desc_widget.setText(f''' + def __init__(self, library: "Library", driver: "QtDriver"): + super().__init__() + self.lib = library + self.driver = driver + self.setWindowTitle(f"Mirror Entries") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setMinimumSize(500, 400) + 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.setText(f""" Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries? - ''') - self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + """) + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.list_view = QListView() - self.model = QStandardItemModel() - self.list_view.setModel(self.model) - - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6,6,6,6) - self.button_layout.addStretch(1) + self.list_view = QListView() + self.model = QStandardItemModel() + self.list_view.setModel(self.model) - self.cancel_button = QPushButton() - self.cancel_button.setText('&Cancel') - self.cancel_button.setDefault(True) - self.cancel_button.clicked.connect(self.hide) - self.button_layout.addWidget(self.cancel_button) - - self.mirror_button = QPushButton() - self.mirror_button.setText('&Mirror') - self.mirror_button.clicked.connect(self.hide) - self.mirror_button.clicked.connect(lambda: self.mirror_entries()) - self.button_layout.addWidget(self.mirror_button) - - self.root_layout.addWidget(self.desc_widget) - self.root_layout.addWidget(self.list_view) - self.root_layout.addWidget(self.button_container) - - def refresh_list(self): - self.desc_widget.setText(f''' + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) + + self.cancel_button = QPushButton() + self.cancel_button.setText("&Cancel") + self.cancel_button.setDefault(True) + self.cancel_button.clicked.connect(self.hide) + self.button_layout.addWidget(self.cancel_button) + + self.mirror_button = QPushButton() + self.mirror_button.setText("&Mirror") + self.mirror_button.clicked.connect(self.hide) + self.mirror_button.clicked.connect(lambda: self.mirror_entries()) + self.button_layout.addWidget(self.mirror_button) + + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.list_view) + self.root_layout.addWidget(self.button_container) + + def refresh_list(self): + self.desc_widget.setText(f""" Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries? - ''') + """) - self.model.clear() - for i in self.lib.dupe_files: - self.model.appendRow(QStandardItem(str(i))) + self.model.clear() + for i in self.lib.dupe_files: + self.model.appendRow(QStandardItem(str(i))) - def mirror_entries(self): - # pb = QProgressDialog('', None, 0, len(self.lib.dupe_files)) - # # pb.setMaximum(len(self.lib.missing_files)) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Mirroring Entries') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() + def mirror_entries(self): + # pb = QProgressDialog('', None, 0, len(self.lib.dupe_files)) + # # pb.setMaximum(len(self.lib.missing_files)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Mirroring Entries') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() - # r = CustomRunnable(lambda: self.mirror_entries_runnable(pb)) - # r.done.connect(lambda: self.done.emit()) - # r.done.connect(lambda: self.driver.preview_panel.refresh()) - # # r.done.connect(lambda: self.model.clear()) - # # QThreadPool.globalInstance().start(r) - # r.run() + # r = CustomRunnable(lambda: self.mirror_entries_runnable(pb)) + # r.done.connect(lambda: self.done.emit()) + # r.done.connect(lambda: self.driver.preview_panel.refresh()) + # # r.done.connect(lambda: self.model.clear()) + # # QThreadPool.globalInstance().start(r) + # r.run() - iterator = FunctionIterator(self.mirror_entries_runnable) - pw = ProgressWidget( - window_title='Mirroring Entries', - label_text=f'Mirroring 1/{len(self.lib.dupe_files)} Entries...', - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.dupe_files) - ) - pw.show() - iterator.value.connect(lambda x: pw.update_progress(x+1)) - iterator.value.connect(lambda x: pw.update_label(f'Mirroring {x+1}/{len(self.lib.dupe_files)} Entries...')) - r = CustomRunnable(lambda:iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect(lambda: ( - pw.hide(), - pw.deleteLater(), - self.driver.preview_panel.update_widgets(), - self.done.emit() - )) - - def mirror_entries_runnable(self): - mirrored = [] - for i, dupe in enumerate(self.lib.dupe_files): - # pb.setValue(i) - # pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries') - entry_id_1 = self.lib.get_entry_id_from_filepath( - dupe[0]) - entry_id_2 = self.lib.get_entry_id_from_filepath( - dupe[1]) - self.lib.mirror_entry_fields([entry_id_1, entry_id_2]) - sleep(0.005) - yield i - for d in mirrored: - self.lib.dupe_files.remove(d) - # self.driver.filter_items('') - # self.done.emit() + iterator = FunctionIterator(self.mirror_entries_runnable) + pw = ProgressWidget( + window_title="Mirroring Entries", + label_text=f"Mirroring 1/{len(self.lib.dupe_files)} Entries...", + cancel_button_text=None, + minimum=0, + maximum=len(self.lib.dupe_files), + ) + pw.show() + iterator.value.connect(lambda x: pw.update_progress(x + 1)) + iterator.value.connect( + lambda x: pw.update_label( + f"Mirroring {x+1}/{len(self.lib.dupe_files)} Entries..." + ) + ) + r = CustomRunnable(lambda: iterator.run()) + QThreadPool.globalInstance().start(r) + r.done.connect( + lambda: ( + pw.hide(), + pw.deleteLater(), + self.driver.preview_panel.update_widgets(), + self.done.emit(), + ) + ) + + def mirror_entries_runnable(self): + mirrored = [] + for i, dupe in enumerate(self.lib.dupe_files): + # pb.setValue(i) + # pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries') + entry_id_1 = self.lib.get_entry_id_from_filepath(dupe[0]) + entry_id_2 = self.lib.get_entry_id_from_filepath(dupe[1]) + self.lib.mirror_entry_fields([entry_id_1, entry_id_2]) + sleep(0.005) + yield i + for d in mirrored: + self.lib.dupe_files.remove(d) + # self.driver.filter_items('') + # self.done.emit() diff --git a/tagstudio/src/qt/modals/relink_unlinked.py b/tagstudio/src/qt/modals/relink_unlinked.py index 97ac60a..dbeb405 100644 --- a/tagstudio/src/qt/modals/relink_unlinked.py +++ b/tagstudio/src/qt/modals/relink_unlinked.py @@ -8,83 +8,91 @@ from PySide6.QtCore import QObject, Signal, QThreadPool from src.core.library import Library from src.qt.helpers import FunctionIterator, CustomRunnable -from src.qt.widgets import ProgressWidget +from src.qt.widgets import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver class RelinkUnlinkedEntries(QObject): - done = Signal() + done = Signal() - def __init__(self, library:'Library', driver:'QtDriver'): - super().__init__() - self.lib = library - self.driver = driver - self.fixed = 0 + def __init__(self, library: "Library", driver: "QtDriver"): + super().__init__() + self.lib = library + self.driver = driver + self.fixed = 0 - def repair_entries(self): - # pb = QProgressDialog('', None, 0, len(self.lib.missing_files)) - # # pb.setMaximum(len(self.lib.missing_files)) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Relinking Entries') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() + def repair_entries(self): + # pb = QProgressDialog('', None, 0, len(self.lib.missing_files)) + # # pb.setMaximum(len(self.lib.missing_files)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Relinking Entries') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() - # r = CustomRunnable(lambda: self.repair_entries_runnable(pb)) - # r.done.connect(lambda: self.done.emit()) - # # r.done.connect(lambda: self.model.clear()) - # QThreadPool.globalInstance().start(r) - # # r.run() + # r = CustomRunnable(lambda: self.repair_entries_runnable(pb)) + # r.done.connect(lambda: self.done.emit()) + # # r.done.connect(lambda: self.model.clear()) + # QThreadPool.globalInstance().start(r) + # # r.run() + iterator = FunctionIterator(self.lib.fix_missing_files) - iterator = FunctionIterator(self.lib.fix_missing_files) + pw = ProgressWidget( + window_title="Relinking Entries", + label_text="", + cancel_button_text=None, + minimum=0, + maximum=len(self.lib.missing_files), + ) + pw.show() - pw = ProgressWidget( - window_title='Relinking Entries', - label_text='', - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.missing_files) - ) - pw.show() + iterator.value.connect(lambda x: pw.update_progress(x[0] + 1)) + iterator.value.connect( + lambda x: ( + self.increment_fixed() if x[1] else (), + pw.update_label( + f"Attempting to Relink {x[0]+1}/{len(self.lib.missing_files)} Entries, {self.fixed} Successfully Relinked" + ), + ) + ) + # iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1])) - iterator.value.connect(lambda x: pw.update_progress(x[0]+1)) - iterator.value.connect(lambda x: (self.increment_fixed() if x[1] else (), pw.update_label(f'Attempting to Relink {x[0]+1}/{len(self.lib.missing_files)} Entries, {self.fixed} Successfully Relinked'))) - # iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1])) + r = CustomRunnable(lambda: iterator.run()) + r.done.connect( + lambda: (pw.hide(), pw.deleteLater(), self.done.emit(), self.reset_fixed()) + ) + QThreadPool.globalInstance().start(r) - r = CustomRunnable(lambda:iterator.run()) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit(), self.reset_fixed())) - QThreadPool.globalInstance().start(r) - - def increment_fixed(self): - self.fixed += 1 - - def reset_fixed(self): - self.fixed = 0 + def increment_fixed(self): + self.fixed += 1 - # def repair_entries_runnable(self, pb: QProgressDialog): - # fixed = 0 - # for i in self.lib.fix_missing_files(): - # if i[1]: - # fixed += 1 - # pb.setValue(i[0]) - # pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked') + def reset_fixed(self): + self.fixed = 0 - # for i, missing in enumerate(self.lib.missing_files): - # pb.setValue(i) - # pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries') - # self.lib.fix_missing_files() - # try: - # id = self.lib.get_entry_id_from_filepath(missing) - # logging.info(f'Removing Entry ID {id}:\n\t{missing}') - # self.lib.remove_entry(id) - # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) - # deleted.append(missing) - # except KeyError: - # logging.info( - # f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') - # for d in deleted: - # self.lib.missing_files.remove(d) + # def repair_entries_runnable(self, pb: QProgressDialog): + # fixed = 0 + # for i in self.lib.fix_missing_files(): + # if i[1]: + # fixed += 1 + # pb.setValue(i[0]) + # pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked') + + # for i, missing in enumerate(self.lib.missing_files): + # pb.setValue(i) + # pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries') + # self.lib.fix_missing_files() + # try: + # id = self.lib.get_entry_id_from_filepath(missing) + # logging.info(f'Removing Entry ID {id}:\n\t{missing}') + # self.lib.remove_entry(id) + # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) + # deleted.append(missing) + # except KeyError: + # logging.info( + # f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') + # for d in deleted: + # self.lib.missing_files.remove(d) diff --git a/tagstudio/src/qt/modals/tag_database.py b/tagstudio/src/qt/modals/tag_database.py index ab5e714..a276a1f 100644 --- a/tagstudio/src/qt/modals/tag_database.py +++ b/tagstudio/src/qt/modals/tag_database.py @@ -3,7 +3,14 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio from PySide6.QtCore import Signal, Qt, QSize -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QScrollArea, QFrame +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLineEdit, + QScrollArea, + QFrame, +) from src.core.library import Library from src.qt.widgets import PanelWidget, PanelModal, TagWidget @@ -11,116 +18,128 @@ from src.qt.modals import BuildTagPanel class TagDatabasePanel(PanelWidget): - tag_chosen = Signal(int) + 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())) + 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.content_container = QWidget() - # self.content_layout = QHBoxLayout(self.content_container) + self.setMinimumSize(300, 400) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) - 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.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.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.content_container = QWidget() + # self.content_layout = QHBoxLayout(self.content_container) - # 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.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.root_layout.addWidget(self.search_field) - self.root_layout.addWidget(self.scroll_area) - self.update_tags('') + 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) - # 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() + # 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) - def update_tags(self, query: str): - # TODO: Look at recycling rather than deleting and reinitializing - while self.scroll_layout.itemAt(0): - self.scroll_layout.takeAt(0).widget().deleteLater() + self.root_layout.addWidget(self.search_field) + self.root_layout.addWidget(self.scroll_area) + self.update_tags("") - # If there is a query, get a list of tag_ids that match, otherwise return all - if query: - tags = self.lib.search_tags(query, include_cluster=True)[:self.tag_limit-1] - else: - # Get tag ids to keep this behaviorally identical - tags = [t.id for t in self.lib.tags] + # def reset(self): + # self.search_field.setText('') + # self.update_tags('') + # self.search_field.setFocus() - first_id_set = False - for tag_id in tags: - if not first_id_set: - self.first_tag_id = tag_id - first_id_set = True - container = QWidget() - row = QHBoxLayout(container) - row.setContentsMargins(0, 0, 0, 0) - row.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))) - row.addWidget(tw) - self.scroll_layout.addWidget(container) + 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() - 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)) - #TODO Check Warning: Expected type 'BuildTagPanel', got 'PanelWidget' instead - self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp)) - 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 update_tags(self, query: str): + # TODO: Look at recycling rather than deleting and reinitializing + while self.scroll_layout.itemAt(0): + self.scroll_layout.takeAt(0).widget().deleteLater() - # def enterEvent(self, event: QEnterEvent) -> None: - # self.search_field.setFocus() - # return super().enterEvent(event) - # self.focusOutEvent + # If there is a query, get a list of tag_ids that match, otherwise return all + if query: + tags = self.lib.search_tags(query, include_cluster=True)[ + : self.tag_limit - 1 + ] + else: + # Get tag ids to keep this behaviorally identical + tags = [t.id for t in self.lib.tags] + + first_id_set = False + for tag_id in tags: + if not first_id_set: + self.first_tag_id = tag_id + first_id_set = True + container = QWidget() + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + row.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)) + ) + row.addWidget(tw) + self.scroll_layout.addWidget(container) + + 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)) + # TODO Check Warning: Expected type 'BuildTagPanel', got 'PanelWidget' instead + self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp)) + 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 diff --git a/tagstudio/src/qt/modals/tag_search.py b/tagstudio/src/qt/modals/tag_search.py index f6d91e2..f462e07 100644 --- a/tagstudio/src/qt/modals/tag_search.py +++ b/tagstudio/src/qt/modals/tag_search.py @@ -7,136 +7,154 @@ import logging import math from PySide6.QtCore import Signal, Qt, QSize -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QScrollArea, QFrame +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLineEdit, + QScrollArea, + QFrame, +) from src.core.library import Library from src.core.palette import ColorType, get_tag_color from src.qt.widgets import PanelWidget, TagWidget -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class TagSearchPanel(PanelWidget): - tag_chosen = Signal(int) - def __init__(self, library): - super().__init__() - self.lib: Library = library - # self.callback = callback - self.first_tag_id = None - 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) + tag_chosen = Signal(int) - 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())) + def __init__(self, library): + super().__init__() + self.lib: Library = library + # self.callback = callback + self.first_tag_id = None + 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.content_container = QWidget() - # self.content_layout = QHBoxLayout(self.content_container) + 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.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.content_container = QWidget() + # self.content_layout = QHBoxLayout(self.content_container) - 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.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.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.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.root_layout.addWidget(self.search_field) - self.root_layout.addWidget(self.scroll_area) + # 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) - # 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 is not None: - # callback(self.first_tag_id) - self.tag_chosen.emit(self.first_tag_id) - self.search_field.setText('') - self.update_tags() - else: - self.search_field.setFocus() - self.parentWidget().hide() + self.root_layout.addWidget(self.search_field) + self.root_layout.addWidget(self.scroll_area) - def update_tags(self, query:str =''): - # for c in self.scroll_layout.children(): - # c.widget().deleteLater() - while self.scroll_layout.count(): - # logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}") - self.scroll_layout.takeAt(0).widget().deleteLater() + # def reset(self): + # self.search_field.setText('') + # self.update_tags('') + # self.search_field.setFocus() - found_tags = self.lib.search_tags(query, include_cluster=True)[:self.tag_limit - 1] - self.first_tag_id = found_tags[0] if found_tags else None + def on_return(self, text: str): + if text and self.first_tag_id is not None: + # callback(self.first_tag_id) + self.tag_chosen.emit(self.first_tag_id) + self.search_field.setText("") + self.update_tags() + else: + self.search_field.setFocus() + self.parentWidget().hide() - for tag_id in found_tags: - c = QWidget() - l = QHBoxLayout(c) - l.setContentsMargins(0, 0, 0, 0) - l.setSpacing(3) - tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, False) - ab = QPushButton() - ab.setMinimumSize(23, 23) - ab.setMaximumSize(23, 23) - ab.setText('+') - ab.setStyleSheet( - f'QPushButton{{' - f'background: {get_tag_color(ColorType.PRIMARY, self.lib.get_tag(tag_id).color)};' - # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' - # f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};" - f"color: {get_tag_color(ColorType.TEXT, self.lib.get_tag(tag_id).color)};" - f'font-weight: 600;' - f"border-color:{get_tag_color(ColorType.BORDER, self.lib.get_tag(tag_id).color)};" - f'border-radius: 6px;' - f'border-style:solid;' - f'border-width: {math.ceil(1*self.devicePixelRatio())}px;' - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' - f'padding-bottom: 5px;' - # f'padding-left: 4px;' - f'font-size: 20px;' - f'}}' - f'QPushButton::hover' - f'{{' - f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};" - f"color: {get_tag_color(ColorType.DARK_ACCENT, self.lib.get_tag(tag_id).color)};" - f'background: {get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};' - f'}}') + def update_tags(self, query: str = ""): + # for c in self.scroll_layout.children(): + # c.widget().deleteLater() + while self.scroll_layout.count(): + # logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}") + self.scroll_layout.takeAt(0).widget().deleteLater() - ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x)) + found_tags = self.lib.search_tags(query, include_cluster=True)[ + : self.tag_limit - 1 + ] + self.first_tag_id = found_tags[0] if found_tags else None - l.addWidget(tw) - l.addWidget(ab) - self.scroll_layout.addWidget(c) + for tag_id in found_tags: + c = QWidget() + l = QHBoxLayout(c) + l.setContentsMargins(0, 0, 0, 0) + l.setSpacing(3) + tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, False) + ab = QPushButton() + ab.setMinimumSize(23, 23) + ab.setMaximumSize(23, 23) + ab.setText("+") + ab.setStyleSheet( + f"QPushButton{{" + f"background: {get_tag_color(ColorType.PRIMARY, self.lib.get_tag(tag_id).color)};" + # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' + # f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};" + f"color: {get_tag_color(ColorType.TEXT, self.lib.get_tag(tag_id).color)};" + f"font-weight: 600;" + f"border-color:{get_tag_color(ColorType.BORDER, self.lib.get_tag(tag_id).color)};" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width: {math.ceil(1*self.devicePixelRatio())}px;" + # f'padding-top: 1.5px;' + # f'padding-right: 4px;' + f"padding-bottom: 5px;" + # f'padding-left: 4px;' + f"font-size: 20px;" + f"}}" + f"QPushButton::hover" + f"{{" + f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};" + f"color: {get_tag_color(ColorType.DARK_ACCENT, self.lib.get_tag(tag_id).color)};" + f"background: {get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};" + f"}}" + ) - self.search_field.setFocus() - - # def enterEvent(self, event: QEnterEvent) -> None: - # self.search_field.setFocus() - # return super().enterEvent(event) - # self.focusOutEvent + ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x)) + + l.addWidget(tw) + l.addWidget(ab) + self.scroll_layout.addWidget(c) + + self.search_field.setFocus() + + # def enterEvent(self, event: QEnterEvent) -> None: + # self.search_field.setFocus() + # return super().enterEvent(event) + # self.focusOutEvent diff --git a/tagstudio/src/qt/pagination.py b/tagstudio/src/qt/pagination.py index 9fd8a8e..bf4a8b5 100644 --- a/tagstudio/src/qt/pagination.py +++ b/tagstudio/src/qt/pagination.py @@ -7,7 +7,14 @@ from PySide6.QtCore import QObject, Signal, QSize from PySide6.QtGui import QIntValidator -from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QLabel, QLineEdit, QSizePolicy +from PySide6.QtWidgets import ( + QWidget, + QHBoxLayout, + QPushButton, + QLabel, + QLineEdit, + QSizePolicy, +) # class NumberEdit(QLineEdit): @@ -15,537 +22,615 @@ from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QLabel, QLineEd # super().__init__(parent) # self.textChanged + class Pagination(QWidget, QObject): - """Widget containing controls for navigating between pages of items.""" - index = Signal(int) - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.page_count: int = 0 - self.current_page_index: int = 0 - self.buffer_page_count: int = 4 - self.button_size = QSize(32, 24) + """Widget containing controls for navigating between pages of items.""" - # ------------ UI EXAMPLE -------------- - # [<] [1]...[3][4] [5] [6][7]...[42] [>] - # ^^^^ <-- 2 Buffer Pages - # Center Page Number is Editable Text - # -------------------------------------- + index = Signal(int) - # [----------- ROOT LAYOUT ------------] - self.setHidden(True) - self.root_layout = QHBoxLayout(self) - self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) - self.root_layout.setContentsMargins(0,6,0,0) - self.root_layout.setSpacing(3) - # self.setMinimumHeight(32) + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.page_count: int = 0 + self.current_page_index: int = 0 + self.buffer_page_count: int = 4 + self.button_size = QSize(32, 24) - # [<] ---------------------------------- - self.prev_button = QPushButton() - self.prev_button.setText('<') - self.prev_button.setMinimumSize(self.button_size) - self.prev_button.setMaximumSize(self.button_size) + # ------------ UI EXAMPLE -------------- + # [<] [1]...[3][4] [5] [6][7]...[42] [>] + # ^^^^ <-- 2 Buffer Pages + # Center Page Number is Editable Text + # -------------------------------------- - # --- [1] ------------------------------ - self.start_button = QPushButton() - self.start_button.setMinimumSize(self.button_size) - self.start_button.setMaximumSize(self.button_size) - # self.start_button.setStyleSheet('background:cyan;') - # self.start_button.setMaximumHeight(self.button_size.height()) + # [----------- ROOT LAYOUT ------------] + self.setHidden(True) + self.root_layout = QHBoxLayout(self) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + self.root_layout.setContentsMargins(0, 6, 0, 0) + self.root_layout.setSpacing(3) + # self.setMinimumHeight(32) - # ------ ... --------------------------- - self.start_ellipses = QLabel() - self.start_ellipses.setMinimumSize(self.button_size) - self.start_ellipses.setMaximumSize(self.button_size) - # self.start_ellipses.setMaximumHeight(self.button_size.height()) - self.start_ellipses.setText('. . .') + # [<] ---------------------------------- + self.prev_button = QPushButton() + self.prev_button.setText("<") + self.prev_button.setMinimumSize(self.button_size) + self.prev_button.setMaximumSize(self.button_size) - # --------- [3][4] --------------------- - self.start_buffer_container = QWidget() - self.start_buffer_layout = QHBoxLayout(self.start_buffer_container) - self.start_buffer_layout.setContentsMargins(0,0,0,0) - self.start_buffer_layout.setSpacing(3) - # self.start_buffer_container.setStyleSheet('background:blue;') + # --- [1] ------------------------------ + self.start_button = QPushButton() + self.start_button.setMinimumSize(self.button_size) + self.start_button.setMaximumSize(self.button_size) + # self.start_button.setStyleSheet('background:cyan;') + # self.start_button.setMaximumHeight(self.button_size.height()) - # ---------------- [5] ----------------- - self.current_page_field = QLineEdit() - self.current_page_field.setMinimumSize(self.button_size) - self.current_page_field.setMaximumSize(self.button_size) - self.validator = Validator(1, self.page_count) - self.current_page_field.setValidator(self.validator) - self.current_page_field.returnPressed.connect(lambda: self._goto_page(int(self.current_page_field.text())-1)) - # self.current_page_field.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) - # self.current_page_field.setMaximumHeight(self.button_size.height()) - # self.current_page_field.setMaximumWidth(self.button_size.width()) + # ------ ... --------------------------- + self.start_ellipses = QLabel() + self.start_ellipses.setMinimumSize(self.button_size) + self.start_ellipses.setMaximumSize(self.button_size) + # self.start_ellipses.setMaximumHeight(self.button_size.height()) + self.start_ellipses.setText(". . .") - # -------------------- [6][7] ---------- - self.end_buffer_container = QWidget() - self.end_buffer_layout = QHBoxLayout(self.end_buffer_container) - self.end_buffer_layout.setContentsMargins(0,0,0,0) - self.end_buffer_layout.setSpacing(3) - # self.end_buffer_container.setStyleSheet('background:orange;') + # --------- [3][4] --------------------- + self.start_buffer_container = QWidget() + self.start_buffer_layout = QHBoxLayout(self.start_buffer_container) + self.start_buffer_layout.setContentsMargins(0, 0, 0, 0) + self.start_buffer_layout.setSpacing(3) + # self.start_buffer_container.setStyleSheet('background:blue;') - # -------------------------- ... ------- - self.end_ellipses = QLabel() - self.end_ellipses.setMinimumSize(self.button_size) - self.end_ellipses.setMaximumSize(self.button_size) - # self.end_ellipses.setMaximumHeight(self.button_size.height()) - self.end_ellipses.setText('. . .') + # ---------------- [5] ----------------- + self.current_page_field = QLineEdit() + self.current_page_field.setMinimumSize(self.button_size) + self.current_page_field.setMaximumSize(self.button_size) + self.validator = Validator(1, self.page_count) + self.current_page_field.setValidator(self.validator) + self.current_page_field.returnPressed.connect( + lambda: self._goto_page(int(self.current_page_field.text()) - 1) + ) + # self.current_page_field.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + # self.current_page_field.setMaximumHeight(self.button_size.height()) + # self.current_page_field.setMaximumWidth(self.button_size.width()) - # ----------------------------- [42] --- - self.end_button = QPushButton() - self.end_button.setMinimumSize(self.button_size) - self.end_button.setMaximumSize(self.button_size) - # self.end_button.setMaximumHeight(self.button_size.height()) - # self.end_button.setStyleSheet('background:red;') + # -------------------- [6][7] ---------- + self.end_buffer_container = QWidget() + self.end_buffer_layout = QHBoxLayout(self.end_buffer_container) + self.end_buffer_layout.setContentsMargins(0, 0, 0, 0) + self.end_buffer_layout.setSpacing(3) + # self.end_buffer_container.setStyleSheet('background:orange;') - # ---------------------------------- [>] - self.next_button = QPushButton() - self.next_button.setText('>') - self.next_button.setMinimumSize(self.button_size) - self.next_button.setMaximumSize(self.button_size) - - # Add Widgets to Root Layout - self.root_layout.addStretch(1) - self.root_layout.addWidget(self.prev_button) - self.root_layout.addWidget(self.start_button) - self.root_layout.addWidget(self.start_ellipses) - self.root_layout.addWidget(self.start_buffer_container) - self.root_layout.addWidget(self.current_page_field) - self.root_layout.addWidget(self.end_buffer_container) - self.root_layout.addWidget(self.end_ellipses) - self.root_layout.addWidget(self.end_button) - self.root_layout.addWidget(self.next_button) - self.root_layout.addStretch(1) + # -------------------------- ... ------- + self.end_ellipses = QLabel() + self.end_ellipses.setMinimumSize(self.button_size) + self.end_ellipses.setMaximumSize(self.button_size) + # self.end_ellipses.setMaximumHeight(self.button_size.height()) + self.end_ellipses.setText(". . .") - self._populate_buffer_buttons() - # self.update_buttons(page_count=9, index=0) - + # ----------------------------- [42] --- + self.end_button = QPushButton() + self.end_button.setMinimumSize(self.button_size) + self.end_button.setMaximumSize(self.button_size) + # self.end_button.setMaximumHeight(self.button_size.height()) + # self.end_button.setStyleSheet('background:red;') - def update_buttons(self, page_count:int, index:int, emit:bool=True): - - # Screw it - for i in range(0, 10): - if self.start_buffer_layout.itemAt(i): - self.start_buffer_layout.itemAt(i).widget().setHidden(True) - if self.end_buffer_layout.itemAt(i): - self.end_buffer_layout.itemAt(i).widget().setHidden(True) + # ---------------------------------- [>] + self.next_button = QPushButton() + self.next_button.setText(">") + self.next_button.setMinimumSize(self.button_size) + self.next_button.setMaximumSize(self.button_size) - if page_count <= 1: - # Hide everything if there are only one or less pages. - # [-------------- HIDDEN --------------] - self.setHidden(True) - # elif page_count > 1 and page_count < 7: - # # Only show Next/Prev, current index field, and both start and end - # # buffers (the end may be odd). - # # [<] [1][2][3][4][5][6] [>] - # self.start_button.setHidden(True) - # self.start_ellipses.setHidden(True) - # self.end_ellipses.setHidden(True) - # self.end_button.setHidden(True) - # elif page_count > 1: - # self.start_button.setHidden(False) - # self.start_ellipses.setHidden(False) - # self.end_ellipses.setHidden(False) - # self.end_button.setHidden(False) + # Add Widgets to Root Layout + self.root_layout.addStretch(1) + self.root_layout.addWidget(self.prev_button) + self.root_layout.addWidget(self.start_button) + self.root_layout.addWidget(self.start_ellipses) + self.root_layout.addWidget(self.start_buffer_container) + self.root_layout.addWidget(self.current_page_field) + self.root_layout.addWidget(self.end_buffer_container) + self.root_layout.addWidget(self.end_ellipses) + self.root_layout.addWidget(self.end_button) + self.root_layout.addWidget(self.next_button) + self.root_layout.addStretch(1) - # self.start_button.setText('1') - # self.assign_click(self.start_button, 0) - # self.end_button.setText(str(page_count)) - # self.assign_click(self.end_button, page_count-1) - - elif page_count > 1: + self._populate_buffer_buttons() + # self.update_buttons(page_count=9, index=0) - # Enable/Disable Next+Prev Buttons - if index == 0: - self.prev_button.setDisabled(True) - # self.start_buffer_layout.setContentsMargins(0,0,0,0) - else: - # self.start_buffer_layout.setContentsMargins(3,0,3,0) - self._assign_click(self.prev_button, index-1) - self.prev_button.setDisabled(False) - if index == page_count-1: - self.next_button.setDisabled(True) - # self.end_buffer_layout.setContentsMargins(0,0,0,0) - else: - # self.end_buffer_layout.setContentsMargins(3,0,3,0) - self._assign_click(self.next_button, index+1) - self.next_button.setDisabled(False) - - # Set Ellipses Sizes - if page_count == 8: - if index == 0: - self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.end_ellipses.setMinimumWidth(self.button_size.width()) - self.end_ellipses.setMaximumWidth(self.button_size.width()) - if index == page_count-1: - self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.start_ellipses.setMinimumWidth(self.button_size.width()) - self.start_ellipses.setMaximumWidth(self.button_size.width()) - elif page_count == 9: - if index == 0: - self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == 1: - self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.end_ellipses.setMinimumWidth(self.button_size.width()) - self.end_ellipses.setMaximumWidth(self.button_size.width()) - if index == page_count-1: - self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == page_count-2: - self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.start_ellipses.setMinimumWidth(self.button_size.width()) - self.start_ellipses.setMaximumWidth(self.button_size.width()) - elif page_count == 10: - if index == 0: - self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9) - self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9) - elif index == 1: - self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == 2: - self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.end_ellipses.setMinimumWidth(self.button_size.width()) - self.end_ellipses.setMaximumWidth(self.button_size.width()) - if index == page_count-1: - self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9) - self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9) - elif index == page_count-2: - self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == page_count-3: - self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.start_ellipses.setMinimumWidth(self.button_size.width()) - self.start_ellipses.setMaximumWidth(self.button_size.width()) - elif page_count == 11: - if index == 0: - self.end_ellipses.setMinimumWidth(self.button_size.width()*5 + 12) - self.end_ellipses.setMaximumWidth(self.button_size.width()*5 + 12) - elif index == 1: - self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9) - self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9) - elif index == 2: - self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == 3: - self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.end_ellipses.setMinimumWidth(self.button_size.width()) - self.end_ellipses.setMaximumWidth(self.button_size.width()) - if index == page_count-1: - self.start_ellipses.setMinimumWidth(self.button_size.width()*5 + 12) - self.start_ellipses.setMaximumWidth(self.button_size.width()*5 + 12) - elif index == page_count-2: - self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9) - self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9) - elif index == page_count-3: - self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == page_count-4: - self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.start_ellipses.setMinimumWidth(self.button_size.width()) - self.start_ellipses.setMaximumWidth(self.button_size.width()) - elif page_count > 11: - if index == 0: - self.end_ellipses.setMinimumWidth(self.button_size.width()*7 + 18) - self.end_ellipses.setMaximumWidth(self.button_size.width()*7 + 18) - elif index == 1: - self.end_ellipses.setMinimumWidth(self.button_size.width()*6 + 15) - self.end_ellipses.setMaximumWidth(self.button_size.width()*6 + 15) - elif index == 2: - self.end_ellipses.setMinimumWidth(self.button_size.width()*5 + 12) - self.end_ellipses.setMaximumWidth(self.button_size.width()*5 + 12) - elif index == 3: - self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9) - self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9) - elif index == 4: - self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == 5: - self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.end_ellipses.setMinimumWidth(self.button_size.width()) - self.end_ellipses.setMaximumWidth(self.button_size.width()) - if index == page_count-1: - self.start_ellipses.setMinimumWidth(self.button_size.width()*7 + 18) - self.start_ellipses.setMaximumWidth(self.button_size.width()*7 + 18) - elif index == page_count-2: - self.start_ellipses.setMinimumWidth(self.button_size.width()*6 + 15) - self.start_ellipses.setMaximumWidth(self.button_size.width()*6 + 15) - elif index == page_count-3: - self.start_ellipses.setMinimumWidth(self.button_size.width()*5 + 12) - self.start_ellipses.setMaximumWidth(self.button_size.width()*5 + 12) - elif index == page_count-4: - self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9) - self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9) - elif index == page_count-5: - self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6) - self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6) - elif index == page_count-6: - self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3) - self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3) - else: - self.start_ellipses.setMinimumWidth(self.button_size.width()) - self.start_ellipses.setMaximumWidth(self.button_size.width()) - - # Enable/Disable Ellipses - # if index <= max(self.buffer_page_count, 5)+1: - if index <= self.buffer_page_count+1: - self.start_ellipses.setHidden(True) - # self.start_button.setHidden(True) - else: - self.start_ellipses.setHidden(False) - # self.start_button.setHidden(False) - # self.start_button.setText('1') - self._assign_click(self.start_button, 0) - # if index >=(page_count-max(self.buffer_page_count, 5)-2): - if index >= (page_count-self.buffer_page_count-2): - self.end_ellipses.setHidden(True) - # self.end_button.setHidden(True) - else: - self.end_ellipses.setHidden(False) - # self.end_button.setHidden(False) - # self.end_button.setText(str(page_count)) - # self.assign_click(self.end_button, page_count-1) - - # Hide/Unhide Start+End Buttons - if index != 0: - self.start_button.setText('1') - self._assign_click(self.start_button, 0) - self.start_button.setHidden(False) - # self.start_buffer_layout.setContentsMargins(3,0,0,0) - else: - self.start_button.setHidden(True) - # self.start_buffer_layout.setContentsMargins(0,0,0,0) - if index != page_count-1: - self.end_button.setText(str(page_count)) - self._assign_click(self.end_button, page_count-1) - self.end_button.setHidden(False) - # self.end_buffer_layout.setContentsMargins(0,0,3,0) - else: - self.end_button.setHidden(True) - # self.end_buffer_layout.setContentsMargins(0,0,0,0) + def update_buttons(self, page_count: int, index: int, emit: bool = True): + # Screw it + for i in range(0, 10): + if self.start_buffer_layout.itemAt(i): + self.start_buffer_layout.itemAt(i).widget().setHidden(True) + if self.end_buffer_layout.itemAt(i): + self.end_buffer_layout.itemAt(i).widget().setHidden(True) - if index == 0 or index == 1: - self.start_buffer_container.setHidden(True) - else: - self.start_buffer_container.setHidden(False) - - if index == page_count-1 or index == page_count-2: - self.end_buffer_container.setHidden(True) - else: - self.end_buffer_container.setHidden(False) + if page_count <= 1: + # Hide everything if there are only one or less pages. + # [-------------- HIDDEN --------------] + self.setHidden(True) + # elif page_count > 1 and page_count < 7: + # # Only show Next/Prev, current index field, and both start and end + # # buffers (the end may be odd). + # # [<] [1][2][3][4][5][6] [>] + # self.start_button.setHidden(True) + # self.start_ellipses.setHidden(True) + # self.end_ellipses.setHidden(True) + # self.end_button.setHidden(True) + # elif page_count > 1: + # self.start_button.setHidden(False) + # self.start_ellipses.setHidden(False) + # self.end_ellipses.setHidden(False) + # self.end_button.setHidden(False) - # for i in range(0, self.buffer_page_count): - # self.start_buffer_layout.itemAt(i).widget().setHidden(True) + # self.start_button.setText('1') + # self.assign_click(self.start_button, 0) + # self.end_button.setText(str(page_count)) + # self.assign_click(self.end_button, page_count-1) - # Current Field and Buffer Pages - sbc = 0 - # for i in range(0, max(self.buffer_page_count*2, 11)): - for i in range(0, page_count): - # for j in range(0, self.buffer_page_count+1): - # self.start_buffer_layout.itemAt(j).widget().setHidden(True) - # if i == 1: - # self.start_buffer_layout.itemAt(i).widget().setHidden(True) - # elif i == page_count-2: - # self.end_buffer_layout.itemAt(i).widget().setHidden(True) - - # Set Field - if i == index: - # print(f'Current Index: {i}') - if self.start_buffer_layout.itemAt(i): - self.start_buffer_layout.itemAt(i).widget().setHidden(True) - if self.end_buffer_layout.itemAt(i): - self.end_buffer_layout.itemAt(i).widget().setHidden(True) - sbc += 1 - self.current_page_field.setText((str(i+1))) - # elif index == page_count-1: - # self.start_button.setText(str(page_count)) + elif page_count > 1: + # Enable/Disable Next+Prev Buttons + if index == 0: + self.prev_button.setDisabled(True) + # self.start_buffer_layout.setContentsMargins(0,0,0,0) + else: + # self.start_buffer_layout.setContentsMargins(3,0,3,0) + self._assign_click(self.prev_button, index - 1) + self.prev_button.setDisabled(False) + if index == page_count - 1: + self.next_button.setDisabled(True) + # self.end_buffer_layout.setContentsMargins(0,0,0,0) + else: + # self.end_buffer_layout.setContentsMargins(3,0,3,0) + self._assign_click(self.next_button, index + 1) + self.next_button.setDisabled(False) - start_offset = max(0, (index-4)-4) - end_offset = min(page_count-1, (index+4)-4) - if i < index: - # if i != 0 and ((i-self.buffer_page_count) >= 0 or i <= self.buffer_page_count): - if (i != 0) and i >= index-4: - # print(f' Start i: {i}') - # print(f'Start Offset: {start_offset}') - # print(f' Requested i: {i-start_offset}') - # print(f'Setting Text "{str(i+1)}" for Local Start i:{i-start_offset}, Global i:{i}') - self.start_buffer_layout.itemAt(i-start_offset).widget().setHidden(False) - self.start_buffer_layout.itemAt(i-start_offset).widget().setText(str(i+1)) - self._assign_click(self.start_buffer_layout.itemAt(i-start_offset).widget(), i) - sbc += 1 - else: - if self.start_buffer_layout.itemAt(i): - # print(f'Removing S-Start {i}') - self.start_buffer_layout.itemAt(i).widget().setHidden(True) - if self.end_buffer_layout.itemAt(i): - # print(f'Removing S-End {i}') - self.end_buffer_layout.itemAt(i).widget().setHidden(True) - elif i > index: - # if i != page_count-1: - if i != page_count-1 and i <= index+4: - # print(f'End Buffer: {i}') - # print(f' End i: {i}') - # print(f' End Offset: {end_offset}') - # print(f'Requested i: {i-end_offset}') - # print(f'Requested i: {end_offset-sbc-i}') - # if self.start_buffer_layout.itemAt(i): - # self.start_buffer_layout.itemAt(i).widget().setHidden(True) - # print(f'Setting Text "{str(i+1)}" for Local End i:{i-end_offset}, Global i:{i}') - self.end_buffer_layout.itemAt(i-end_offset).widget().setHidden(False) - self.end_buffer_layout.itemAt(i-end_offset).widget().setText(str(i+1)) - self._assign_click(self.end_buffer_layout.itemAt(i-end_offset).widget(), i) - else: - # if self.start_buffer_layout.itemAt(i-1): - # print(f'Removing E-Start {i-1}') - # self.start_buffer_layout.itemAt(i-1).widget().setHidden(True) - # if self.start_buffer_layout.itemAt(i-start_offset): - # print(f'Removing E-Start Offset {i-end_offset}') - # self.start_buffer_layout.itemAt(i-end_offset).widget().setHidden(True) - - if self.end_buffer_layout.itemAt(i): - # print(f'Removing E-End {i}') - self.end_buffer_layout.itemAt(i).widget().setHidden(True) - for j in range(0,self.buffer_page_count): - if self.end_buffer_layout.itemAt(i-end_offset+j): - # print(f'Removing E-End-Offset {i-end_offset+j}') - self.end_buffer_layout.itemAt(i-end_offset+j).widget().setHidden(True) + # Set Ellipses Sizes + if page_count == 8: + if index == 0: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 2 + 3) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 2 + 3) + else: + self.end_ellipses.setMinimumWidth(self.button_size.width()) + self.end_ellipses.setMaximumWidth(self.button_size.width()) + if index == page_count - 1: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 2 + 3 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 2 + 3 + ) + else: + self.start_ellipses.setMinimumWidth(self.button_size.width()) + self.start_ellipses.setMaximumWidth(self.button_size.width()) + elif page_count == 9: + if index == 0: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 3 + 6) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 3 + 6) + elif index == 1: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 2 + 3) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 2 + 3) + else: + self.end_ellipses.setMinimumWidth(self.button_size.width()) + self.end_ellipses.setMaximumWidth(self.button_size.width()) + if index == page_count - 1: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 3 + 6 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 3 + 6 + ) + elif index == page_count - 2: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 2 + 3 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 2 + 3 + ) + else: + self.start_ellipses.setMinimumWidth(self.button_size.width()) + self.start_ellipses.setMaximumWidth(self.button_size.width()) + elif page_count == 10: + if index == 0: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 4 + 9) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 4 + 9) + elif index == 1: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 3 + 6) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 3 + 6) + elif index == 2: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 2 + 3) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 2 + 3) + else: + self.end_ellipses.setMinimumWidth(self.button_size.width()) + self.end_ellipses.setMaximumWidth(self.button_size.width()) + if index == page_count - 1: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 4 + 9 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 4 + 9 + ) + elif index == page_count - 2: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 3 + 6 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 3 + 6 + ) + elif index == page_count - 3: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 2 + 3 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 2 + 3 + ) + else: + self.start_ellipses.setMinimumWidth(self.button_size.width()) + self.start_ellipses.setMaximumWidth(self.button_size.width()) + elif page_count == 11: + if index == 0: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 5 + 12) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 5 + 12) + elif index == 1: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 4 + 9) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 4 + 9) + elif index == 2: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 3 + 6) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 3 + 6) + elif index == 3: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 2 + 3) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 2 + 3) + else: + self.end_ellipses.setMinimumWidth(self.button_size.width()) + self.end_ellipses.setMaximumWidth(self.button_size.width()) + if index == page_count - 1: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 5 + 12 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 5 + 12 + ) + elif index == page_count - 2: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 4 + 9 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 4 + 9 + ) + elif index == page_count - 3: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 3 + 6 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 3 + 6 + ) + elif index == page_count - 4: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 2 + 3 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 2 + 3 + ) + else: + self.start_ellipses.setMinimumWidth(self.button_size.width()) + self.start_ellipses.setMaximumWidth(self.button_size.width()) + elif page_count > 11: + if index == 0: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 7 + 18) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 7 + 18) + elif index == 1: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 6 + 15) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 6 + 15) + elif index == 2: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 5 + 12) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 5 + 12) + elif index == 3: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 4 + 9) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 4 + 9) + elif index == 4: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 3 + 6) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 3 + 6) + elif index == 5: + self.end_ellipses.setMinimumWidth(self.button_size.width() * 2 + 3) + self.end_ellipses.setMaximumWidth(self.button_size.width() * 2 + 3) + else: + self.end_ellipses.setMinimumWidth(self.button_size.width()) + self.end_ellipses.setMaximumWidth(self.button_size.width()) + if index == page_count - 1: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 7 + 18 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 7 + 18 + ) + elif index == page_count - 2: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 6 + 15 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 6 + 15 + ) + elif index == page_count - 3: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 5 + 12 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 5 + 12 + ) + elif index == page_count - 4: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 4 + 9 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 4 + 9 + ) + elif index == page_count - 5: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 3 + 6 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 3 + 6 + ) + elif index == page_count - 6: + self.start_ellipses.setMinimumWidth( + self.button_size.width() * 2 + 3 + ) + self.start_ellipses.setMaximumWidth( + self.button_size.width() * 2 + 3 + ) + else: + self.start_ellipses.setMinimumWidth(self.button_size.width()) + self.start_ellipses.setMaximumWidth(self.button_size.width()) - # if self.end_buffer_layout.itemAt(i+1): - # print(f'Removing T-End {i+1}') - # self.end_buffer_layout.itemAt(i+1).widget().setHidden(True) - - if self.start_buffer_layout.itemAt(i-1): - # print(f'Removing T-Start {i-1}') - self.start_buffer_layout.itemAt(i-1).widget().setHidden(True) - + # Enable/Disable Ellipses + # if index <= max(self.buffer_page_count, 5)+1: + if index <= self.buffer_page_count + 1: + self.start_ellipses.setHidden(True) + # self.start_button.setHidden(True) + else: + self.start_ellipses.setHidden(False) + # self.start_button.setHidden(False) + # self.start_button.setText('1') + self._assign_click(self.start_button, 0) + # if index >=(page_count-max(self.buffer_page_count, 5)-2): + if index >= (page_count - self.buffer_page_count - 2): + self.end_ellipses.setHidden(True) + # self.end_button.setHidden(True) + else: + self.end_ellipses.setHidden(False) + # self.end_button.setHidden(False) + # self.end_button.setText(str(page_count)) + # self.assign_click(self.end_button, page_count-1) - # if index == 0 or index == 1: - # print(f'Removing Start i: {i}') - # if self.start_buffer_layout.itemAt(i): - # self.start_buffer_layout.itemAt(i).widget().setHidden(True) - - # elif index == page_count-1 or index == page_count-2 or index == page_count-3 or index == page_count-4: - # print(f' Removing End i: {i}') - # if self.end_buffer_layout.itemAt(i): - # self.end_buffer_layout.itemAt(i).widget().setHidden(True) - + # Hide/Unhide Start+End Buttons + if index != 0: + self.start_button.setText("1") + self._assign_click(self.start_button, 0) + self.start_button.setHidden(False) + # self.start_buffer_layout.setContentsMargins(3,0,0,0) + else: + self.start_button.setHidden(True) + # self.start_buffer_layout.setContentsMargins(0,0,0,0) + if index != page_count - 1: + self.end_button.setText(str(page_count)) + self._assign_click(self.end_button, page_count - 1) + self.end_button.setHidden(False) + # self.end_buffer_layout.setContentsMargins(0,0,3,0) + else: + self.end_button.setHidden(True) + # self.end_buffer_layout.setContentsMargins(0,0,0,0) + if index == 0 or index == 1: + self.start_buffer_container.setHidden(True) + else: + self.start_buffer_container.setHidden(False) - # else: - # print(f'Truncate: {i}') - # if self.start_buffer_layout.itemAt(i): - # self.start_buffer_layout.itemAt(i).widget().setHidden(True) - # if self.end_buffer_layout.itemAt(i): - # self.end_buffer_layout.itemAt(i).widget().setHidden(True) - - # if i < self.buffer_page_count: - # print(f'start {i}') - # if i == 0: - # self.start_buffer_layout.itemAt(i).widget().setHidden(True) - # self.current_page_field.setText((str(i+1))) - # else: - # self.start_buffer_layout.itemAt(i).widget().setHidden(False) - # self.start_buffer_layout.itemAt(i).widget().setText(str(i+1)) - # elif i >= self.buffer_page_count and i < count: - # print(f'end {i}') - # self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(False) - # self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setText(str(i+1)) - # else: - # self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(True) + if index == page_count - 1 or index == page_count - 2: + self.end_buffer_container.setHidden(True) + else: + self.end_buffer_container.setHidden(False) - self.setHidden(False) - # elif page_count >= 7: - # # Show everything, except truncate the buffers as needed. - # # [<] [1]...[3] [4] [5]...[7] [>] - # self.start_button.setHidden(False) - # self.start_ellipses.setHidden(False) - # self.end_ellipses.setHidden(False) - # self.end_button.setHidden(False) + # for i in range(0, self.buffer_page_count): + # self.start_buffer_layout.itemAt(i).widget().setHidden(True) - # if index == 0: - # self.prev_button.setDisabled(True) - # self.start_buffer_layout.setContentsMargins(0,0,3,0) - # else: - # self.start_buffer_layout.setContentsMargins(3,0,3,0) - # self.assign_click(self.prev_button, index-1) - # self.prev_button.setDisabled(False) - - # if index == page_count-1: - # self.next_button.setDisabled(True) - # self.end_buffer_layout.setContentsMargins(3,0,0,0) - # else: - # self.end_buffer_layout.setContentsMargins(3,0,3,0) - # self.assign_click(self.next_button, index+1) - # self.next_button.setDisabled(False) + # Current Field and Buffer Pages + sbc = 0 + # for i in range(0, max(self.buffer_page_count*2, 11)): + for i in range(0, page_count): + # for j in range(0, self.buffer_page_count+1): + # self.start_buffer_layout.itemAt(j).widget().setHidden(True) + # if i == 1: + # self.start_buffer_layout.itemAt(i).widget().setHidden(True) + # elif i == page_count-2: + # self.end_buffer_layout.itemAt(i).widget().setHidden(True) - # self.start_button.setText('1') - # self.assign_click(self.start_button, 0) - # self.end_button.setText(str(page_count)) - # self.assign_click(self.end_button, page_count-1) + # Set Field + if i == index: + # print(f'Current Index: {i}') + if self.start_buffer_layout.itemAt(i): + self.start_buffer_layout.itemAt(i).widget().setHidden(True) + if self.end_buffer_layout.itemAt(i): + self.end_buffer_layout.itemAt(i).widget().setHidden(True) + sbc += 1 + self.current_page_field.setText((str(i + 1))) + # elif index == page_count-1: + # self.start_button.setText(str(page_count)) - # self.setHidden(False) + start_offset = max(0, (index - 4) - 4) + end_offset = min(page_count - 1, (index + 4) - 4) + if i < index: + # if i != 0 and ((i-self.buffer_page_count) >= 0 or i <= self.buffer_page_count): + if (i != 0) and i >= index - 4: + # print(f' Start i: {i}') + # print(f'Start Offset: {start_offset}') + # print(f' Requested i: {i-start_offset}') + # print(f'Setting Text "{str(i+1)}" for Local Start i:{i-start_offset}, Global i:{i}') + self.start_buffer_layout.itemAt( + i - start_offset + ).widget().setHidden(False) + self.start_buffer_layout.itemAt( + i - start_offset + ).widget().setText(str(i + 1)) + self._assign_click( + self.start_buffer_layout.itemAt(i - start_offset).widget(), + i, + ) + sbc += 1 + else: + if self.start_buffer_layout.itemAt(i): + # print(f'Removing S-Start {i}') + self.start_buffer_layout.itemAt(i).widget().setHidden(True) + if self.end_buffer_layout.itemAt(i): + # print(f'Removing S-End {i}') + self.end_buffer_layout.itemAt(i).widget().setHidden(True) + elif i > index: + # if i != page_count-1: + if i != page_count - 1 and i <= index + 4: + # print(f'End Buffer: {i}') + # print(f' End i: {i}') + # print(f' End Offset: {end_offset}') + # print(f'Requested i: {i-end_offset}') + # print(f'Requested i: {end_offset-sbc-i}') + # if self.start_buffer_layout.itemAt(i): + # self.start_buffer_layout.itemAt(i).widget().setHidden(True) + # print(f'Setting Text "{str(i+1)}" for Local End i:{i-end_offset}, Global i:{i}') + self.end_buffer_layout.itemAt( + i - end_offset + ).widget().setHidden(False) + self.end_buffer_layout.itemAt(i - end_offset).widget().setText( + str(i + 1) + ) + self._assign_click( + self.end_buffer_layout.itemAt(i - end_offset).widget(), i + ) + else: + # if self.start_buffer_layout.itemAt(i-1): + # print(f'Removing E-Start {i-1}') + # self.start_buffer_layout.itemAt(i-1).widget().setHidden(True) + # if self.start_buffer_layout.itemAt(i-start_offset): + # print(f'Removing E-Start Offset {i-end_offset}') + # self.start_buffer_layout.itemAt(i-end_offset).widget().setHidden(True) - self.validator.setTop(page_count) - # if self.current_page_index != index: - if emit: - print(f'[PAGINATION] Emitting {index}') - self.index.emit(index) - self.current_page_index = index - self.page_count = page_count - - - def _goto_page(self, index:int): - # print(f'GOTO PAGE: {index}') - self.update_buttons(self.page_count, index) + if self.end_buffer_layout.itemAt(i): + # print(f'Removing E-End {i}') + self.end_buffer_layout.itemAt(i).widget().setHidden(True) + for j in range(0, self.buffer_page_count): + if self.end_buffer_layout.itemAt(i - end_offset + j): + # print(f'Removing E-End-Offset {i-end_offset+j}') + self.end_buffer_layout.itemAt( + i - end_offset + j + ).widget().setHidden(True) + + # if self.end_buffer_layout.itemAt(i+1): + # print(f'Removing T-End {i+1}') + # self.end_buffer_layout.itemAt(i+1).widget().setHidden(True) + + if self.start_buffer_layout.itemAt(i - 1): + # print(f'Removing T-Start {i-1}') + self.start_buffer_layout.itemAt(i - 1).widget().setHidden(True) + + # if index == 0 or index == 1: + # print(f'Removing Start i: {i}') + # if self.start_buffer_layout.itemAt(i): + # self.start_buffer_layout.itemAt(i).widget().setHidden(True) + + # elif index == page_count-1 or index == page_count-2 or index == page_count-3 or index == page_count-4: + # print(f' Removing End i: {i}') + # if self.end_buffer_layout.itemAt(i): + # self.end_buffer_layout.itemAt(i).widget().setHidden(True) + + # else: + # print(f'Truncate: {i}') + # if self.start_buffer_layout.itemAt(i): + # self.start_buffer_layout.itemAt(i).widget().setHidden(True) + # if self.end_buffer_layout.itemAt(i): + # self.end_buffer_layout.itemAt(i).widget().setHidden(True) + + # if i < self.buffer_page_count: + # print(f'start {i}') + # if i == 0: + # self.start_buffer_layout.itemAt(i).widget().setHidden(True) + # self.current_page_field.setText((str(i+1))) + # else: + # self.start_buffer_layout.itemAt(i).widget().setHidden(False) + # self.start_buffer_layout.itemAt(i).widget().setText(str(i+1)) + # elif i >= self.buffer_page_count and i < count: + # print(f'end {i}') + # self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(False) + # self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setText(str(i+1)) + # else: + # self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(True) + + self.setHidden(False) + # elif page_count >= 7: + # # Show everything, except truncate the buffers as needed. + # # [<] [1]...[3] [4] [5]...[7] [>] + # self.start_button.setHidden(False) + # self.start_ellipses.setHidden(False) + # self.end_ellipses.setHidden(False) + # self.end_button.setHidden(False) + + # if index == 0: + # self.prev_button.setDisabled(True) + # self.start_buffer_layout.setContentsMargins(0,0,3,0) + # else: + # self.start_buffer_layout.setContentsMargins(3,0,3,0) + # self.assign_click(self.prev_button, index-1) + # self.prev_button.setDisabled(False) + + # if index == page_count-1: + # self.next_button.setDisabled(True) + # self.end_buffer_layout.setContentsMargins(3,0,0,0) + # else: + # self.end_buffer_layout.setContentsMargins(3,0,3,0) + # self.assign_click(self.next_button, index+1) + # self.next_button.setDisabled(False) + + # self.start_button.setText('1') + # self.assign_click(self.start_button, 0) + # self.end_button.setText(str(page_count)) + # self.assign_click(self.end_button, page_count-1) + + # self.setHidden(False) + + self.validator.setTop(page_count) + # if self.current_page_index != index: + if emit: + print(f"[PAGINATION] Emitting {index}") + self.index.emit(index) + self.current_page_index = index + self.page_count = page_count + + def _goto_page(self, index: int): + # print(f'GOTO PAGE: {index}') + self.update_buttons(self.page_count, index) + + def _assign_click(self, button: QPushButton, index): + try: + button.clicked.disconnect() + except RuntimeError: + pass + button.clicked.connect(lambda checked=False, i=index: self._goto_page(i)) + + def _populate_buffer_buttons(self): + for i in range(max(self.buffer_page_count * 2, 5)): + button = QPushButton() + button.setMinimumSize(self.button_size) + button.setMaximumSize(self.button_size) + button.setHidden(True) + # button.setMaximumHeight(self.button_size.height()) + self.start_buffer_layout.addWidget(button) + + for i in range(max(self.buffer_page_count * 2, 5)): + button = QPushButton() + button.setMinimumSize(self.button_size) + button.setMaximumSize(self.button_size) + button.setHidden(True) + # button.setMaximumHeight(self.button_size.height()) + self.end_buffer_layout.addWidget(button) - def _assign_click(self, button:QPushButton, index): - try: - button.clicked.disconnect() - except RuntimeError: - pass - button.clicked.connect(lambda checked=False, i=index: self._goto_page(i)) - - def _populate_buffer_buttons(self): - for i in range(max(self.buffer_page_count*2, 5)): - button = QPushButton() - button.setMinimumSize(self.button_size) - button.setMaximumSize(self.button_size) - button.setHidden(True) - # button.setMaximumHeight(self.button_size.height()) - self.start_buffer_layout.addWidget(button) - - for i in range(max(self.buffer_page_count*2, 5)): - button = QPushButton() - button.setMinimumSize(self.button_size) - button.setMaximumSize(self.button_size) - button.setHidden(True) - # button.setMaximumHeight(self.button_size.height()) - self.end_buffer_layout.addWidget(button) class Validator(QIntValidator): - def __init__(self, bottom: int, top: int, parent=None) -> None: - super().__init__(bottom, top, parent) - - def fixup(self, input: str) -> str: - # print(input) - input = input.strip('0') - print(input) - return super().fixup(str(self.top()) if input else '1') + def __init__(self, bottom: int, top: int, parent=None) -> None: + super().__init__(bottom, top, parent) + + def fixup(self, input: str) -> str: + # print(input) + input = input.strip("0") + print(input) + return super().fixup(str(self.top()) if input else "1") diff --git a/tagstudio/src/qt/resources.py b/tagstudio/src/qt/resources.py index 7640f18..b93adce 100644 --- a/tagstudio/src/qt/resources.py +++ b/tagstudio/src/qt/resources.py @@ -15419,10 +15419,17 @@ qt_resource_struct = b"\ \x00\x00\x01\x8a\xd2\x83?\x9d\ " + def qInitResources(): - QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + def qCleanupResources(): - QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + qInitResources() diff --git a/tagstudio/src/qt/resources_rc.py b/tagstudio/src/qt/resources_rc.py index aca38cd..97845b9 100644 --- a/tagstudio/src/qt/resources_rc.py +++ b/tagstudio/src/qt/resources_rc.py @@ -16901,10 +16901,17 @@ qt_resource_struct = b"\ \x00\x00\x01\x8a\xd2\x83?\x9d\ " + def qInitResources(): - QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + def qCleanupResources(): - QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + qInitResources() diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 88a01e4..18736fd 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -21,38 +21,87 @@ from typing import Optional from PIL import Image from PySide6 import QtCore from PySide6.QtCore import QObject, QThread, Signal, Qt, QThreadPool, QTimer, QSettings -from PySide6.QtGui import (QGuiApplication, QPixmap, QMouseEvent, QColor, QAction, - QFontDatabase, QIcon) +from PySide6.QtGui import ( + QGuiApplication, + QPixmap, + QMouseEvent, + QColor, + QAction, + QFontDatabase, + QIcon, +) from PySide6.QtUiTools import QUiLoader -from PySide6.QtWidgets import (QApplication, QWidget, QHBoxLayout, QPushButton, QLineEdit, QScrollArea, QFileDialog, - QSplashScreen, QMenu) +from PySide6.QtWidgets import ( + QApplication, + QWidget, + QHBoxLayout, + QPushButton, + QLineEdit, + QScrollArea, + QFileDialog, + QSplashScreen, + QMenu, +) from humanfriendly import format_timespan from src.core.library import ItemType -from src.core.ts_core import (PLAINTEXT_TYPES, TagStudioCore, TAG_COLORS, DATE_FIELDS, TEXT_FIELDS, BOX_FIELDS, ALL_FILE_TYPES, - SHORTCUT_TYPES, PROGRAM_TYPES, ARCHIVE_TYPES, PRESENTATION_TYPES, - SPREADSHEET_TYPES, DOC_TYPES, AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES, - LIBRARY_FILENAME, COLLAGE_FOLDER_NAME, BACKUP_FOLDER_NAME, TS_FOLDER_NAME, - VERSION_BRANCH, VERSION) +from src.core.ts_core import ( + PLAINTEXT_TYPES, + TagStudioCore, + TAG_COLORS, + DATE_FIELDS, + TEXT_FIELDS, + BOX_FIELDS, + ALL_FILE_TYPES, + SHORTCUT_TYPES, + PROGRAM_TYPES, + ARCHIVE_TYPES, + PRESENTATION_TYPES, + SPREADSHEET_TYPES, + DOC_TYPES, + AUDIO_TYPES, + VIDEO_TYPES, + IMAGE_TYPES, + LIBRARY_FILENAME, + COLLAGE_FOLDER_NAME, + BACKUP_FOLDER_NAME, + TS_FOLDER_NAME, + VERSION_BRANCH, + VERSION, +) from src.core.utils.web import strip_web_protocol from src.qt.flowlayout import FlowLayout from src.qt.main_window import Ui_MainWindow from src.qt.helpers import FunctionIterator, CustomRunnable -from src.qt.widgets import CollageIconRenderer, ThumbRenderer, PanelModal, ProgressWidget, PreviewPanel, ItemThumb -from src.qt.modals import (BuildTagPanel, TagDatabasePanel, FileExtensionModal, FixUnlinkedEntriesModal, - FixDupeFilesModal, FoldersToTagsModal) +from src.qt.widgets import ( + CollageIconRenderer, + ThumbRenderer, + PanelModal, + ProgressWidget, + PreviewPanel, + ItemThumb, +) +from src.qt.modals import ( + BuildTagPanel, + TagDatabasePanel, + FileExtensionModal, + FixUnlinkedEntriesModal, + FixDupeFilesModal, + FoldersToTagsModal, +) import src.qt.resources_rc # SIGQUIT is not defined on Windows if sys.platform == "win32": - from signal import signal, SIGINT, SIGTERM - SIGQUIT = SIGTERM -else: - from signal import signal, SIGINT, SIGTERM, SIGQUIT + from signal import signal, SIGINT, SIGTERM -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' + SIGQUIT = SIGTERM +else: + from signal import signal, SIGINT, SIGTERM, SIGQUIT + +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) @@ -60,1124 +109,1336 @@ logging.basicConfig(format="%(message)s", level=logging.INFO) QSettings.setPath(QSettings.IniFormat, QSettings.UserScope, os.getcwd()) -class NavigationState(): - """Represents a state of the Library grid view.""" +class NavigationState: + """Represents a state of the Library grid view.""" - def __init__(self, contents, scrollbar_pos: int, page_index:int, page_count:int, search_text: str = None, thumb_size=None, spacing=None) -> None: - self.contents = contents - self.scrollbar_pos = scrollbar_pos - self.page_index = page_index - self.page_count = page_count - self.search_text = search_text - self.thumb_size = thumb_size - self.spacing = spacing + def __init__( + self, + contents, + scrollbar_pos: int, + page_index: int, + page_count: int, + search_text: str = None, + thumb_size=None, + spacing=None, + ) -> None: + self.contents = contents + self.scrollbar_pos = scrollbar_pos + self.page_index = page_index + self.page_count = page_count + self.search_text = search_text + self.thumb_size = thumb_size + self.spacing = spacing class Consumer(QThread): - def __init__(self, queue) -> None: - self.queue = queue - QThread.__init__(self) + def __init__(self, queue) -> None: + self.queue = queue + QThread.__init__(self) - def run(self): - self.active = True - while self.active: - try: - job = self.queue.get(timeout=0.2) - # print('Running job...') - # logging.info(*job[1]) - job[0](*job[1]) - except (Empty, RuntimeError): - pass + def run(self): + self.active = True + while self.active: + try: + job = self.queue.get(timeout=0.2) + # print('Running job...') + # logging.info(*job[1]) + job[0](*job[1]) + except (Empty, RuntimeError): + pass - - def set_page_count(self, count:int): - self.page_count = count - - def jump_to_page(self, index:int): - pass + def set_page_count(self, count: int): + self.page_count = count - def nav_back(self): - pass + def jump_to_page(self, index: int): + pass - def nav_forward(self): - pass + def nav_back(self): + pass + + def nav_forward(self): + pass class QtDriver(QObject): - """A Qt GUI frontend driver for TagStudio.""" - - SIGTERM = Signal() - - def __init__(self, core, args): - super().__init__() - self.core: TagStudioCore = core - self.lib = self.core.lib - self.args = args - - # self.main_window = None - # self.main_window = Ui_MainWindow() - - self.branch: str = (' ('+VERSION_BRANCH + - ')') if VERSION_BRANCH else '' - self.base_title: str = f'TagStudio {VERSION}{self.branch}' - # self.title_text: str = self.base_title - # self.buffer = {} - self.thumb_job_queue: Queue = Queue() - self.thumb_threads = [] - self.thumb_cutoff: float = time.time() - # self.selected: list[tuple[int,int]] = [] # (Thumb Index, Page Index) - self.selected: list[tuple[ItemType,int]] = [] # (Item Type, Item ID) - - 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): - # thread = threading.Thread(target=self.consumer, name=f'ThumbRenderer_{i}',args=(), daemon=True) - # thread.start() - thread = Consumer(self.thumb_job_queue) - thread.setObjectName(f'ThumbRenderer_{i}') - self.thumb_threads.append(thread) - thread.start() - - def open_library_from_dialog(self): - dir = QFileDialog.getExistingDirectory(None, - 'Open/Create Library', - '/', - QFileDialog.ShowDirsOnly) - if dir not in (None, ''): - self.open_library(dir) - - def signal_handler(self, sig, frame): - if sig in (SIGINT, SIGTERM, SIGQUIT): - self.SIGTERM.emit() - - def setup_signals(self): - signal(SIGINT, self.signal_handler) - signal(SIGTERM, self.signal_handler) - signal(SIGQUIT, self.signal_handler) - - def start(self): - """Launches the main Qt window.""" - - loader = QUiLoader() - if os.name == 'nt': - sys.argv += ['-platform', 'windows:darkmode=2'] - app = QApplication(sys.argv) - app.setStyle('Fusion') - # pal: QPalette = app.palette() - # pal.setColor(QPalette.ColorGroup.Active, - # QPalette.ColorRole.Highlight, QColor('#6E4BCE')) - # pal.setColor(QPalette.ColorGroup.Normal, - # QPalette.ColorRole.Window, QColor('#110F1B')) - # app.setPalette(pal) - home_path = os.path.normpath(f'{Path(__file__).parent}/ui/home.ui') - icon_path = os.path.normpath( - f'{Path(__file__).parent.parent.parent}/resources/icon.png') - - # Handle OS signals - self.setup_signals() - timer = QTimer() - timer.start(500) - timer.timeout.connect(lambda: None) - - # self.main_window = loader.load(home_path) - self.main_window = Ui_MainWindow() - self.main_window.setWindowTitle(self.base_title) - self.main_window.mousePressEvent = self.mouse_navigation - # self.main_window.setStyleSheet( - # f'QScrollBar::{{background:red;}}' - # ) - - # # self.main_window.windowFlags() & - # # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) - # self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True) - # self.main_window.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False) - # self.main_window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - # self.windowFX = WindowEffect() - # self.windowFX.setAcrylicEffect(self.main_window.winId()) - - splash_pixmap = QPixmap(':/images/splash.png') - splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio()) - self.splash = QSplashScreen(splash_pixmap) - # self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self.splash.show() - - menu_bar = self.main_window.menuBar() - menu_bar.setNativeMenuBar(False) - # menu_bar.setStyleSheet('background:#00000000;') - file_menu = QMenu('&File', menu_bar) - edit_menu = QMenu('&Edit', menu_bar) - tools_menu = QMenu('&Tools', menu_bar) - macros_menu = QMenu('&Macros', menu_bar) - help_menu = QMenu('&Help', menu_bar) - - # File Menu ============================================================ - # file_menu.addAction(QAction('&New Library', menu_bar)) - # file_menu.addAction(QAction('&Open Library', menu_bar)) - - 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.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() - - # refresh_lib_action = QAction('&Refresh Directories', self.main_window) - # 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) - - file_menu.addSeparator() - - close_library_action = QAction('&Close Library', menu_bar) - close_library_action.triggered.connect(lambda: self.close_library()) - file_menu.addAction(close_library_action) - - # Edit Menu ============================================================ - 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) - fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show()) - tools_menu.addAction(fix_unlinked_entries_action) - - fix_dupe_files_action = QAction('Fix Duplicate &Files', menu_bar) - fdf_modal = FixDupeFilesModal(self.lib, self) - fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show()) - tools_menu.addAction(fix_dupe_files_action) - - create_collage_action = QAction('Create Collage', menu_bar) - create_collage_action.triggered.connect(lambda: self.create_collage()) - tools_menu.addAction(create_collage_action) - - # Macros Menu ========================================================== - self.autofill_action = QAction('Autofill', menu_bar) - 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.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) - menu_bar.addMenu(edit_menu) - menu_bar.addMenu(tools_menu) - menu_bar.addMenu(macros_menu) - menu_bar.addMenu(help_menu) - - # self.main_window.setMenuBar(menu_bar) - # self.main_window.centralWidget().layout().addWidget(menu_bar, 0,0,1,1) - # self.main_window.tb_layout.addWidget(menu_bar) - - icon = QIcon() - icon.addFile(icon_path) - self.main_window.setWindowIcon(icon) - - self.preview_panel = PreviewPanel(self.lib, self) - l: QHBoxLayout = self.main_window.splitter - l.addWidget(self.preview_panel) - # self.preview_panel.update_widgets() - # l.setEnabled(False) - # self.entry_panel.setWindowIcon(icon) - - if os.name == 'nt': - appid = "cyanvoxel.tagstudio.9" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( - appid) - app.setWindowIcon(icon) - - QFontDatabase.addApplicationFont(os.path.normpath( - f'{Path(__file__).parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf')) - - self.thumb_size = 128 - self.max_results = 500 - self.item_thumbs: list[ItemThumb] = [] - self.thumb_renderers: list[ThumbRenderer] = [] - self.collation_thumb_size = math.ceil(self.thumb_size * 2) - # self.filtered_items: list[tuple[SearchItemType, int]] = [] - - self._init_thumb_grid() - - # TODO: Put this into its own method that copies the font file(s) into memory - # so the resource isn't being used, then store the specific size variations - # in a global dict for methods to access for different DPIs. - # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) - # self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size) - - search_button: QPushButton = self.main_window.searchButton - search_button.clicked.connect( - lambda: self.filter_items(self.main_window.searchField.text())) - search_field: QLineEdit = self.main_window.searchField - search_field.returnPressed.connect( - lambda: self.filter_items(self.main_window.searchField.text())) - - back_button: QPushButton = self.main_window.backButton - back_button.clicked.connect(self.nav_back) - forward_button: QPushButton = self.main_window.forwardButton - forward_button.clicked.connect(self.nav_forward) - - self.frame_dict = {} - self.main_window.pagination.index.connect(lambda i:(self.nav_forward(*self.get_frame_contents(i, self.nav_frames[self.cur_frame_idx].search_text)), logging.info(f'emitted {i}'))) - - - - self.nav_frames: list[NavigationState] = [] - self.cur_frame_idx: int = -1 - self.cur_query: str = '' - self.filter_items() - # self.update_thumbs() - - # self.render_times: list = [] - # self.main_window.setWindowFlag(Qt.FramelessWindowHint) - - # NOTE: Putting this early will result in a white non-responsive - # window until everything is loaded. Consider adding a splash screen - # or implementing some clever loading tricks. - self.main_window.show() - self.main_window.activateWindow() - # self.main_window.raise_() - self.splash.finish(self.main_window) - self.preview_panel.update_widgets() - - # 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") and os.path.isdir(self.settings.value("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_() - - self.shutdown() - - - def callback_library_needed_check(self,func): - """Check if loaded library has valid path before executing the button function""" - if self.lib.library_dir: - func() - - - def handleSIGTERM(self): - self.shutdown() - - def shutdown(self): - """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() - logging.info("[SHUTDOWN] Ending Thumbnail Threads...") - for thread in self.thumb_threads: - thread.active=False - thread.quit() - thread.wait() - QApplication.quit() - - - def save_library(self): - logging.info(f'Saving Library...') - self.main_window.statusbar.showMessage(f'Saving Library...') - start_time = time.time() - self.lib.save_library_to_disk() - end_time = time.time() - self.main_window.statusbar.showMessage(f'Library Saved! ({format_timespan(end_time - start_time)})') - - def close_library(self): - if self.lib.library_dir: - # TODO: it is kinda the same code from "save_library"... - logging.info(f'Closing & Saving Library...') - self.main_window.statusbar.showMessage(f'Closed & Saving Library...') - start_time = time.time() - self.lib.save_library_to_disk() - self.settings.setValue("last_library", self.lib.library_dir) - self.settings.sync() - - self.lib.clear_internal_vars() - title_text = f'{self.base_title}' - self.main_window.setWindowTitle(title_text) - - self.nav_frames: list[NavigationState] = [] - self.cur_frame_idx: int = -1 - self.cur_query: str = '' - self.selected.clear() - self.preview_panel.update_widgets() - self.filter_items() - - end_time = time.time() - self.main_window.statusbar.showMessage(f'Library Saved and Closed! ({format_timespan(end_time - start_time)})') - - def backup_library(self): - logging.info(f'Backing Up Library...') - self.main_window.statusbar.showMessage(f'Saving Library...') - start_time = time.time() - fn = self.lib.save_library_backup_to_disk() - end_time = time.time() - self.main_window.statusbar.showMessage(f'Library Backup Saved at: "{os.path.normpath(os.path.normpath(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}/{fn}"))}" ({format_timespan(end_time - start_time)})') - - def add_tag_action_callback(self): - self.modal = PanelModal(BuildTagPanel(self.lib), - 'New Tag', - 'Add Tag', - has_save=True) - # self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t)) - panel: BuildTagPanel = self.modal.widget - self.modal.saved.connect(lambda: (self.lib.add_tag_to_library(panel.build_tag()), self.modal.hide())) - # 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: - # # mb = QMessageBox() - # # mb.setText(f'Would you like to refresh the directory before adding {len(self.lib.files_not_in_library)} new files to the library?\nThis will add any additional files that have been moved to the directory since the last refresh.') - # # mb.setWindowTitle('Refresh Library') - # # mb.setIcon(QMessageBox.Icon.Information) - # # mb.setStandardButtons(QMessageBox.StandardButton.No) - # # refresh_button = mb.addButton('Refresh', QMessageBox.ButtonRole.AcceptRole) - # # mb.setDefaultButton(refresh_button) - # # result = mb.exec_() - # # # logging.info(result) - # # if result == 0: - # # self.main_window.statusbar.showMessage(f'Refreshing Library...', 3) - # # self.lib.refresh_dir() - # # else: - # pb = QProgressDialog('Scanning Directories for New Files...\nPreparing...', None, 0,0) - - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # # pb.setLabelText('Scanning Directories...') - # pb.setWindowTitle('Scanning Directories') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # # pb.setMinimum(0) - # # pb.setMaximum(0) - # # pb.setValue(0) - # pb.show() - # self.main_window.statusbar.showMessage(f'Refreshing Library...', 3) - # # self.lib.refresh_dir() - # r = CustomRunnable(lambda: self.runnable(pb)) - # logging.info(f'Main: {QThread.currentThread()}') - # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.add_new_files_runnable())) - # QThreadPool.globalInstance().start(r) - # # r.run() - - # # new_ids: list[int] = self.lib.add_new_files_as_entries() - # # # logging.info(f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') - # # # self.main_window.statusbar.showMessage(f'Running configured Macros on {len(new_ids)} new Entries...', 3) - # # # for id in new_ids: - # # # self.run_macro('autofill', id) - - # # self.main_window.statusbar.showMessage('', 3) - # # self.filter_entries('') - - - iterator = FunctionIterator(self.lib.refresh_dir) - pw = ProgressWidget( - window_title='Refreshing Directories', - label_text='Scanning Directories for New Files...\nPreparing...', - cancel_button_text=None, - minimum=0, - maximum=0 - ) - pw.show() - iterator.value.connect(lambda x: pw.update_progress(x+1)) - iterator.value.connect(lambda x: pw.update_label(f'Scanning Directories for New Files...\n{x+1} File{"s" if x+1 != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found')) - r = CustomRunnable(lambda:iterator.run()) - # r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items(''))) - # vvv This one runs the macros when adding new files to the library. - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.add_new_files_runnable())) - QThreadPool.globalInstance().start(r) - - - # def runnable(self, pb:QProgressDialog): - # for i in self.lib.refresh_dir(): - # pb.setLabelText(f'Scanning Directories for New Files...\n{i} File{"s" if i != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found') - - - def add_new_files_runnable(self): - """ - Threaded method that adds any known new files to the library and - initiates running default macros on them. - """ - # logging.info(f'Start ANF: {QThread.currentThread()}') - new_ids: list[int] = self.lib.add_new_files_as_entries() - # pb = QProgressDialog(f'Running Configured Macros on 1/{len(new_ids)} New Entries', None, 0,len(new_ids)) - # pb.setFixedSize(432, 112) - # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - # pb.setWindowTitle('Running Macros') - # pb.setWindowModality(Qt.WindowModality.ApplicationModal) - # pb.show() - - # r = CustomRunnable(lambda: self.new_file_macros_runnable(pb, new_ids)) - # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.filter_items(''))) - # r.run() - # # QThreadPool.globalInstance().start(r) - - # # logging.info(f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') - # # self.main_window.statusbar.showMessage(f'Running configured Macros on {len(new_ids)} new Entries...', 3) - - # # pb.hide() - - - - iterator = FunctionIterator(lambda:self.new_file_macros_runnable(new_ids)) - pw = ProgressWidget( - window_title='Running Macros on New Entries', - label_text=f'Running Configured Macros on 1/{len(new_ids)} New Entries', - cancel_button_text=None, - minimum=0, - maximum=0 - ) - pw.show() - iterator.value.connect(lambda x: pw.update_progress(x+1)) - iterator.value.connect(lambda x: pw.update_label(f'Running Configured Macros on {x+1}/{len(new_ids)} New Entries')) - r = CustomRunnable(lambda:iterator.run()) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items(''))) - QThreadPool.globalInstance().start(r) - - def new_file_macros_runnable(self, new_ids): - """Threaded method that runs macros on a set of Entry IDs.""" - # sleep(1) - # logging.info(f'ANFR: {QThread.currentThread()}') - # for i, id in enumerate(new_ids): - # # pb.setValue(i) - # # pb.setLabelText(f'Running Configured Macros on {i}/{len(new_ids)} New Entries') - # # self.run_macro('autofill', id) - - # NOTE: I don't know. I don't know why it needs this. The whole program - # falls apart if this method doesn't run, and it DOESN'T DO ANYTHING - yield 0 - - # self.main_window.statusbar.showMessage('', 3) - - # sleep(5) - # pb.deleteLater() - - 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) - - def run_macro(self, name: str, entry_id: int): - """Runs a specific Macro on an Entry given a Macro name.""" - entry = self.lib.get_entry(entry_id) - path = os.path.normpath( - f'{self.lib.library_dir}/{entry.path}/{entry.filename}') - source = path.split(os.sep)[1].lower() - if name == 'sidecar': - self.lib.add_generic_data_to_entry( - self.core.get_gdl_sidecar(path, source), entry_id) - elif name == 'autofill': - self.run_macro('sidecar', entry_id) - self.run_macro('build-url', entry_id) - self.run_macro('match', entry_id) - self.run_macro('clean-url', entry_id) - self.run_macro('sort-fields', entry_id) - elif name == 'build-url': - data = {'source': self.core.build_url(entry_id, source)} - self.lib.add_generic_data_to_entry(data, entry_id) - elif name == 'sort-fields': - order: list[int] = ( - [0] + - [1, 2] + - [9, 17, 18, 19, 20] + - [8, 7, 6] + - [4] + - [3, 21] + - [10, 14, 11, 12, 13, 22] + - [5] - ) - self.lib.sort_fields(entry_id, order) - elif name == 'match': - self.core.match_conditions(entry_id) - # elif name == 'scrape': - # self.core.scrape(entry_id) - elif name == 'clean-url': - # entry = self.lib.get_entry_from_index(entry_id) - if entry.fields: - for i, field in enumerate(entry.fields, start=0): - if self.lib.get_field_attr(field, 'type') == 'text_line': - self.lib.update_entry_field( - entry_id=entry_id, - field_index=i, - content=strip_web_protocol( - self.lib.get_field_attr(field, 'content')), - mode='replace') - - def mouse_navigation(self, event: QMouseEvent): - # print(event.button()) - if event.button() == Qt.MouseButton.ForwardButton: - self.nav_forward() - elif event.button() == Qt.MouseButton.BackButton: - self.nav_back() - - def nav_forward(self, frame_content: Optional[list[tuple[ItemType, int]]] = None, page_index:int=0, page_count:int = 0): - """Navigates a step further into the navigation stack.""" - logging.info(f'Calling NavForward with Content:{False if not frame_content else frame_content[0]}, Index:{page_index}, PageCount:{page_count}') - - # Ex. User visits | A ->[B] | - # | A B ->[C]| - # | A [B]<- C | - # |[A]<- B C | Previous routes still exist - # | A ->[D] | Stack is cut from [:A] on new route - - # Moving forward (w/ or wo/ new content) in the middle of the stack - original_pos = self.cur_frame_idx - sb: QScrollArea = self.main_window.scrollArea - sb_pos = sb.verticalScrollBar().value() - search_text = self.main_window.searchField.text() - - trimmed = False - if len(self.nav_frames) > self.cur_frame_idx + 1: - 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: - self.nav_frames.pop() - trimmed = True - self.nav_frames.append(NavigationState(frame_content, 0, page_index, page_count, search_text)) - # logging.info(f'Saving Text: {search_text}') - # Update the last frame's scroll_pos - 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 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() - trimmed = True - self.nav_frames.append(NavigationState(frame_content, 0, page_index, page_count, search_text)) - # logging.info(f'Saving Text: {search_text}') - self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos - 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 is not None): - self.update_thumbs() - sb.verticalScrollBar().setValue( - self.nav_frames[self.cur_frame_idx].scrollbar_pos) - self.main_window.searchField.setText(self.nav_frames[self.cur_frame_idx].search_text) - self.main_window.pagination.update_buttons(self.nav_frames[self.cur_frame_idx].page_count, self.nav_frames[self.cur_frame_idx].page_index, emit=False) - # logging.info(f'Setting Text: {self.nav_stack[self.cur_page_idx].search_text}') - # else: - # self.nav_stack.pop() - # self.cur_page_idx -= 1 - # self.update_thumbs() - # sb.verticalScrollBar().setValue(self.nav_stack[self.cur_page_idx].scrollbar_pos) - - # logging.info(f'Forward: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}, SB {self.nav_stack[self.cur_page_idx].scrollbar_pos}') - - def nav_back(self): - """Navigates a step backwards in the navigation stack.""" - - original_pos = self.cur_frame_idx - sb: QScrollArea = self.main_window.scrollArea - sb_pos = sb.verticalScrollBar().value() - - if self.cur_frame_idx > 0: - self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos - self.cur_frame_idx -= 1 - if self.cur_frame_idx != original_pos: - self.update_thumbs() - sb.verticalScrollBar().setValue( - self.nav_frames[self.cur_frame_idx].scrollbar_pos) - self.main_window.searchField.setText(self.nav_frames[self.cur_frame_idx].search_text) - self.main_window.pagination.update_buttons(self.nav_frames[self.cur_frame_idx].page_count, self.nav_frames[self.cur_frame_idx].page_index, emit=False) - # logging.info(f'Setting Text: {self.nav_stack[self.cur_page_idx].search_text}') - # logging.info(f'Back: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}, SB {self.nav_stack[self.cur_page_idx].scrollbar_pos}') - - def refresh_frame(self, frame_content: list[tuple[ItemType, int]], page_index:int=0, page_count:int = 0): - """ - Refreshes the current navigation contents without altering the - navigation stack order. - """ - if self.nav_frames: - self.nav_frames[self.cur_frame_idx] = NavigationState(frame_content, 0, self.nav_frames[self.cur_frame_idx].page_index, self.nav_frames[self.cur_frame_idx].page_count, self.main_window.searchField.text()) - else: - self.nav_forward(frame_content, page_index, page_count) - self.update_thumbs() - # logging.info(f'Refresh: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}') - - def purge_item_from_navigation(self, type:ItemType, id:int): - # logging.info(self.nav_frames) - for i, frame in enumerate(self.nav_frames, start=0): - while (type, id) in frame.contents: - logging.info(f'Removing {id} from nav stack frame {i}') - frame.contents.remove((type, id)) - - for i, key in enumerate(self.frame_dict.keys(), start=0): - for frame in self.frame_dict[key]: - while (type, id) in frame: - logging.info(f'Removing {id} from frame dict item {i}') - frame.remove((type, id)) - - while (type, id) in self.selected: - logging.info(f'Removing {id} from frame selected') - self.selected.remove((type, id)) - - - def _init_thumb_grid(self): - # logging.info('Initializing Thumbnail Grid...') - layout = FlowLayout() - layout.setGridEfficiency(True) - # layout.setContentsMargins(0,0,0,0) - layout.setSpacing(min(self.thumb_size//10, 12)) - # layout = QHBoxLayout() - # layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize) - # layout = QListView() - # layout.setViewMode(QListView.ViewMode.IconMode) - - col_size = 28 - for i in range(0, self.max_results): - item_thumb = ItemThumb(None, self.lib, self.preview_panel, - (self.thumb_size, self.thumb_size)) - layout.addWidget(item_thumb) - self.item_thumbs.append(item_thumb) - - self.flow_container: QWidget = QWidget() - self.flow_container.setObjectName('flowContainer') - self.flow_container.setLayout(layout) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - sa: QScrollArea = self.main_window.scrollArea - sa.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - sa.setWidgetResizable(True) - sa.setWidget(self.flow_container) - - def select_item(self, type:int, id:int, append:bool, bridge:bool): - """Selects one or more items in the Thumbnail Grid.""" - if append: - # self.selected.append((thumb_index, page_index)) - if ((type, id)) not in self.selected: - self.selected.append((type, id)) - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - it.thumb_button.set_selected(True) - else: - self.selected.remove((type, id)) - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - it.thumb_button.set_selected(False) - # self.item_thumbs[thumb_index].thumb_button.set_selected(True) - - elif bridge and self.selected: - logging.info(f'Last Selected: {self.selected[-1]}') - contents = self.nav_frames[self.cur_frame_idx].contents - last_index = self.nav_frames[self.cur_frame_idx].contents.index(self.selected[-1]) - current_index = self.nav_frames[self.cur_frame_idx].contents.index((type, id)) - index_range: list = contents[min(last_index, current_index):max(last_index, current_index)+1] - # Preserve bridge direction for correct appending order. - if last_index < current_index: - index_range.reverse() - - # logging.info(f'Current Frame Contents: {len(self.nav_frames[self.cur_frame_idx].contents)}') - # logging.info(f'Last Selected Index: {last_index}') - # logging.info(f'Current Selected Index: {current_index}') - # logging.info(f'Index Range: {index_range}') - - for c_type, c_id in index_range: - for it in self.item_thumbs: - if it.mode == c_type and it.item_id == c_id: - it.thumb_button.set_selected(True) - if ((c_type, c_id)) not in self.selected: - self.selected.append((c_type, c_id)) - else: - # for i in self.selected: - # if i[1] == self.cur_frame_idx: - # self.item_thumbs[i[0]].thumb_button.set_selected(False) - self.selected.clear() - # self.selected.append((thumb_index, page_index)) - self.selected.append((type, id)) - # self.item_thumbs[thumb_index].thumb_button.set_selected(True) - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - it.thumb_button.set_selected(True) - else: - it.thumb_button.set_selected(False) - - # NOTE: By using the preview panel's "set_tags_updated_slot" method, - # only the last of multiple identical item selections are connected. - # If attaching the slot to multiple duplicate selections is needed, - # just bypass the method and manually disconnect and connect the slots. - if len(self.selected) == 1: - for it in self.item_thumbs: - if it.mode == type and it.item_id == id: - self.preview_panel.set_tags_updated_slot(it.update_badges) - - self.set_macro_menu_viability() - self.preview_panel.update_widgets() - - def set_macro_menu_viability(self): - if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0: - self.autofill_action.setDisabled(True) - self.sort_fields_action.setDisabled(True) - else: - self.autofill_action.setDisabled(False) - self.sort_fields_action.setDisabled(False) - - def update_thumbs(self): - """Updates search thumbnails.""" - # start_time = time.time() - # logging.info(f'Current Page: {self.cur_page_idx}, Stack Length:{len(self.nav_stack)}') - with self.thumb_job_queue.mutex: - # Cancels all thumb jobs waiting to be started - self.thumb_job_queue.queue.clear() - self.thumb_job_queue.all_tasks_done.notify_all() - self.thumb_job_queue.not_full.notify_all() - # Stops in-progress jobs from finishing - ItemThumb.update_cutoff = time.time() - - ratio: float = self.main_window.devicePixelRatio() - base_size: tuple[int, int] = (self.thumb_size, self.thumb_size) - - for i, item_thumb in enumerate(self.item_thumbs, start=0): - - if i < len(self.nav_frames[self.cur_frame_idx].contents): - # Set new item type modes - # logging.info(f'[UPDATE] Setting Mode To: {self.nav_stack[self.cur_page_idx].contents[i][0]}') - item_thumb.set_mode(self.nav_frames[self.cur_frame_idx].contents[i][0]) - item_thumb.ignore_size = False - # logging.info(f'[UPDATE] Set Mode To: {item.mode}') - # Set thumbnails to loading (will always finish if rendering) - self.thumb_job_queue.put( - (item_thumb.renderer.render, (sys.float_info.max, '', - base_size, ratio, True))) - # # Restore Selected Borders - # if (item_thumb.mode, item_thumb.item_id) in self.selected: - # item_thumb.thumb_button.set_selected(True) - # else: - # item_thumb.thumb_button.set_selected(False) - else: - item_thumb.ignore_size = True - item_thumb.set_mode(None) - item_thumb.set_item_id(-1) - item_thumb.thumb_button.set_selected(False) - - # scrollbar: QScrollArea = self.main_window.scrollArea - # scrollbar.verticalScrollBar().setValue(scrollbar_pos) - self.flow_container.layout().update() - self.main_window.update() - - for i, item_thumb in enumerate(self.item_thumbs, start=0): - if i < len(self.nav_frames[self.cur_frame_idx].contents): - filepath = '' - if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY: - entry = self.lib.get_entry( - self.nav_frames[self.cur_frame_idx].contents[i][1]) - filepath = os.path.normpath( - f'{self.lib.library_dir}/{entry.path}/{entry.filename}') - - item_thumb.set_item_id(entry.id) - item_thumb.assign_archived(entry.has_tag(self.lib, 0)) - item_thumb.assign_favorite(entry.has_tag(self.lib, 1)) - # ctrl_down = True if QGuiApplication.keyboardModifiers() else False - # TODO: Change how this works. The click function - # for collations a few lines down should NOT be allowed during modifier keys. - item_thumb.update_clickable(clickable=( - lambda checked=False, entry=entry: - self.select_item(ItemType.ENTRY, entry.id, - append=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier else False, - bridge=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier else False))) - # item_thumb.update_clickable(clickable=( - # lambda checked=False, filepath=filepath, entry=entry, - # item_t=item_thumb, i=i, page=self.cur_frame_idx: ( - # self.preview_panel.update_widgets(entry), - # self.select_item(ItemType.ENTRY, entry.id, - # append=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier else False, - # bridge=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier else False)))) - # item.dumpObjectTree() - elif self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.COLLATION: - collation = self.lib.get_collation( - self.nav_frames[self.cur_frame_idx].contents[i][1]) - cover_id = collation.cover_id if collation.cover_id >= 0 else collation.e_ids_and_pages[ - 0][0] - cover_e = self.lib.get_entry(cover_id) - filepath = os.path.normpath( - f'{self.lib.library_dir}/{cover_e.path}/{cover_e.filename}') - item_thumb.set_count(str(len(collation.e_ids_and_pages))) - item_thumb.update_clickable(clickable=(lambda checked=False, filepath=filepath, entry=cover_e, collation=collation: ( - self.expand_collation(collation.e_ids_and_pages)))) - # item.setHidden(False) - - # Restore Selected Borders - if (item_thumb.mode, item_thumb.item_id) in self.selected: - item_thumb.thumb_button.set_selected(True) - else: - item_thumb.thumb_button.set_selected(False) - - self.thumb_job_queue.put( - (item_thumb.renderer.render, (time.time(), filepath, base_size, ratio, False))) - else: - # item.setHidden(True) - pass - # update_widget_clickable(widget=item.bg_button, clickable=()) - # self.thumb_job_queue.put( - # (item.renderer.render, ('', base_size, ratio, False))) - - # end_time = time.time() - # 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 = 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=''): - if self.lib: - - # logging.info('Filtering...') - self.main_window.statusbar.showMessage( - f'Searching Library for \"{query}\"...') - self.main_window.statusbar.repaint() - start_time = time.time() - - # self.filtered_items = self.lib.search_library(query) - # 73601 Entries at 500 size should be 246 - all_items = self.lib.search_library(query) - frames = [] - frame_count = math.ceil(len(all_items)/self.max_results) - for i in range(0, frame_count): - frames.append(all_items[min(len(all_items)-1, (i)*self.max_results):min(len(all_items), (i+1)*self.max_results)]) - for i, f in enumerate(frames): - logging.info(f'Query:{query}, Frame: {i}, Length: {len(f)}') - self.frame_dict[query] = frames - # self.frame_dict[query] = [all_items] - - if self.cur_query == query: - # self.refresh_frame(self.lib.search_library(query)) - # NOTE: Trying to refresh instead of navigating forward here - # now creates a bug when the page counts differ on refresh. - # If refreshing is absolutely desired, see how to update - # page counts where they need to be updated. - self.nav_forward(*self.get_frame_contents(0, query)) - else: - # self.nav_forward(self.lib.search_library(query)) - self.nav_forward(*self.get_frame_contents(0, query)) - self.cur_query = query - - end_time = time.time() - if query: - self.main_window.statusbar.showMessage( - f'{len(all_items)} Results Found for \"{query}\" ({format_timespan(end_time - start_time)})') - else: - self.main_window.statusbar.showMessage( - f'{len(all_items)} Results ({format_timespan(end_time - start_time)})') - # logging.info(f'Done Filtering! ({(end_time - start_time):.3f}) seconds') - - # self.update_thumbs() - - def open_library(self, path): - """Opens a TagStudio library.""" - if self.lib.library_dir: - self.save_library() - self.lib.clear_internal_vars() - - self.main_window.statusbar.showMessage(f'Opening Library {path}', 3) - return_code = self.lib.open_library(path) - if return_code == 1: - # if self.args.external_preview: - # self.init_external_preview() - - # if len(self.lib.entries) <= 1000: - # print(f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...') - # self.lib.refresh_missing_files() - # title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - # self.main_window.setWindowTitle(title_text) - pass - - else: - logging.info(f'{ERROR} No existing TagStudio library found at \'{path}\'. Creating one.') - print(f'Library Creation Return Code: {self.lib.create_library(path)}') - self.add_new_files_callback() - - title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\'' - self.main_window.setWindowTitle(title_text) - - self.nav_frames: list[NavigationState] = [] - self.cur_frame_idx: int = -1 - self.cur_query: str = '' - self.selected.clear() - self.preview_panel.update_widgets() - self.filter_items() - - - def create_collage(self) -> None: - """Generates and saves an image collage based on Library Entries.""" - - run: bool = True - keep_aspect: bool = False - data_only_mode: bool = False - data_tint_mode: bool = False - - self.main_window.statusbar.showMessage(f'Creating Library Collage...') - self.collage_start_time = time.time() - - # mode:int = self.scr_choose_option(subtitle='Choose Collage Mode(s)', - # choices=[ - # ('Normal','Creates a standard square image collage made up of Library media files.'), - # ('Data Tint','Tints the collage with a color representing data about the Library Entries/files.'), - # ('Data Only','Ignores media files entirely and only outputs a collage of Library Entry/file data.'), - # ('Normal & Data Only','Creates both Normal and Data Only collages.'), - # ], prompt='', required=True) - mode = 0 - - if mode == 1: - data_tint_mode = True - - if mode == 2: - data_only_mode = True - - if mode in [0, 1, 3]: - # keep_aspect = self.scr_choose_option( - # subtitle='Choose Aspect Ratio Option', - # choices=[ - # ('Stretch to Fill','Stretches the media file to fill the entire collage square.'), - # ('Keep Aspect Ratio','Keeps the original media file\'s aspect ratio, filling the rest of the square with black bars.') - # ], prompt='', required=True) - keep_aspect = 0 - - if mode in [1, 2, 3]: - # TODO: Choose data visualization options here. - pass - - full_thumb_size: int = 1 - - if mode in [0, 1, 3]: - # full_thumb_size = self.scr_choose_option( - # subtitle='Choose Thumbnail Size', - # choices=[ - # ('Tiny (32px)',''), - # ('Small (64px)',''), - # ('Medium (128px)',''), - # ('Large (256px)',''), - # ('Extra Large (512px)','') - # ], prompt='', required=True) - full_thumb_size = 0 - - thumb_size: int = (32 if (full_thumb_size == 0) - else 64 if (full_thumb_size == 1) - else 128 if (full_thumb_size == 2) - else 256 if (full_thumb_size == 3) - else 512 if (full_thumb_size == 4) - else 32) - thumb_size = 16 - - # if len(com) > 1 and com[1] == 'keep-aspect': - # keep_aspect = True - # elif len(com) > 1 and com[1] == 'data-only': - # data_only_mode = True - # elif len(com) > 1 and com[1] == 'data-tint': - # data_tint_mode = True - grid_size = math.ceil(math.sqrt(len(self.lib.entries)))**2 - grid_len = math.floor(math.sqrt(grid_size)) - thumb_size = thumb_size if not data_only_mode else 1 - img_size = thumb_size * grid_len - - logging.info(f'Creating collage for {len(self.lib.entries)} Entries.\nGrid Size: {grid_size} ({grid_len}x{grid_len})\nIndividual Picture Size: ({thumb_size}x{thumb_size})') - if keep_aspect: - logging.info('Keeping original aspect ratios.') - if data_only_mode: - logging.info('Visualizing Entry Data') - - if not data_only_mode: - time.sleep(5) - - self.collage = Image.new('RGB', (img_size,img_size)) - i = 0 - self.completed = 0 - for x in range(0, grid_len): - for y in range(0, grid_len): - if i < len(self.lib.entries) and run: - # if i < 5 and run: - - entry_id = self.lib.entries[i].id - renderer = CollageIconRenderer(self.lib) - renderer.rendered.connect(lambda image, x=x, y=y: self.collage.paste(image, (y*thumb_size, x*thumb_size))) - renderer.done.connect(lambda: self.try_save_collage(True)) - self.thumb_job_queue.put((renderer.render, - ( - entry_id, - (thumb_size, thumb_size), - data_tint_mode, - data_only_mode, - keep_aspect - ))) - i = i+1 - - def try_save_collage(self, increment_progress:bool): - if increment_progress: - 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_{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)})') + """A Qt GUI frontend driver for TagStudio.""" + + SIGTERM = Signal() + + def __init__(self, core, args): + super().__init__() + self.core: TagStudioCore = core + self.lib = self.core.lib + self.args = args + + # self.main_window = None + # self.main_window = Ui_MainWindow() + + self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else "" + self.base_title: str = f"TagStudio {VERSION}{self.branch}" + # self.title_text: str = self.base_title + # self.buffer = {} + self.thumb_job_queue: Queue = Queue() + self.thumb_threads = [] + self.thumb_cutoff: float = time.time() + # self.selected: list[tuple[int,int]] = [] # (Thumb Index, Page Index) + self.selected: list[tuple[ItemType, int]] = [] # (Item Type, Item ID) + + 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): + # thread = threading.Thread(target=self.consumer, name=f'ThumbRenderer_{i}',args=(), daemon=True) + # thread.start() + thread = Consumer(self.thumb_job_queue) + thread.setObjectName(f"ThumbRenderer_{i}") + self.thumb_threads.append(thread) + thread.start() + + def open_library_from_dialog(self): + dir = QFileDialog.getExistingDirectory( + None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly + ) + if dir not in (None, ""): + self.open_library(dir) + + def signal_handler(self, sig, frame): + if sig in (SIGINT, SIGTERM, SIGQUIT): + self.SIGTERM.emit() + + def setup_signals(self): + signal(SIGINT, self.signal_handler) + signal(SIGTERM, self.signal_handler) + signal(SIGQUIT, self.signal_handler) + + def start(self): + """Launches the main Qt window.""" + + loader = QUiLoader() + if os.name == "nt": + sys.argv += ["-platform", "windows:darkmode=2"] + app = QApplication(sys.argv) + app.setStyle("Fusion") + # pal: QPalette = app.palette() + # pal.setColor(QPalette.ColorGroup.Active, + # QPalette.ColorRole.Highlight, QColor('#6E4BCE')) + # pal.setColor(QPalette.ColorGroup.Normal, + # QPalette.ColorRole.Window, QColor('#110F1B')) + # app.setPalette(pal) + home_path = os.path.normpath(f"{Path(__file__).parent}/ui/home.ui") + icon_path = os.path.normpath( + f"{Path(__file__).parent.parent.parent}/resources/icon.png" + ) + + # Handle OS signals + self.setup_signals() + timer = QTimer() + timer.start(500) + timer.timeout.connect(lambda: None) + + # self.main_window = loader.load(home_path) + self.main_window = Ui_MainWindow() + self.main_window.setWindowTitle(self.base_title) + self.main_window.mousePressEvent = self.mouse_navigation + # self.main_window.setStyleSheet( + # f'QScrollBar::{{background:red;}}' + # ) + + # # self.main_window.windowFlags() & + # # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) + # self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True) + # self.main_window.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False) + # self.main_window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + # self.windowFX = WindowEffect() + # self.windowFX.setAcrylicEffect(self.main_window.winId()) + + splash_pixmap = QPixmap(":/images/splash.png") + splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio()) + self.splash = QSplashScreen(splash_pixmap) + # self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.splash.show() + + menu_bar = self.main_window.menuBar() + menu_bar.setNativeMenuBar(False) + # menu_bar.setStyleSheet('background:#00000000;') + file_menu = QMenu("&File", menu_bar) + edit_menu = QMenu("&Edit", menu_bar) + tools_menu = QMenu("&Tools", menu_bar) + macros_menu = QMenu("&Macros", menu_bar) + help_menu = QMenu("&Help", menu_bar) + + # File Menu ============================================================ + # file_menu.addAction(QAction('&New Library', menu_bar)) + # file_menu.addAction(QAction('&Open Library', menu_bar)) + + 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.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() + + # refresh_lib_action = QAction('&Refresh Directories', self.main_window) + # 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) + + file_menu.addSeparator() + + close_library_action = QAction("&Close Library", menu_bar) + close_library_action.triggered.connect(lambda: self.close_library()) + file_menu.addAction(close_library_action) + + # Edit Menu ============================================================ + 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) + fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show()) + tools_menu.addAction(fix_unlinked_entries_action) + + fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar) + fdf_modal = FixDupeFilesModal(self.lib, self) + fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show()) + tools_menu.addAction(fix_dupe_files_action) + + create_collage_action = QAction("Create Collage", menu_bar) + create_collage_action.triggered.connect(lambda: self.create_collage()) + tools_menu.addAction(create_collage_action) + + # Macros Menu ========================================================== + self.autofill_action = QAction("Autofill", menu_bar) + 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.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) + menu_bar.addMenu(edit_menu) + menu_bar.addMenu(tools_menu) + menu_bar.addMenu(macros_menu) + menu_bar.addMenu(help_menu) + + # self.main_window.setMenuBar(menu_bar) + # self.main_window.centralWidget().layout().addWidget(menu_bar, 0,0,1,1) + # self.main_window.tb_layout.addWidget(menu_bar) + + icon = QIcon() + icon.addFile(icon_path) + self.main_window.setWindowIcon(icon) + + self.preview_panel = PreviewPanel(self.lib, self) + l: QHBoxLayout = self.main_window.splitter + l.addWidget(self.preview_panel) + # self.preview_panel.update_widgets() + # l.setEnabled(False) + # self.entry_panel.setWindowIcon(icon) + + if os.name == "nt": + appid = "cyanvoxel.tagstudio.9" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) + app.setWindowIcon(icon) + + QFontDatabase.addApplicationFont( + os.path.normpath( + f"{Path(__file__).parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf" + ) + ) + + self.thumb_size = 128 + self.max_results = 500 + self.item_thumbs: list[ItemThumb] = [] + self.thumb_renderers: list[ThumbRenderer] = [] + self.collation_thumb_size = math.ceil(self.thumb_size * 2) + # self.filtered_items: list[tuple[SearchItemType, int]] = [] + + self._init_thumb_grid() + + # TODO: Put this into its own method that copies the font file(s) into memory + # so the resource isn't being used, then store the specific size variations + # in a global dict for methods to access for different DPIs. + # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) + # self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size) + + search_button: QPushButton = self.main_window.searchButton + search_button.clicked.connect( + lambda: self.filter_items(self.main_window.searchField.text()) + ) + search_field: QLineEdit = self.main_window.searchField + search_field.returnPressed.connect( + lambda: self.filter_items(self.main_window.searchField.text()) + ) + + back_button: QPushButton = self.main_window.backButton + back_button.clicked.connect(self.nav_back) + forward_button: QPushButton = self.main_window.forwardButton + forward_button.clicked.connect(self.nav_forward) + + self.frame_dict = {} + self.main_window.pagination.index.connect( + lambda i: ( + self.nav_forward( + *self.get_frame_contents( + i, self.nav_frames[self.cur_frame_idx].search_text + ) + ), + logging.info(f"emitted {i}"), + ) + ) + + self.nav_frames: list[NavigationState] = [] + self.cur_frame_idx: int = -1 + self.cur_query: str = "" + self.filter_items() + # self.update_thumbs() + + # self.render_times: list = [] + # self.main_window.setWindowFlag(Qt.FramelessWindowHint) + + # NOTE: Putting this early will result in a white non-responsive + # window until everything is loaded. Consider adding a splash screen + # or implementing some clever loading tricks. + self.main_window.show() + self.main_window.activateWindow() + # self.main_window.raise_() + self.splash.finish(self.main_window) + self.preview_panel.update_widgets() + + # 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") + and os.path.isdir(self.settings.value("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_() + + self.shutdown() + + def callback_library_needed_check(self, func): + """Check if loaded library has valid path before executing the button function""" + if self.lib.library_dir: + func() + + def handleSIGTERM(self): + self.shutdown() + + def shutdown(self): + """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() + logging.info("[SHUTDOWN] Ending Thumbnail Threads...") + for thread in self.thumb_threads: + thread.active = False + thread.quit() + thread.wait() + QApplication.quit() + + def save_library(self): + logging.info(f"Saving Library...") + self.main_window.statusbar.showMessage(f"Saving Library...") + start_time = time.time() + self.lib.save_library_to_disk() + end_time = time.time() + self.main_window.statusbar.showMessage( + f"Library Saved! ({format_timespan(end_time - start_time)})" + ) + + def close_library(self): + if self.lib.library_dir: + # TODO: it is kinda the same code from "save_library"... + logging.info(f"Closing & Saving Library...") + self.main_window.statusbar.showMessage(f"Closed & Saving Library...") + start_time = time.time() + self.lib.save_library_to_disk() + self.settings.setValue("last_library", self.lib.library_dir) + self.settings.sync() + + self.lib.clear_internal_vars() + title_text = f"{self.base_title}" + self.main_window.setWindowTitle(title_text) + + self.nav_frames: list[NavigationState] = [] + self.cur_frame_idx: int = -1 + self.cur_query: str = "" + self.selected.clear() + self.preview_panel.update_widgets() + self.filter_items() + + end_time = time.time() + self.main_window.statusbar.showMessage( + f"Library Saved and Closed! ({format_timespan(end_time - start_time)})" + ) + + def backup_library(self): + logging.info(f"Backing Up Library...") + self.main_window.statusbar.showMessage(f"Saving Library...") + start_time = time.time() + fn = self.lib.save_library_backup_to_disk() + end_time = time.time() + self.main_window.statusbar.showMessage( + f'Library Backup Saved at: "{os.path.normpath(os.path.normpath(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}/{fn}"))}" ({format_timespan(end_time - start_time)})' + ) + + def add_tag_action_callback(self): + self.modal = PanelModal( + BuildTagPanel(self.lib), "New Tag", "Add Tag", has_save=True + ) + # self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t)) + panel: BuildTagPanel = self.modal.widget + self.modal.saved.connect( + lambda: (self.lib.add_tag_to_library(panel.build_tag()), self.modal.hide()) + ) + # 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: + # # mb = QMessageBox() + # # mb.setText(f'Would you like to refresh the directory before adding {len(self.lib.files_not_in_library)} new files to the library?\nThis will add any additional files that have been moved to the directory since the last refresh.') + # # mb.setWindowTitle('Refresh Library') + # # mb.setIcon(QMessageBox.Icon.Information) + # # mb.setStandardButtons(QMessageBox.StandardButton.No) + # # refresh_button = mb.addButton('Refresh', QMessageBox.ButtonRole.AcceptRole) + # # mb.setDefaultButton(refresh_button) + # # result = mb.exec_() + # # # logging.info(result) + # # if result == 0: + # # self.main_window.statusbar.showMessage(f'Refreshing Library...', 3) + # # self.lib.refresh_dir() + # # else: + # pb = QProgressDialog('Scanning Directories for New Files...\nPreparing...', None, 0,0) + + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # # pb.setLabelText('Scanning Directories...') + # pb.setWindowTitle('Scanning Directories') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # # pb.setMinimum(0) + # # pb.setMaximum(0) + # # pb.setValue(0) + # pb.show() + # self.main_window.statusbar.showMessage(f'Refreshing Library...', 3) + # # self.lib.refresh_dir() + # r = CustomRunnable(lambda: self.runnable(pb)) + # logging.info(f'Main: {QThread.currentThread()}') + # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.add_new_files_runnable())) + # QThreadPool.globalInstance().start(r) + # # r.run() + + # # new_ids: list[int] = self.lib.add_new_files_as_entries() + # # # logging.info(f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') + # # # self.main_window.statusbar.showMessage(f'Running configured Macros on {len(new_ids)} new Entries...', 3) + # # # for id in new_ids: + # # # self.run_macro('autofill', id) + + # # self.main_window.statusbar.showMessage('', 3) + # # self.filter_entries('') + + iterator = FunctionIterator(self.lib.refresh_dir) + pw = ProgressWidget( + window_title="Refreshing Directories", + label_text="Scanning Directories for New Files...\nPreparing...", + cancel_button_text=None, + minimum=0, + maximum=0, + ) + pw.show() + iterator.value.connect(lambda x: pw.update_progress(x + 1)) + iterator.value.connect( + lambda x: pw.update_label( + f'Scanning Directories for New Files...\n{x+1} File{"s" if x+1 != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found' + ) + ) + r = CustomRunnable(lambda: iterator.run()) + # r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items(''))) + # vvv This one runs the macros when adding new files to the library. + r.done.connect( + lambda: (pw.hide(), pw.deleteLater(), self.add_new_files_runnable()) + ) + QThreadPool.globalInstance().start(r) + + # def runnable(self, pb:QProgressDialog): + # for i in self.lib.refresh_dir(): + # pb.setLabelText(f'Scanning Directories for New Files...\n{i} File{"s" if i != 1 else ""} Searched, {len(self.lib.files_not_in_library)} New Files Found') + + def add_new_files_runnable(self): + """ + Threaded method that adds any known new files to the library and + initiates running default macros on them. + """ + # logging.info(f'Start ANF: {QThread.currentThread()}') + new_ids: list[int] = self.lib.add_new_files_as_entries() + # pb = QProgressDialog(f'Running Configured Macros on 1/{len(new_ids)} New Entries', None, 0,len(new_ids)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Running Macros') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() + + # r = CustomRunnable(lambda: self.new_file_macros_runnable(pb, new_ids)) + # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.filter_items(''))) + # r.run() + # # QThreadPool.globalInstance().start(r) + + # # logging.info(f'{INFO} Running configured Macros on {len(new_ids)} new Entries...') + # # self.main_window.statusbar.showMessage(f'Running configured Macros on {len(new_ids)} new Entries...', 3) + + # # pb.hide() + + iterator = FunctionIterator(lambda: self.new_file_macros_runnable(new_ids)) + pw = ProgressWidget( + window_title="Running Macros on New Entries", + label_text=f"Running Configured Macros on 1/{len(new_ids)} New Entries", + cancel_button_text=None, + minimum=0, + maximum=0, + ) + pw.show() + iterator.value.connect(lambda x: pw.update_progress(x + 1)) + iterator.value.connect( + lambda x: pw.update_label( + f"Running Configured Macros on {x+1}/{len(new_ids)} New Entries" + ) + ) + r = CustomRunnable(lambda: iterator.run()) + r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items(""))) + QThreadPool.globalInstance().start(r) + + def new_file_macros_runnable(self, new_ids): + """Threaded method that runs macros on a set of Entry IDs.""" + # sleep(1) + # logging.info(f'ANFR: {QThread.currentThread()}') + # for i, id in enumerate(new_ids): + # # pb.setValue(i) + # # pb.setLabelText(f'Running Configured Macros on {i}/{len(new_ids)} New Entries') + # # self.run_macro('autofill', id) + + # NOTE: I don't know. I don't know why it needs this. The whole program + # falls apart if this method doesn't run, and it DOESN'T DO ANYTHING + yield 0 + + # self.main_window.statusbar.showMessage('', 3) + + # sleep(5) + # pb.deleteLater() + + 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) + + def run_macro(self, name: str, entry_id: int): + """Runs a specific Macro on an Entry given a Macro name.""" + entry = self.lib.get_entry(entry_id) + path = os.path.normpath(f"{self.lib.library_dir}/{entry.path}/{entry.filename}") + source = path.split(os.sep)[1].lower() + if name == "sidecar": + self.lib.add_generic_data_to_entry( + self.core.get_gdl_sidecar(path, source), entry_id + ) + elif name == "autofill": + self.run_macro("sidecar", entry_id) + self.run_macro("build-url", entry_id) + self.run_macro("match", entry_id) + self.run_macro("clean-url", entry_id) + self.run_macro("sort-fields", entry_id) + elif name == "build-url": + data = {"source": self.core.build_url(entry_id, source)} + self.lib.add_generic_data_to_entry(data, entry_id) + elif name == "sort-fields": + order: list[int] = ( + [0] + + [1, 2] + + [9, 17, 18, 19, 20] + + [8, 7, 6] + + [4] + + [3, 21] + + [10, 14, 11, 12, 13, 22] + + [5] + ) + self.lib.sort_fields(entry_id, order) + elif name == "match": + self.core.match_conditions(entry_id) + # elif name == 'scrape': + # self.core.scrape(entry_id) + elif name == "clean-url": + # entry = self.lib.get_entry_from_index(entry_id) + if entry.fields: + for i, field in enumerate(entry.fields, start=0): + if self.lib.get_field_attr(field, "type") == "text_line": + self.lib.update_entry_field( + entry_id=entry_id, + field_index=i, + content=strip_web_protocol( + self.lib.get_field_attr(field, "content") + ), + mode="replace", + ) + + def mouse_navigation(self, event: QMouseEvent): + # print(event.button()) + if event.button() == Qt.MouseButton.ForwardButton: + self.nav_forward() + elif event.button() == Qt.MouseButton.BackButton: + self.nav_back() + + def nav_forward( + self, + frame_content: Optional[list[tuple[ItemType, int]]] = None, + page_index: int = 0, + page_count: int = 0, + ): + """Navigates a step further into the navigation stack.""" + logging.info( + f"Calling NavForward with Content:{False if not frame_content else frame_content[0]}, Index:{page_index}, PageCount:{page_count}" + ) + + # Ex. User visits | A ->[B] | + # | A B ->[C]| + # | A [B]<- C | + # |[A]<- B C | Previous routes still exist + # | A ->[D] | Stack is cut from [:A] on new route + + # Moving forward (w/ or wo/ new content) in the middle of the stack + original_pos = self.cur_frame_idx + sb: QScrollArea = self.main_window.scrollArea + sb_pos = sb.verticalScrollBar().value() + search_text = self.main_window.searchField.text() + + trimmed = False + if len(self.nav_frames) > self.cur_frame_idx + 1: + 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: + self.nav_frames.pop() + trimmed = True + self.nav_frames.append( + NavigationState( + frame_content, 0, page_index, page_count, search_text + ) + ) + # logging.info(f'Saving Text: {search_text}') + # Update the last frame's scroll_pos + 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 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() + trimmed = True + self.nav_frames.append( + NavigationState(frame_content, 0, page_index, page_count, search_text) + ) + # logging.info(f'Saving Text: {search_text}') + self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos + 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 is not None): + self.update_thumbs() + sb.verticalScrollBar().setValue( + self.nav_frames[self.cur_frame_idx].scrollbar_pos + ) + self.main_window.searchField.setText( + self.nav_frames[self.cur_frame_idx].search_text + ) + self.main_window.pagination.update_buttons( + self.nav_frames[self.cur_frame_idx].page_count, + self.nav_frames[self.cur_frame_idx].page_index, + emit=False, + ) + # logging.info(f'Setting Text: {self.nav_stack[self.cur_page_idx].search_text}') + # else: + # self.nav_stack.pop() + # self.cur_page_idx -= 1 + # self.update_thumbs() + # sb.verticalScrollBar().setValue(self.nav_stack[self.cur_page_idx].scrollbar_pos) + + # logging.info(f'Forward: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}, SB {self.nav_stack[self.cur_page_idx].scrollbar_pos}') + + def nav_back(self): + """Navigates a step backwards in the navigation stack.""" + + original_pos = self.cur_frame_idx + sb: QScrollArea = self.main_window.scrollArea + sb_pos = sb.verticalScrollBar().value() + + if self.cur_frame_idx > 0: + self.nav_frames[self.cur_frame_idx].scrollbar_pos = sb_pos + self.cur_frame_idx -= 1 + if self.cur_frame_idx != original_pos: + self.update_thumbs() + sb.verticalScrollBar().setValue( + self.nav_frames[self.cur_frame_idx].scrollbar_pos + ) + self.main_window.searchField.setText( + self.nav_frames[self.cur_frame_idx].search_text + ) + self.main_window.pagination.update_buttons( + self.nav_frames[self.cur_frame_idx].page_count, + self.nav_frames[self.cur_frame_idx].page_index, + emit=False, + ) + # logging.info(f'Setting Text: {self.nav_stack[self.cur_page_idx].search_text}') + # logging.info(f'Back: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}, SB {self.nav_stack[self.cur_page_idx].scrollbar_pos}') + + def refresh_frame( + self, + frame_content: list[tuple[ItemType, int]], + page_index: int = 0, + page_count: int = 0, + ): + """ + Refreshes the current navigation contents without altering the + navigation stack order. + """ + if self.nav_frames: + self.nav_frames[self.cur_frame_idx] = NavigationState( + frame_content, + 0, + self.nav_frames[self.cur_frame_idx].page_index, + self.nav_frames[self.cur_frame_idx].page_count, + self.main_window.searchField.text(), + ) + else: + self.nav_forward(frame_content, page_index, page_count) + self.update_thumbs() + # logging.info(f'Refresh: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}') + + def purge_item_from_navigation(self, type: ItemType, id: int): + # logging.info(self.nav_frames) + for i, frame in enumerate(self.nav_frames, start=0): + while (type, id) in frame.contents: + logging.info(f"Removing {id} from nav stack frame {i}") + frame.contents.remove((type, id)) + + for i, key in enumerate(self.frame_dict.keys(), start=0): + for frame in self.frame_dict[key]: + while (type, id) in frame: + logging.info(f"Removing {id} from frame dict item {i}") + frame.remove((type, id)) + + while (type, id) in self.selected: + logging.info(f"Removing {id} from frame selected") + self.selected.remove((type, id)) + + def _init_thumb_grid(self): + # logging.info('Initializing Thumbnail Grid...') + layout = FlowLayout() + layout.setGridEfficiency(True) + # layout.setContentsMargins(0,0,0,0) + layout.setSpacing(min(self.thumb_size // 10, 12)) + # layout = QHBoxLayout() + # layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize) + # layout = QListView() + # layout.setViewMode(QListView.ViewMode.IconMode) + + col_size = 28 + for i in range(0, self.max_results): + item_thumb = ItemThumb( + None, self.lib, self.preview_panel, (self.thumb_size, self.thumb_size) + ) + layout.addWidget(item_thumb) + self.item_thumbs.append(item_thumb) + + self.flow_container: QWidget = QWidget() + self.flow_container.setObjectName("flowContainer") + self.flow_container.setLayout(layout) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + sa: QScrollArea = self.main_window.scrollArea + sa.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + sa.setWidgetResizable(True) + sa.setWidget(self.flow_container) + + def select_item(self, type: int, id: int, append: bool, bridge: bool): + """Selects one or more items in the Thumbnail Grid.""" + if append: + # self.selected.append((thumb_index, page_index)) + if ((type, id)) not in self.selected: + self.selected.append((type, id)) + for it in self.item_thumbs: + if it.mode == type and it.item_id == id: + it.thumb_button.set_selected(True) + else: + self.selected.remove((type, id)) + for it in self.item_thumbs: + if it.mode == type and it.item_id == id: + it.thumb_button.set_selected(False) + # self.item_thumbs[thumb_index].thumb_button.set_selected(True) + + elif bridge and self.selected: + logging.info(f"Last Selected: {self.selected[-1]}") + contents = self.nav_frames[self.cur_frame_idx].contents + last_index = self.nav_frames[self.cur_frame_idx].contents.index( + self.selected[-1] + ) + current_index = self.nav_frames[self.cur_frame_idx].contents.index( + (type, id) + ) + index_range: list = contents[ + min(last_index, current_index) : max(last_index, current_index) + 1 + ] + # Preserve bridge direction for correct appending order. + if last_index < current_index: + index_range.reverse() + + # logging.info(f'Current Frame Contents: {len(self.nav_frames[self.cur_frame_idx].contents)}') + # logging.info(f'Last Selected Index: {last_index}') + # logging.info(f'Current Selected Index: {current_index}') + # logging.info(f'Index Range: {index_range}') + + for c_type, c_id in index_range: + for it in self.item_thumbs: + if it.mode == c_type and it.item_id == c_id: + it.thumb_button.set_selected(True) + if ((c_type, c_id)) not in self.selected: + self.selected.append((c_type, c_id)) + else: + # for i in self.selected: + # if i[1] == self.cur_frame_idx: + # self.item_thumbs[i[0]].thumb_button.set_selected(False) + self.selected.clear() + # self.selected.append((thumb_index, page_index)) + self.selected.append((type, id)) + # self.item_thumbs[thumb_index].thumb_button.set_selected(True) + for it in self.item_thumbs: + if it.mode == type and it.item_id == id: + it.thumb_button.set_selected(True) + else: + it.thumb_button.set_selected(False) + + # NOTE: By using the preview panel's "set_tags_updated_slot" method, + # only the last of multiple identical item selections are connected. + # If attaching the slot to multiple duplicate selections is needed, + # just bypass the method and manually disconnect and connect the slots. + if len(self.selected) == 1: + for it in self.item_thumbs: + if it.mode == type and it.item_id == id: + self.preview_panel.set_tags_updated_slot(it.update_badges) + + self.set_macro_menu_viability() + self.preview_panel.update_widgets() + + def set_macro_menu_viability(self): + if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0: + self.autofill_action.setDisabled(True) + self.sort_fields_action.setDisabled(True) + else: + self.autofill_action.setDisabled(False) + self.sort_fields_action.setDisabled(False) + + def update_thumbs(self): + """Updates search thumbnails.""" + # start_time = time.time() + # logging.info(f'Current Page: {self.cur_page_idx}, Stack Length:{len(self.nav_stack)}') + with self.thumb_job_queue.mutex: + # Cancels all thumb jobs waiting to be started + self.thumb_job_queue.queue.clear() + self.thumb_job_queue.all_tasks_done.notify_all() + self.thumb_job_queue.not_full.notify_all() + # Stops in-progress jobs from finishing + ItemThumb.update_cutoff = time.time() + + ratio: float = self.main_window.devicePixelRatio() + base_size: tuple[int, int] = (self.thumb_size, self.thumb_size) + + for i, item_thumb in enumerate(self.item_thumbs, start=0): + if i < len(self.nav_frames[self.cur_frame_idx].contents): + # Set new item type modes + # logging.info(f'[UPDATE] Setting Mode To: {self.nav_stack[self.cur_page_idx].contents[i][0]}') + item_thumb.set_mode(self.nav_frames[self.cur_frame_idx].contents[i][0]) + item_thumb.ignore_size = False + # logging.info(f'[UPDATE] Set Mode To: {item.mode}') + # Set thumbnails to loading (will always finish if rendering) + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (sys.float_info.max, "", base_size, ratio, True), + ) + ) + # # Restore Selected Borders + # if (item_thumb.mode, item_thumb.item_id) in self.selected: + # item_thumb.thumb_button.set_selected(True) + # else: + # item_thumb.thumb_button.set_selected(False) + else: + item_thumb.ignore_size = True + item_thumb.set_mode(None) + item_thumb.set_item_id(-1) + item_thumb.thumb_button.set_selected(False) + + # scrollbar: QScrollArea = self.main_window.scrollArea + # scrollbar.verticalScrollBar().setValue(scrollbar_pos) + self.flow_container.layout().update() + self.main_window.update() + + for i, item_thumb in enumerate(self.item_thumbs, start=0): + if i < len(self.nav_frames[self.cur_frame_idx].contents): + filepath = "" + if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY: + entry = self.lib.get_entry( + self.nav_frames[self.cur_frame_idx].contents[i][1] + ) + filepath = os.path.normpath( + f"{self.lib.library_dir}/{entry.path}/{entry.filename}" + ) + + item_thumb.set_item_id(entry.id) + item_thumb.assign_archived(entry.has_tag(self.lib, 0)) + item_thumb.assign_favorite(entry.has_tag(self.lib, 1)) + # ctrl_down = True if QGuiApplication.keyboardModifiers() else False + # TODO: Change how this works. The click function + # for collations a few lines down should NOT be allowed during modifier keys. + item_thumb.update_clickable( + clickable=( + lambda checked=False, entry=entry: self.select_item( + ItemType.ENTRY, + entry.id, + append=True + if QGuiApplication.keyboardModifiers() + == Qt.KeyboardModifier.ControlModifier + else False, + bridge=True + if QGuiApplication.keyboardModifiers() + == Qt.KeyboardModifier.ShiftModifier + else False, + ) + ) + ) + # item_thumb.update_clickable(clickable=( + # lambda checked=False, filepath=filepath, entry=entry, + # item_t=item_thumb, i=i, page=self.cur_frame_idx: ( + # self.preview_panel.update_widgets(entry), + # self.select_item(ItemType.ENTRY, entry.id, + # append=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier else False, + # bridge=True if QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier else False)))) + # item.dumpObjectTree() + elif ( + self.nav_frames[self.cur_frame_idx].contents[i][0] + == ItemType.COLLATION + ): + collation = self.lib.get_collation( + self.nav_frames[self.cur_frame_idx].contents[i][1] + ) + cover_id = ( + collation.cover_id + if collation.cover_id >= 0 + else collation.e_ids_and_pages[0][0] + ) + cover_e = self.lib.get_entry(cover_id) + filepath = os.path.normpath( + f"{self.lib.library_dir}/{cover_e.path}/{cover_e.filename}" + ) + item_thumb.set_count(str(len(collation.e_ids_and_pages))) + item_thumb.update_clickable( + clickable=( + lambda checked=False, + filepath=filepath, + entry=cover_e, + collation=collation: ( + self.expand_collation(collation.e_ids_and_pages) + ) + ) + ) + # item.setHidden(False) + + # Restore Selected Borders + if (item_thumb.mode, item_thumb.item_id) in self.selected: + item_thumb.thumb_button.set_selected(True) + else: + item_thumb.thumb_button.set_selected(False) + + self.thumb_job_queue.put( + ( + item_thumb.renderer.render, + (time.time(), filepath, base_size, ratio, False), + ) + ) + else: + # item.setHidden(True) + pass + # update_widget_clickable(widget=item.bg_button, clickable=()) + # self.thumb_job_queue.put( + # (item.renderer.render, ('', base_size, ratio, False))) + + # end_time = time.time() + # 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 = 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=""): + if self.lib: + # logging.info('Filtering...') + self.main_window.statusbar.showMessage( + f'Searching Library for "{query}"...' + ) + self.main_window.statusbar.repaint() + start_time = time.time() + + # self.filtered_items = self.lib.search_library(query) + # 73601 Entries at 500 size should be 246 + all_items = self.lib.search_library(query) + frames = [] + frame_count = math.ceil(len(all_items) / self.max_results) + for i in range(0, frame_count): + frames.append( + all_items[ + min(len(all_items) - 1, (i) * self.max_results) : min( + len(all_items), (i + 1) * self.max_results + ) + ] + ) + for i, f in enumerate(frames): + logging.info(f"Query:{query}, Frame: {i}, Length: {len(f)}") + self.frame_dict[query] = frames + # self.frame_dict[query] = [all_items] + + if self.cur_query == query: + # self.refresh_frame(self.lib.search_library(query)) + # NOTE: Trying to refresh instead of navigating forward here + # now creates a bug when the page counts differ on refresh. + # If refreshing is absolutely desired, see how to update + # page counts where they need to be updated. + self.nav_forward(*self.get_frame_contents(0, query)) + else: + # self.nav_forward(self.lib.search_library(query)) + self.nav_forward(*self.get_frame_contents(0, query)) + self.cur_query = query + + end_time = time.time() + if query: + self.main_window.statusbar.showMessage( + f'{len(all_items)} Results Found for "{query}" ({format_timespan(end_time - start_time)})' + ) + else: + self.main_window.statusbar.showMessage( + f"{len(all_items)} Results ({format_timespan(end_time - start_time)})" + ) + # logging.info(f'Done Filtering! ({(end_time - start_time):.3f}) seconds') + + # self.update_thumbs() + + def open_library(self, path): + """Opens a TagStudio library.""" + if self.lib.library_dir: + self.save_library() + self.lib.clear_internal_vars() + + self.main_window.statusbar.showMessage(f"Opening Library {path}", 3) + return_code = self.lib.open_library(path) + if return_code == 1: + # if self.args.external_preview: + # self.init_external_preview() + + # if len(self.lib.entries) <= 1000: + # print(f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...') + # self.lib.refresh_missing_files() + # title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\'' + # self.main_window.setWindowTitle(title_text) + pass + + else: + logging.info( + f"{ERROR} No existing TagStudio library found at '{path}'. Creating one." + ) + print(f"Library Creation Return Code: {self.lib.create_library(path)}") + self.add_new_files_callback() + + title_text = f"{self.base_title} - Library '{self.lib.library_dir}'" + self.main_window.setWindowTitle(title_text) + + self.nav_frames: list[NavigationState] = [] + self.cur_frame_idx: int = -1 + self.cur_query: str = "" + self.selected.clear() + self.preview_panel.update_widgets() + self.filter_items() + + def create_collage(self) -> None: + """Generates and saves an image collage based on Library Entries.""" + + run: bool = True + keep_aspect: bool = False + data_only_mode: bool = False + data_tint_mode: bool = False + + self.main_window.statusbar.showMessage(f"Creating Library Collage...") + self.collage_start_time = time.time() + + # mode:int = self.scr_choose_option(subtitle='Choose Collage Mode(s)', + # choices=[ + # ('Normal','Creates a standard square image collage made up of Library media files.'), + # ('Data Tint','Tints the collage with a color representing data about the Library Entries/files.'), + # ('Data Only','Ignores media files entirely and only outputs a collage of Library Entry/file data.'), + # ('Normal & Data Only','Creates both Normal and Data Only collages.'), + # ], prompt='', required=True) + mode = 0 + + if mode == 1: + data_tint_mode = True + + if mode == 2: + data_only_mode = True + + if mode in [0, 1, 3]: + # keep_aspect = self.scr_choose_option( + # subtitle='Choose Aspect Ratio Option', + # choices=[ + # ('Stretch to Fill','Stretches the media file to fill the entire collage square.'), + # ('Keep Aspect Ratio','Keeps the original media file\'s aspect ratio, filling the rest of the square with black bars.') + # ], prompt='', required=True) + keep_aspect = 0 + + if mode in [1, 2, 3]: + # TODO: Choose data visualization options here. + pass + + full_thumb_size: int = 1 + + if mode in [0, 1, 3]: + # full_thumb_size = self.scr_choose_option( + # subtitle='Choose Thumbnail Size', + # choices=[ + # ('Tiny (32px)',''), + # ('Small (64px)',''), + # ('Medium (128px)',''), + # ('Large (256px)',''), + # ('Extra Large (512px)','') + # ], prompt='', required=True) + full_thumb_size = 0 + + thumb_size: int = ( + 32 + if (full_thumb_size == 0) + else 64 + if (full_thumb_size == 1) + else 128 + if (full_thumb_size == 2) + else 256 + if (full_thumb_size == 3) + else 512 + if (full_thumb_size == 4) + else 32 + ) + thumb_size = 16 + + # if len(com) > 1 and com[1] == 'keep-aspect': + # keep_aspect = True + # elif len(com) > 1 and com[1] == 'data-only': + # data_only_mode = True + # elif len(com) > 1 and com[1] == 'data-tint': + # data_tint_mode = True + grid_size = math.ceil(math.sqrt(len(self.lib.entries))) ** 2 + grid_len = math.floor(math.sqrt(grid_size)) + thumb_size = thumb_size if not data_only_mode else 1 + img_size = thumb_size * grid_len + + logging.info( + f"Creating collage for {len(self.lib.entries)} Entries.\nGrid Size: {grid_size} ({grid_len}x{grid_len})\nIndividual Picture Size: ({thumb_size}x{thumb_size})" + ) + if keep_aspect: + logging.info("Keeping original aspect ratios.") + if data_only_mode: + logging.info("Visualizing Entry Data") + + if not data_only_mode: + time.sleep(5) + + self.collage = Image.new("RGB", (img_size, img_size)) + i = 0 + self.completed = 0 + for x in range(0, grid_len): + for y in range(0, grid_len): + if i < len(self.lib.entries) and run: + # if i < 5 and run: + + entry_id = self.lib.entries[i].id + renderer = CollageIconRenderer(self.lib) + renderer.rendered.connect( + lambda image, x=x, y=y: self.collage.paste( + image, (y * thumb_size, x * thumb_size) + ) + ) + renderer.done.connect(lambda: self.try_save_collage(True)) + self.thumb_job_queue.put( + ( + renderer.render, + ( + entry_id, + (thumb_size, thumb_size), + data_tint_mode, + data_only_mode, + keep_aspect, + ), + ) + ) + i = i + 1 + + def try_save_collage(self, increment_progress: bool): + if increment_progress: + 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_{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)})' + ) diff --git a/tagstudio/src/qt/widgets/__init__.py b/tagstudio/src/qt/widgets/__init__.py index 058291a..70bc7e0 100644 --- a/tagstudio/src/qt/widgets/__init__.py +++ b/tagstudio/src/qt/widgets/__init__.py @@ -10,4 +10,4 @@ from .tag import TagWidget from .tag_box import TagBoxWidget from .text import TextWidget from .item_thumb import ItemThumb -from .preview_panel import PreviewPanel \ No newline at end of file +from .preview_panel import PreviewPanel diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index ee43521..0b1bb01 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -9,133 +9,171 @@ from pathlib import Path import cv2 from PIL import Image, ImageChops, UnidentifiedImageError -from PySide6.QtCore import QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QTimer, QSettings +from PySide6.QtCore import ( + QObject, + QThread, + Signal, + QRunnable, + Qt, + QThreadPool, + QSize, + QEvent, + QTimer, + QSettings, +) from src.core.library import Library from src.core.ts_core import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class CollageIconRenderer(QObject): - rendered = Signal(Image.Image) - done = Signal() + rendered = Signal(Image.Image) + done = Signal() - def __init__(self, library:Library): - QObject.__init__(self) - self.lib = library - - def render(self, entry_id, size:tuple[int,int], data_tint_mode, data_only_mode, keep_aspect): - entry = self.lib.get_entry(entry_id) - filepath = os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}') - file_type = os.path.splitext(filepath)[1].lower()[1:] - color: str = '' + def __init__(self, library: Library): + QObject.__init__(self) + self.lib = library - try: - if data_tint_mode or data_only_mode: - color = '#000000' # Black (Default) - - if entry.fields: - has_any_tags:bool = False - has_content_tags:bool = False - has_meta_tags:bool = False - for field in entry.fields: - if self.lib.get_field_attr(field, 'type') == 'tag_box': - if self.lib.get_field_attr(field, 'content'): - has_any_tags = True - if self.lib.get_field_attr(field, 'id') == 7: - has_content_tags = True - elif self.lib.get_field_attr(field, 'id') == 8: - has_meta_tags = True - if has_content_tags and has_meta_tags: - color = '#28bb48' # Green - elif has_any_tags: - color = '#ffd63d' # Yellow - # color = '#95e345' # Yellow-Green - else: - # color = '#fa9a2c' # Yellow-Orange - color = '#ed8022' # Orange - else: - color = '#e22c3c' # Red + def render( + self, + entry_id, + size: tuple[int, int], + data_tint_mode, + data_only_mode, + keep_aspect, + ): + entry = self.lib.get_entry(entry_id) + filepath = os.path.normpath( + f"{self.lib.library_dir}/{entry.path}/{entry.filename}" + ) + file_type = os.path.splitext(filepath)[1].lower()[1:] + color: str = "" - if data_only_mode: - pic: Image = Image.new('RGB', size, color) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) - self.rendered.emit(pic) - if not data_only_mode: - logging.info(f'\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}\033[0m') - # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') - # sys.stdout.flush() - if file_type in IMAGE_TYPES: - with Image.open(os.path.normpath(f'{self.lib.library_dir}/{entry.path}/{entry.filename}')) as pic: - if keep_aspect: - pic.thumbnail(size) - else: - pic = pic.resize(size) - if data_tint_mode and color: - pic = pic.convert(mode='RGB') - pic = ImageChops.hard_light(pic, Image.new('RGB', size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) - self.rendered.emit(pic) - elif file_type in VIDEO_TYPES: - video = cv2.VideoCapture(filepath) - video.set(cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2)) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - with Image.fromarray(frame, mode='RGB') as pic: - if keep_aspect: - pic.thumbnail(size) - else: - pic = pic.resize(size) - if data_tint_mode and color: - pic = ImageChops.hard_light(pic, Image.new('RGB', size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) - self.rendered.emit(pic) - except (UnidentifiedImageError, FileNotFoundError): - logging.info(f'\n{ERROR} Couldn\'t read {entry.path}{os.sep}{entry.filename}') - with Image.open(os.path.normpath(f'{Path(__file__).parent.parent.parent}/resources/qt/images/thumb_broken_512.png')) as pic: - pic.thumbnail(size) - if data_tint_mode and color: - pic = pic.convert(mode='RGB') - pic = ImageChops.hard_light(pic, Image.new('RGB', size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) - self.rendered.emit(pic) - except KeyboardInterrupt: - # self.quit(save=False, backup=True) - run = False - # clear() - logging.info('\n') - logging.info(f'{INFO} Collage operation cancelled.') - clear_scr=False - except: - logging.info(f'{ERROR} {entry.path}{os.sep}{entry.filename}') - traceback.print_exc() - logging.info('Continuing...') - - self.done.emit() - # logging.info('Done!') - - def get_file_color(self, ext: str): - if ext.lower().replace('.','',1) == 'gif': - return '\033[93m' - if ext.lower().replace('.','',1) in IMAGE_TYPES: - return '\033[37m' - elif ext.lower().replace('.','',1) in VIDEO_TYPES: - return '\033[96m' - elif ext.lower().replace('.','',1) in DOC_TYPES: - return '\033[92m' - else: - return '\033[97m' + try: + if data_tint_mode or data_only_mode: + color = "#000000" # Black (Default) + + if entry.fields: + has_any_tags: bool = False + has_content_tags: bool = False + has_meta_tags: bool = False + for field in entry.fields: + if self.lib.get_field_attr(field, "type") == "tag_box": + if self.lib.get_field_attr(field, "content"): + has_any_tags = True + if self.lib.get_field_attr(field, "id") == 7: + has_content_tags = True + elif self.lib.get_field_attr(field, "id") == 8: + has_meta_tags = True + if has_content_tags and has_meta_tags: + color = "#28bb48" # Green + elif has_any_tags: + color = "#ffd63d" # Yellow + # color = '#95e345' # Yellow-Green + else: + # color = '#fa9a2c' # Yellow-Orange + color = "#ed8022" # Orange + else: + color = "#e22c3c" # Red + + if data_only_mode: + pic: Image = Image.new("RGB", size, color) + # collage.paste(pic, (y*thumb_size, x*thumb_size)) + self.rendered.emit(pic) + if not data_only_mode: + logging.info( + f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}\033[0m" + ) + # sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}') + # sys.stdout.flush() + if file_type in IMAGE_TYPES: + with Image.open( + os.path.normpath( + f"{self.lib.library_dir}/{entry.path}/{entry.filename}" + ) + ) as pic: + if keep_aspect: + pic.thumbnail(size) + else: + pic = pic.resize(size) + if data_tint_mode and color: + pic = pic.convert(mode="RGB") + pic = ImageChops.hard_light( + pic, Image.new("RGB", size, color) + ) + # collage.paste(pic, (y*thumb_size, x*thumb_size)) + self.rendered.emit(pic) + elif file_type in VIDEO_TYPES: + video = cv2.VideoCapture(filepath) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + with Image.fromarray(frame, mode="RGB") as pic: + if keep_aspect: + pic.thumbnail(size) + else: + pic = pic.resize(size) + if data_tint_mode and color: + pic = ImageChops.hard_light( + pic, Image.new("RGB", size, color) + ) + # collage.paste(pic, (y*thumb_size, x*thumb_size)) + self.rendered.emit(pic) + except (UnidentifiedImageError, FileNotFoundError): + logging.info( + f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}" + ) + with Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent}/resources/qt/images/thumb_broken_512.png" + ) + ) as pic: + pic.thumbnail(size) + if data_tint_mode and color: + pic = pic.convert(mode="RGB") + pic = ImageChops.hard_light(pic, Image.new("RGB", size, color)) + # collage.paste(pic, (y*thumb_size, x*thumb_size)) + self.rendered.emit(pic) + except KeyboardInterrupt: + # self.quit(save=False, backup=True) + run = False + # clear() + logging.info("\n") + logging.info(f"{INFO} Collage operation cancelled.") + clear_scr = False + except: + logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}") + traceback.print_exc() + logging.info("Continuing...") + + self.done.emit() + # logging.info('Done!') + + def get_file_color(self, ext: str): + if ext.lower().replace(".", "", 1) == "gif": + return "\033[93m" + if ext.lower().replace(".", "", 1) in IMAGE_TYPES: + return "\033[37m" + elif ext.lower().replace(".", "", 1) in VIDEO_TYPES: + return "\033[96m" + elif ext.lower().replace(".", "", 1) in DOC_TYPES: + return "\033[92m" + else: + return "\033[97m" diff --git a/tagstudio/src/qt/widgets/fields.py b/tagstudio/src/qt/widgets/fields.py index e3ed20c..7ed4eb7 100644 --- a/tagstudio/src/qt/widgets/fields.py +++ b/tagstudio/src/qt/widgets/fields.py @@ -10,190 +10,203 @@ from pathlib import Path from typing import Optional from PIL import Image, ImageQt -from PySide6.QtCore import Qt,QEvent +from PySide6.QtCore import Qt, QEvent from PySide6.QtGui import QPixmap, QEnterEvent from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton + class FieldContainer(QWidget): - # TODO: reference a resources folder rather than path.parent.parent.parent.parent? - clipboard_icon_128: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/clipboard_icon_128.png')).resize((math.floor(24*1.25),math.floor(24*1.25))) - clipboard_icon_128.load() + # TODO: reference a resources folder rather than path.parent.parent.parent.parent? + clipboard_icon_128: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/clipboard_icon_128.png" + ) + ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) + clipboard_icon_128.load() - edit_icon_128: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/edit_icon_128.png')).resize((math.floor(24*1.25),math.floor(24*1.25))) - edit_icon_128.load() + edit_icon_128: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/edit_icon_128.png" + ) + ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) + edit_icon_128.load() - trash_icon_128: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/trash_icon_128.png')).resize((math.floor(24*1.25),math.floor(24*1.25))) - trash_icon_128.load() - - def __init__(self, title:str='Field', inline:bool=True) -> None: - super().__init__() - # self.mode:str = mode - self.setObjectName('fieldContainer') - # self.item = item - self.title:str = title - self.inline:bool = inline - # self.editable:bool = editable - self.copy_callback:FunctionType = None - self.edit_callback:FunctionType = None - self.remove_callback:FunctionType = None - button_size = 24 - # self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;') + trash_icon_128: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/trash_icon_128.png" + ) + ).resize((math.floor(24 * 1.25), math.floor(24 * 1.25))) + trash_icon_128.load() + def __init__(self, title: str = "Field", inline: bool = True) -> None: + super().__init__() + # self.mode:str = mode + self.setObjectName("fieldContainer") + # self.item = item + self.title: str = title + self.inline: bool = inline + # self.editable:bool = editable + self.copy_callback: FunctionType = None + self.edit_callback: FunctionType = None + self.remove_callback: FunctionType = None + button_size = 24 + # self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;') - self.root_layout = QVBoxLayout(self) - self.root_layout.setObjectName('baseLayout') - self.root_layout.setContentsMargins(0, 0, 0, 0) - # self.setStyleSheet('background-color:red;') + self.root_layout = QVBoxLayout(self) + self.root_layout.setObjectName("baseLayout") + self.root_layout.setContentsMargins(0, 0, 0, 0) + # self.setStyleSheet('background-color:red;') - self.inner_layout = QVBoxLayout() - self.inner_layout.setObjectName('innerLayout') - self.inner_layout.setContentsMargins(0,0,0,0) - self.inner_layout.setSpacing(0) - self.inner_container = QWidget() - self.inner_container.setObjectName('innerContainer') - self.inner_container.setLayout(self.inner_layout) - self.root_layout.addWidget(self.inner_container) + self.inner_layout = QVBoxLayout() + self.inner_layout.setObjectName("innerLayout") + self.inner_layout.setContentsMargins(0, 0, 0, 0) + self.inner_layout.setSpacing(0) + self.inner_container = QWidget() + self.inner_container.setObjectName("innerContainer") + self.inner_container.setLayout(self.inner_layout) + self.root_layout.addWidget(self.inner_container) - self.title_container = QWidget() - # self.title_container.setStyleSheet('background:black;') - self.title_layout = QHBoxLayout(self.title_container) - self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) - self.title_layout.setObjectName('fieldLayout') - self.title_layout.setContentsMargins(0,0,0,0) - self.title_layout.setSpacing(0) - self.inner_layout.addWidget(self.title_container) + self.title_container = QWidget() + # self.title_container.setStyleSheet('background:black;') + self.title_layout = QHBoxLayout(self.title_container) + self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_layout.setObjectName("fieldLayout") + self.title_layout.setContentsMargins(0, 0, 0, 0) + self.title_layout.setSpacing(0) + self.inner_layout.addWidget(self.title_container) - self.title_widget = QLabel() - self.title_widget.setMinimumHeight(button_size) - self.title_widget.setObjectName('fieldTitle') - self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet('font-weight: bold; font-size: 14px;') - # self.title_widget.setStyleSheet('background-color:orange;') - self.title_widget.setText(title) - # self.inner_layout.addWidget(self.title_widget) - self.title_layout.addWidget(self.title_widget) + self.title_widget = QLabel() + self.title_widget.setMinimumHeight(button_size) + self.title_widget.setObjectName("fieldTitle") + self.title_widget.setWordWrap(True) + self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;") + # self.title_widget.setStyleSheet('background-color:orange;') + self.title_widget.setText(title) + # self.inner_layout.addWidget(self.title_widget) + self.title_layout.addWidget(self.title_widget) - self.title_layout.addStretch(2) + self.title_layout.addStretch(2) + self.copy_button = QPushButton() + self.copy_button.setMinimumSize(button_size, button_size) + self.copy_button.setMaximumSize(button_size, button_size) + self.copy_button.setFlat(True) + self.copy_button.setIcon( + QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128)) + ) + self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.title_layout.addWidget(self.copy_button) + self.copy_button.setHidden(True) - self.copy_button = QPushButton() - self.copy_button.setMinimumSize(button_size,button_size) - self.copy_button.setMaximumSize(button_size,button_size) - self.copy_button.setFlat(True) - self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128))) - self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_layout.addWidget(self.copy_button) - self.copy_button.setHidden(True) + self.edit_button = QPushButton() + self.edit_button.setMinimumSize(button_size, button_size) + self.edit_button.setMaximumSize(button_size, button_size) + self.edit_button.setFlat(True) + self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128))) + self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.title_layout.addWidget(self.edit_button) + self.edit_button.setHidden(True) - self.edit_button = QPushButton() - self.edit_button.setMinimumSize(button_size,button_size) - self.edit_button.setMaximumSize(button_size,button_size) - self.edit_button.setFlat(True) - self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128))) - self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_layout.addWidget(self.edit_button) - self.edit_button.setHidden(True) + self.remove_button = QPushButton() + self.remove_button.setMinimumSize(button_size, button_size) + self.remove_button.setMaximumSize(button_size, button_size) + self.remove_button.setFlat(True) + self.remove_button.setIcon( + QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128)) + ) + self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.title_layout.addWidget(self.remove_button) + self.remove_button.setHidden(True) - self.remove_button = QPushButton() - self.remove_button.setMinimumSize(button_size,button_size) - self.remove_button.setMaximumSize(button_size,button_size) - self.remove_button.setFlat(True) - self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128))) - self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_layout.addWidget(self.remove_button) - self.remove_button.setHidden(True) + self.field_container = QWidget() + self.field_container.setObjectName("fieldContainer") + self.field_layout = QHBoxLayout() + self.field_layout.setObjectName("fieldLayout") + self.field_layout.setContentsMargins(0, 0, 0, 0) + self.field_container.setLayout(self.field_layout) + # self.field_container.setStyleSheet('background-color:#666600;') + self.inner_layout.addWidget(self.field_container) - self.field_container = QWidget() - self.field_container.setObjectName('fieldContainer') - self.field_layout = QHBoxLayout() - self.field_layout.setObjectName('fieldLayout') - self.field_layout.setContentsMargins(0,0,0,0) - self.field_container.setLayout(self.field_layout) - # self.field_container.setStyleSheet('background-color:#666600;') - self.inner_layout.addWidget(self.field_container) + # self.set_inner_widget(mode) - # self.set_inner_widget(mode) + def set_copy_callback(self, callback: Optional[FunctionType]): + try: + self.copy_button.clicked.disconnect() + except RuntimeError: + pass - def set_copy_callback(self, callback:Optional[FunctionType]): - try: - self.copy_button.clicked.disconnect() - except RuntimeError: - pass + self.copy_callback = callback + self.copy_button.clicked.connect(callback) - self.copy_callback = callback - self.copy_button.clicked.connect(callback) + def set_edit_callback(self, callback: Optional[FunctionType]): + try: + self.edit_button.clicked.disconnect() + except RuntimeError: + pass - def set_edit_callback(self, callback:Optional[FunctionType]): - try: - self.edit_button.clicked.disconnect() - except RuntimeError: - pass + self.edit_callback = callback + self.edit_button.clicked.connect(callback) - self.edit_callback = callback - self.edit_button.clicked.connect(callback) + def set_remove_callback(self, callback: Optional[FunctionType]): + try: + self.remove_button.clicked.disconnect() + except RuntimeError: + pass - def set_remove_callback(self, callback:Optional[FunctionType]): - try: - self.remove_button.clicked.disconnect() - except RuntimeError: - pass + self.remove_callback = callback + self.remove_button.clicked.connect(callback) - self.remove_callback = callback - self.remove_button.clicked.connect(callback) - - def set_inner_widget(self, widget:'FieldWidget'): - # widget.setStyleSheet('background-color:green;') - # self.inner_container.dumpObjectTree() - # logging.info('') - if self.field_layout.itemAt(0): - # logging.info(f'Removing {self.field_layout.itemAt(0)}') - # self.field_layout.removeItem(self.field_layout.itemAt(0)) - self.field_layout.itemAt(0).widget().deleteLater() - self.field_layout.addWidget(widget) - - def get_inner_widget(self) -> Optional['FieldWidget']: - if self.field_layout.itemAt(0): - return self.field_layout.itemAt(0).widget() - return None + def set_inner_widget(self, widget: "FieldWidget"): + # widget.setStyleSheet('background-color:green;') + # self.inner_container.dumpObjectTree() + # logging.info('') + if self.field_layout.itemAt(0): + # logging.info(f'Removing {self.field_layout.itemAt(0)}') + # self.field_layout.removeItem(self.field_layout.itemAt(0)) + self.field_layout.itemAt(0).widget().deleteLater() + self.field_layout.addWidget(widget) - def set_title(self, title:str): - self.title = title - self.title_widget.setText(title) - - def set_inline(self, inline:bool): - self.inline = inline - - # def set_editable(self, editable:bool): - # self.editable = editable - - def enterEvent(self, event: QEnterEvent) -> None: - # if self.field_layout.itemAt(1): - # self.field_layout.itemAt(1). - # NOTE: You could pass the hover event to the FieldWidget if needed. - if self.copy_callback: - self.copy_button.setHidden(False) - if self.edit_callback: - self.edit_button.setHidden(False) - if self.remove_callback: - self.remove_button.setHidden(False) - return super().enterEvent(event) + def get_inner_widget(self) -> Optional["FieldWidget"]: + if self.field_layout.itemAt(0): + return self.field_layout.itemAt(0).widget() + return None - def leaveEvent(self, event: QEvent) -> None: - if self.copy_callback: - self.copy_button.setHidden(True) - if self.edit_callback: - self.edit_button.setHidden(True) - if self.remove_callback: - self.remove_button.setHidden(True) - return super().leaveEvent(event) + def set_title(self, title: str): + self.title = title + self.title_widget.setText(title) + + def set_inline(self, inline: bool): + self.inline = inline + + # def set_editable(self, editable:bool): + # self.editable = editable + + def enterEvent(self, event: QEnterEvent) -> None: + # if self.field_layout.itemAt(1): + # self.field_layout.itemAt(1). + # NOTE: You could pass the hover event to the FieldWidget if needed. + if self.copy_callback: + self.copy_button.setHidden(False) + if self.edit_callback: + self.edit_button.setHidden(False) + if self.remove_callback: + self.remove_button.setHidden(False) + return super().enterEvent(event) + + def leaveEvent(self, event: QEvent) -> None: + if self.copy_callback: + self.copy_button.setHidden(True) + if self.edit_callback: + self.edit_button.setHidden(True) + if self.remove_callback: + self.remove_button.setHidden(True) + return super().leaveEvent(event) class FieldWidget(QWidget): - field = dict - def __init__(self, title) -> None: - super().__init__() - # self.item = item - self.title = title + field = dict + + def __init__(self, title) -> None: + super().__init__() + # self.item = item + self.title = title diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 9aff370..1c5f405 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -14,7 +14,14 @@ from typing import Optional from PIL import Image, ImageQt from PySide6.QtCore import Qt, QSize, QEvent from PySide6.QtGui import QPixmap, QEnterEvent, QAction -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QBoxLayout, QCheckBox +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QBoxLayout, + QCheckBox, +) from src.core.library import ItemType, Library from src.core.ts_core import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES @@ -23,461 +30,514 @@ from src.qt.helpers import FileOpenerHelper from src.qt.widgets import ThumbRenderer, ThumbButton if typing.TYPE_CHECKING: - from src.qt.widgets import PreviewPanel + from src.qt.widgets import PreviewPanel -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class ItemThumb(FlowWidget): - """ - The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.). - """ - update_cutoff: float = time.time() + """ + The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.). + """ - collation_icon_128: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/collation_icon_128.png')) - collation_icon_128.load() + update_cutoff: float = time.time() - tag_group_icon_128: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/tag_group_icon_128.png')) - tag_group_icon_128.load() + collation_icon_128: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/collation_icon_128.png" + ) + ) + collation_icon_128.load() - small_text_style = ( - f'background-color:rgba(0, 0, 0, 128);' - f'font-family:Oxanium;' - f'font-weight:bold;' - f'font-size:12px;' - f'border-radius:3px;' - f'padding-top: 4px;' - f'padding-right: 1px;' - f'padding-bottom: 1px;' - f'padding-left: 1px;' - ) + tag_group_icon_128: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/tag_group_icon_128.png" + ) + ) + tag_group_icon_128.load() - med_text_style = ( - f'background-color:rgba(17, 15, 27, 192);' - f'font-family:Oxanium;' - f'font-weight:bold;' - f'font-size:18px;' - f'border-radius:3px;' - f'padding-top: 4px;' - f'padding-right: 1px;' - f'padding-bottom: 1px;' - f'padding-left: 1px;' - ) + small_text_style = ( + f"background-color:rgba(0, 0, 0, 128);" + f"font-family:Oxanium;" + f"font-weight:bold;" + f"font-size:12px;" + f"border-radius:3px;" + f"padding-top: 4px;" + f"padding-right: 1px;" + f"padding-bottom: 1px;" + f"padding-left: 1px;" + ) - def __init__(self, mode: Optional[ItemType], library: Library, panel: 'PreviewPanel', thumb_size: tuple[int, int]): - """Modes: entry, collation, tag_group""" - super().__init__() - self.lib = library - self.panel = panel - self.mode = mode - self.item_id: int = -1 - self.isFavorite: bool = False - self.isArchived: bool = False - self.thumb_size:tuple[int,int]= thumb_size - self.setMinimumSize(*thumb_size) - self.setMaximumSize(*thumb_size) - check_size = 24 - # self.setStyleSheet('background-color:red;') + med_text_style = ( + f"background-color:rgba(17, 15, 27, 192);" + f"font-family:Oxanium;" + f"font-weight:bold;" + f"font-size:18px;" + f"border-radius:3px;" + f"padding-top: 4px;" + f"padding-right: 1px;" + f"padding-bottom: 1px;" + f"padding-left: 1px;" + ) - # +----------+ - # | ARC FAV| Top Right: Favorite & Archived Badges - # | | - # | | - # |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon - # +----------+ Lower Right: Collation Count, Video Length, or Word Count + def __init__( + self, + mode: Optional[ItemType], + library: Library, + panel: "PreviewPanel", + thumb_size: tuple[int, int], + ): + """Modes: entry, collation, tag_group""" + super().__init__() + self.lib = library + self.panel = panel + self.mode = mode + self.item_id: int = -1 + self.isFavorite: bool = False + self.isArchived: bool = False + self.thumb_size: tuple[int, int] = thumb_size + self.setMinimumSize(*thumb_size) + self.setMaximumSize(*thumb_size) + check_size = 24 + # self.setStyleSheet('background-color:red;') - # Thumbnail ============================================================ + # +----------+ + # | ARC FAV| Top Right: Favorite & Archived Badges + # | | + # | | + # |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon + # +----------+ Lower Right: Collation Count, Video Length, or Word Count - # +----------+ - # |*--------*| - # || || - # || || - # |*--------*| - # +----------+ - self.base_layout = QVBoxLayout(self) - self.base_layout.setObjectName('baseLayout') - # self.base_layout.setRowStretch(1, 2) - self.base_layout.setContentsMargins(0, 0, 0, 0) + # Thumbnail ============================================================ - # +----------+ - # |[~~~~~~~~]| - # | | - # | | - # | | - # +----------+ - self.top_layout = QHBoxLayout() - self.top_layout.setObjectName('topLayout') - # self.top_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # self.top_layout.setColumnStretch(1, 2) - self.top_layout.setContentsMargins(6, 6, 6, 6) - self.top_container = QWidget() - self.top_container.setLayout(self.top_layout) - self.base_layout.addWidget(self.top_container) + # +----------+ + # |*--------*| + # || || + # || || + # |*--------*| + # +----------+ + self.base_layout = QVBoxLayout(self) + self.base_layout.setObjectName("baseLayout") + # self.base_layout.setRowStretch(1, 2) + self.base_layout.setContentsMargins(0, 0, 0, 0) - # +----------+ - # |[~~~~~~~~]| - # | ^ | - # | | | - # | v | - # +----------+ - self.base_layout.addStretch(2) + # +----------+ + # |[~~~~~~~~]| + # | | + # | | + # | | + # +----------+ + self.top_layout = QHBoxLayout() + self.top_layout.setObjectName("topLayout") + # self.top_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + # self.top_layout.setColumnStretch(1, 2) + self.top_layout.setContentsMargins(6, 6, 6, 6) + self.top_container = QWidget() + self.top_container.setLayout(self.top_layout) + self.base_layout.addWidget(self.top_container) - # +----------+ - # |[~~~~~~~~]| - # | ^ | - # | v | - # |[~~~~~~~~]| - # +----------+ - self.bottom_layout = QHBoxLayout() - self.bottom_layout.setObjectName('bottomLayout') - # self.bottom_container.setAlignment(Qt.AlignmentFlag.AlignBottom) - # self.bottom_layout.setColumnStretch(1, 2) - self.bottom_layout.setContentsMargins(6, 6, 6, 6) - self.bottom_container = QWidget() - self.bottom_container.setLayout(self.bottom_layout) - self.base_layout.addWidget(self.bottom_container) + # +----------+ + # |[~~~~~~~~]| + # | ^ | + # | | | + # | v | + # +----------+ + self.base_layout.addStretch(2) - # self.root_layout = QGridLayout(self) - # self.root_layout.setObjectName('rootLayout') - # self.root_layout.setColumnStretch(1, 2) - # self.root_layout.setRowStretch(1, 2) - # self.root_layout.setContentsMargins(6,6,6,6) - # # root_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + # +----------+ + # |[~~~~~~~~]| + # | ^ | + # | v | + # |[~~~~~~~~]| + # +----------+ + self.bottom_layout = QHBoxLayout() + self.bottom_layout.setObjectName("bottomLayout") + # self.bottom_container.setAlignment(Qt.AlignmentFlag.AlignBottom) + # self.bottom_layout.setColumnStretch(1, 2) + self.bottom_layout.setContentsMargins(6, 6, 6, 6) + self.bottom_container = QWidget() + self.bottom_container.setLayout(self.bottom_layout) + self.base_layout.addWidget(self.bottom_container) - self.thumb_button = ThumbButton(self, thumb_size) - self.renderer = ThumbRenderer() - self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i), - self.update_size( - ts, size=s), - self.set_extension(ext))) - self.thumb_button.setFlat(True) + # self.root_layout = QGridLayout(self) + # self.root_layout.setObjectName('rootLayout') + # self.root_layout.setColumnStretch(1, 2) + # self.root_layout.setRowStretch(1, 2) + # self.root_layout.setContentsMargins(6,6,6,6) + # # root_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) - # self.bg_button.setStyleSheet('background-color:blue;') - # self.bg_button.setLayout(self.root_layout) - self.thumb_button.setLayout(self.base_layout) - # self.bg_button.setMinimumSize(*thumb_size) - # self.bg_button.setMaximumSize(*thumb_size) + self.thumb_button = ThumbButton(self, thumb_size) + self.renderer = ThumbRenderer() + self.renderer.updated.connect( + lambda ts, i, s, ext: ( + self.update_thumb(ts, image=i), + self.update_size(ts, size=s), + self.set_extension(ext), + ) + ) + self.thumb_button.setFlat(True) - 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) + # self.bg_button.setStyleSheet('background-color:blue;') + # self.bg_button.setLayout(self.root_layout) + self.thumb_button.setLayout(self.base_layout) + # self.bg_button.setMinimumSize(*thumb_size) + # self.bg_button.setMaximumSize(*thumb_size) - # Static Badges ======================================================== + 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) - # Item Type Badge ------------------------------------------------------ - # Used for showing the Tag Group / Collation icons. - # Mutually exclusive with the File Extension Badge. - self.item_type_badge = QLabel() - self.item_type_badge.setObjectName('itemBadge') - self.item_type_badge.setPixmap(QPixmap.fromImage(ImageQt.ImageQt( - ItemThumb.collation_icon_128.resize((check_size, check_size), Image.Resampling.BILINEAR)))) - self.item_type_badge.setMinimumSize(check_size, check_size) - self.item_type_badge.setMaximumSize(check_size, check_size) - # self.root_layout.addWidget(self.item_type_badge, 2, 0) - self.bottom_layout.addWidget(self.item_type_badge) + # Static Badges ======================================================== - # File Extension Badge ------------------------------------------------- - # Mutually exclusive with the File Extension Badge. - self.ext_badge = QLabel() - self.ext_badge.setObjectName('extBadge') - # self.ext_badge.setText('MP4') - # self.ext_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter) - self.ext_badge.setStyleSheet(ItemThumb.small_text_style) - # self.type_badge.setAlignment(Qt.AlignmentFlag.AlignRight) - # self.root_layout.addWidget(self.ext_badge, 2, 0) - self.bottom_layout.addWidget(self.ext_badge) - # self.type_badge.setHidden(True) - # bl_layout.addWidget(self.type_badge) + # Item Type Badge ------------------------------------------------------ + # Used for showing the Tag Group / Collation icons. + # Mutually exclusive with the File Extension Badge. + self.item_type_badge = QLabel() + self.item_type_badge.setObjectName("itemBadge") + self.item_type_badge.setPixmap( + QPixmap.fromImage( + ImageQt.ImageQt( + ItemThumb.collation_icon_128.resize( + (check_size, check_size), Image.Resampling.BILINEAR + ) + ) + ) + ) + self.item_type_badge.setMinimumSize(check_size, check_size) + self.item_type_badge.setMaximumSize(check_size, check_size) + # self.root_layout.addWidget(self.item_type_badge, 2, 0) + self.bottom_layout.addWidget(self.item_type_badge) - self.bottom_layout.addStretch(2) + # File Extension Badge ------------------------------------------------- + # Mutually exclusive with the File Extension Badge. + self.ext_badge = QLabel() + self.ext_badge.setObjectName("extBadge") + # self.ext_badge.setText('MP4') + # self.ext_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter) + self.ext_badge.setStyleSheet(ItemThumb.small_text_style) + # self.type_badge.setAlignment(Qt.AlignmentFlag.AlignRight) + # self.root_layout.addWidget(self.ext_badge, 2, 0) + self.bottom_layout.addWidget(self.ext_badge) + # self.type_badge.setHidden(True) + # bl_layout.addWidget(self.type_badge) - # Count Badge ---------------------------------------------------------- - # Used for Tag Group + Collation counts, video length, word count, etc. - self.count_badge = QLabel() - self.count_badge.setObjectName('countBadge') - # self.count_badge.setMaximumHeight(17) - self.count_badge.setText('-:--') - # self.count_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter) - self.count_badge.setStyleSheet(ItemThumb.small_text_style) - # self.count_badge.setAlignment(Qt.AlignmentFlag.AlignBottom) - # self.root_layout.addWidget(self.count_badge, 2, 2) - self.bottom_layout.addWidget( - self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom) + self.bottom_layout.addStretch(2) - self.top_layout.addStretch(2) + # Count Badge ---------------------------------------------------------- + # Used for Tag Group + Collation counts, video length, word count, etc. + self.count_badge = QLabel() + self.count_badge.setObjectName("countBadge") + # self.count_badge.setMaximumHeight(17) + self.count_badge.setText("-:--") + # self.count_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter) + self.count_badge.setStyleSheet(ItemThumb.small_text_style) + # self.count_badge.setAlignment(Qt.AlignmentFlag.AlignBottom) + # self.root_layout.addWidget(self.count_badge, 2, 2) + self.bottom_layout.addWidget( + self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom + ) - # Intractable Badges =================================================== - self.cb_container = QWidget() - # check_badges.setStyleSheet('background-color:cyan;') - self.cb_layout = QHBoxLayout() - self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft) - self.cb_layout.setContentsMargins(0, 0, 0, 0) - self.cb_layout.setSpacing(6) - self.cb_container.setLayout(self.cb_layout) - # self.cb_container.setHidden(True) - # self.root_layout.addWidget(self.check_badges, 0, 2) - self.top_layout.addWidget(self.cb_container) + self.top_layout.addStretch(2) - # Favorite Badge ------------------------------------------------------- - self.favorite_badge = QCheckBox() - self.favorite_badge.setObjectName('favBadge') - self.favorite_badge.setToolTip('Favorite') - self.favorite_badge.setStyleSheet(f'QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}' - f'QCheckBox::indicator::unchecked{{image: url(:/images/star_icon_empty_128.png)}}' - f'QCheckBox::indicator::checked{{image: url(:/images/star_icon_filled_128.png)}}' - # f'QCheckBox{{background-color:yellow;}}' - ) - self.favorite_badge.setMinimumSize(check_size, check_size) - self.favorite_badge.setMaximumSize(check_size, check_size) - self.favorite_badge.stateChanged.connect( - lambda x=self.favorite_badge.isChecked(): self.on_favorite_check(bool(x))) + # Intractable Badges =================================================== + self.cb_container = QWidget() + # check_badges.setStyleSheet('background-color:cyan;') + self.cb_layout = QHBoxLayout() + self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft) + self.cb_layout.setContentsMargins(0, 0, 0, 0) + self.cb_layout.setSpacing(6) + self.cb_container.setLayout(self.cb_layout) + # self.cb_container.setHidden(True) + # self.root_layout.addWidget(self.check_badges, 0, 2) + self.top_layout.addWidget(self.cb_container) - # self.fav_badge.setContentsMargins(0,0,0,0) - # tr_layout.addWidget(self.fav_badge) - # root_layout.addWidget(self.fav_badge, 0, 2) - self.cb_layout.addWidget(self.favorite_badge) - self.favorite_badge.setHidden(True) + # Favorite Badge ------------------------------------------------------- + self.favorite_badge = QCheckBox() + self.favorite_badge.setObjectName("favBadge") + self.favorite_badge.setToolTip("Favorite") + self.favorite_badge.setStyleSheet( + f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}" + f"QCheckBox::indicator::unchecked{{image: url(:/images/star_icon_empty_128.png)}}" + f"QCheckBox::indicator::checked{{image: url(:/images/star_icon_filled_128.png)}}" + # f'QCheckBox{{background-color:yellow;}}' + ) + self.favorite_badge.setMinimumSize(check_size, check_size) + self.favorite_badge.setMaximumSize(check_size, check_size) + self.favorite_badge.stateChanged.connect( + lambda x=self.favorite_badge.isChecked(): self.on_favorite_check(bool(x)) + ) - # Archive Badge -------------------------------------------------------- - self.archived_badge = QCheckBox() - self.archived_badge.setObjectName('archiveBadge') - self.archived_badge.setToolTip('Archive') - self.archived_badge.setStyleSheet(f'QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}' - f'QCheckBox::indicator::unchecked{{image: url(:/images/box_icon_empty_128.png)}}' - f'QCheckBox::indicator::checked{{image: url(:/images/box_icon_filled_128.png)}}' - # f'QCheckBox{{background-color:red;}}' - ) - self.archived_badge.setMinimumSize(check_size, check_size) - self.archived_badge.setMaximumSize(check_size, check_size) - # self.archived_badge.clicked.connect(lambda x: self.assign_archived(x)) - self.archived_badge.stateChanged.connect( - lambda x=self.archived_badge.isChecked(): self.on_archived_check(bool(x))) + # self.fav_badge.setContentsMargins(0,0,0,0) + # tr_layout.addWidget(self.fav_badge) + # root_layout.addWidget(self.fav_badge, 0, 2) + self.cb_layout.addWidget(self.favorite_badge) + self.favorite_badge.setHidden(True) - # tr_layout.addWidget(self.archive_badge) - self.cb_layout.addWidget(self.archived_badge) - self.archived_badge.setHidden(True) - # root_layout.addWidget(self.archive_badge, 0, 2) - # self.dumpObjectTree() + # Archive Badge -------------------------------------------------------- + self.archived_badge = QCheckBox() + self.archived_badge.setObjectName("archiveBadge") + self.archived_badge.setToolTip("Archive") + self.archived_badge.setStyleSheet( + f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}" + f"QCheckBox::indicator::unchecked{{image: url(:/images/box_icon_empty_128.png)}}" + f"QCheckBox::indicator::checked{{image: url(:/images/box_icon_filled_128.png)}}" + # f'QCheckBox{{background-color:red;}}' + ) + self.archived_badge.setMinimumSize(check_size, check_size) + self.archived_badge.setMaximumSize(check_size, check_size) + # self.archived_badge.clicked.connect(lambda x: self.assign_archived(x)) + self.archived_badge.stateChanged.connect( + lambda x=self.archived_badge.isChecked(): self.on_archived_check(bool(x)) + ) - self.set_mode(mode) + # tr_layout.addWidget(self.archive_badge) + self.cb_layout.addWidget(self.archived_badge) + self.archived_badge.setHidden(True) + # root_layout.addWidget(self.archive_badge, 0, 2) + # self.dumpObjectTree() - def set_mode(self, mode: Optional[ItemType]) -> None: - if mode is None: - self.unsetCursor() - self.thumb_button.setHidden(True) - # self.check_badges.setHidden(True) - # self.ext_badge.setHidden(True) - # self.item_type_badge.setHidden(True) - pass - elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY: - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.thumb_button.setHidden(False) - self.cb_container.setHidden(False) - # Count Badge depends on file extension (video length, word count) - self.item_type_badge.setHidden(True) - self.count_badge.setStyleSheet(ItemThumb.small_text_style) - self.count_badge.setHidden(True) - self.ext_badge.setHidden(True) - elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION: - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.thumb_button.setHidden(False) - self.cb_container.setHidden(True) - self.ext_badge.setHidden(True) - self.count_badge.setStyleSheet(ItemThumb.med_text_style) - self.count_badge.setHidden(False) - self.item_type_badge.setHidden(False) - elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP: - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.thumb_button.setHidden(False) - # self.cb_container.setHidden(True) - self.ext_badge.setHidden(True) - self.count_badge.setHidden(False) - self.item_type_badge.setHidden(False) - self.mode = mode - # logging.info(f'Set Mode To: {self.mode}') + self.set_mode(mode) - # def update_(self, thumb: QPixmap, size:QSize, ext:str, badges:list[QPixmap]) -> None: - # """Updates the ItemThumb's visuals.""" - # if thumb: - # pass + def set_mode(self, mode: Optional[ItemType]) -> None: + if mode is None: + self.unsetCursor() + self.thumb_button.setHidden(True) + # self.check_badges.setHidden(True) + # self.ext_badge.setHidden(True) + # self.item_type_badge.setHidden(True) + pass + elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY: + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.thumb_button.setHidden(False) + self.cb_container.setHidden(False) + # Count Badge depends on file extension (video length, word count) + self.item_type_badge.setHidden(True) + self.count_badge.setStyleSheet(ItemThumb.small_text_style) + self.count_badge.setHidden(True) + self.ext_badge.setHidden(True) + elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION: + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.thumb_button.setHidden(False) + self.cb_container.setHidden(True) + self.ext_badge.setHidden(True) + self.count_badge.setStyleSheet(ItemThumb.med_text_style) + self.count_badge.setHidden(False) + self.item_type_badge.setHidden(False) + elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP: + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.thumb_button.setHidden(False) + # self.cb_container.setHidden(True) + self.ext_badge.setHidden(True) + self.count_badge.setHidden(False) + self.item_type_badge.setHidden(False) + self.mode = mode + # logging.info(f'Set Mode To: {self.mode}') - def set_extension(self, ext: str) -> None: - if ext and ext not in IMAGE_TYPES or ext in ['gif', 'apng']: - self.ext_badge.setHidden(False) - self.ext_badge.setText(ext.upper()) - if ext in VIDEO_TYPES + AUDIO_TYPES: - self.count_badge.setHidden(False) - else: - if self.mode == ItemType.ENTRY: - self.ext_badge.setHidden(True) - self.count_badge.setHidden(True) + # def update_(self, thumb: QPixmap, size:QSize, ext:str, badges:list[QPixmap]) -> None: + # """Updates the ItemThumb's visuals.""" + # if thumb: + # pass - def set_count(self, count: str) -> None: - if count: - self.count_badge.setHidden(False) - self.count_badge.setText(count) - else: - if self.mode == ItemType.ENTRY: - self.ext_badge.setHidden(True) - self.count_badge.setHidden(True) + def set_extension(self, ext: str) -> None: + if ext and ext not in IMAGE_TYPES or ext in ["gif", "apng"]: + self.ext_badge.setHidden(False) + self.ext_badge.setText(ext.upper()) + if ext in VIDEO_TYPES + AUDIO_TYPES: + self.count_badge.setHidden(False) + else: + if self.mode == ItemType.ENTRY: + self.ext_badge.setHidden(True) + self.count_badge.setHidden(True) - def update_thumb(self, timestamp: float, image: QPixmap = None): - """Updates attributes of a thumbnail element.""" - # logging.info(f'[GUI] Updating Thumbnail for element {id(element)}: {id(image) if image else None}') - if timestamp > ItemThumb.update_cutoff: - self.thumb_button.setIcon(image if image else QPixmap()) - # element.repaint() + def set_count(self, count: str) -> None: + if count: + self.count_badge.setHidden(False) + self.count_badge.setText(count) + else: + if self.mode == ItemType.ENTRY: + self.ext_badge.setHidden(True) + self.count_badge.setHidden(True) - def update_size(self, timestamp: float, size: QSize): - """Updates attributes of a thumbnail element.""" - # logging.info(f'[GUI] Updating size for element {id(element)}: {size.__str__()}') - if timestamp > ItemThumb.update_cutoff: - if self.thumb_button.iconSize != size: - self.thumb_button.setIconSize(size) - self.thumb_button.setMinimumSize(size) - self.thumb_button.setMaximumSize(size) + def update_thumb(self, timestamp: float, image: QPixmap = None): + """Updates attributes of a thumbnail element.""" + # logging.info(f'[GUI] Updating Thumbnail for element {id(element)}: {id(image) if image else None}') + if timestamp > ItemThumb.update_cutoff: + self.thumb_button.setIcon(image if image else QPixmap()) + # element.repaint() - def update_clickable(self, clickable: FunctionType = None): - """Updates attributes of a thumbnail element.""" - # logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}') - try: - self.thumb_button.clicked.disconnect() - except RuntimeError: - pass - if clickable: - self.thumb_button.clicked.connect(clickable) - - def update_badges(self): - if self.mode == ItemType.ENTRY: - # logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}') - # logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}') - self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0)) - self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1)) + def update_size(self, timestamp: float, size: QSize): + """Updates attributes of a thumbnail element.""" + # logging.info(f'[GUI] Updating size for element {id(element)}: {size.__str__()}') + if timestamp > ItemThumb.update_cutoff: + if self.thumb_button.iconSize != size: + self.thumb_button.setIconSize(size) + self.thumb_button.setMinimumSize(size) + self.thumb_button.setMaximumSize(size) + def update_clickable(self, clickable: FunctionType = None): + """Updates attributes of a thumbnail element.""" + # logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}') + try: + self.thumb_button.clicked.disconnect() + except RuntimeError: + pass + if clickable: + self.thumb_button.clicked.connect(clickable) - 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 update_badges(self): + if self.mode == ItemType.ENTRY: + # logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}') + # logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}') + self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0)) + self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1)) - def assign_favorite(self, value: bool): - # Switching mode to None to bypass mode-specific operations when the - # checkbox's state changes. - mode = self.mode - self.mode = None - self.isFavorite = value - self.favorite_badge.setChecked(value) - if not self.thumb_button.underMouse(): - self.favorite_badge.setHidden(not self.isFavorite) - self.mode = mode + 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_archived(self, value: bool): - # Switching mode to None to bypass mode-specific operations when the - # checkbox's state changes. - mode = self.mode - self.mode = None - self.isArchived = value - self.archived_badge.setChecked(value) - if not self.thumb_button.underMouse(): - self.archived_badge.setHidden(not self.isArchived) - self.mode = mode + def assign_favorite(self, value: bool): + # Switching mode to None to bypass mode-specific operations when the + # checkbox's state changes. + mode = self.mode + self.mode = None + self.isFavorite = value + self.favorite_badge.setChecked(value) + if not self.thumb_button.underMouse(): + self.favorite_badge.setHidden(not self.isFavorite) + self.mode = mode - def show_check_badges(self, show: bool): - if self.mode != ItemType.TAG_GROUP: - self.favorite_badge.setHidden( - True if (not show and not self.isFavorite) else False) - self.archived_badge.setHidden( - True if (not show and not self.isArchived) else False) + def assign_archived(self, value: bool): + # Switching mode to None to bypass mode-specific operations when the + # checkbox's state changes. + mode = self.mode + self.mode = None + self.isArchived = value + self.archived_badge.setChecked(value) + if not self.thumb_button.underMouse(): + self.archived_badge.setHidden(not self.isArchived) + self.mode = mode - def enterEvent(self, event: QEnterEvent) -> None: - self.show_check_badges(True) - return super().enterEvent(event) + def show_check_badges(self, show: bool): + if self.mode != ItemType.TAG_GROUP: + self.favorite_badge.setHidden( + True if (not show and not self.isFavorite) else False + ) + self.archived_badge.setHidden( + True if (not show and not self.isArchived) else False + ) - def leaveEvent(self, event: QEvent) -> None: - self.show_check_badges(False) - return super().leaveEvent(event) + def enterEvent(self, event: QEnterEvent) -> None: + self.show_check_badges(True) + return super().enterEvent(event) - def on_archived_check(self, value: bool): - # logging.info(f'Archived Check: {value}, Mode: {self.mode}') - if self.mode == ItemType.ENTRY: - self.isArchived = value - 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: - # 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 leaveEvent(self, event: QEvent) -> None: + self.show_check_badges(False) + return super().leaveEvent(event) + def on_archived_check(self, value: bool): + # logging.info(f'Archived Check: {value}, Mode: {self.mode}') + if self.mode == ItemType.ENTRY: + self.isArchived = value + 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: + # 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: - # self.isArchived = False - # e = self.lib.get_entry(self.item_id) + # def on_archived_uncheck(self): + # if self.mode == SearchItemType.ENTRY: + # self.isArchived = False + # e = self.lib.get_entry(self.item_id) - def on_favorite_check(self, value: bool): - # logging.info(f'Favorite Check: {value}, Mode: {self.mode}') - if self.mode == ItemType.ENTRY: - self.isFavorite = value - 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: - # 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_check(self, value: bool): + # logging.info(f'Favorite Check: {value}, Mode: {self.mode}') + if self.mode == ItemType.ENTRY: + self.isFavorite = value + 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: + # 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: - # self.isFavorite = False - # e = self.lib.get_entry(self.item_id) - # e.remove_tag(1) \ No newline at end of file + # def on_favorite_uncheck(self): + # if self.mode == SearchItemType.ENTRY: + # self.isFavorite = False + # e = self.lib.get_entry(self.item_id) + # e.remove_tag(1) diff --git a/tagstudio/src/qt/widgets/panel.py b/tagstudio/src/qt/widgets/panel.py index 430fa01..cb87f1e 100644 --- a/tagstudio/src/qt/widgets/panel.py +++ b/tagstudio/src/qt/widgets/panel.py @@ -10,90 +10,100 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushBu class PanelModal(QWidget): - saved = Signal() - # TODO: Separate callbacks from the buttons you want, and just generally - # figure out what you want from this. - def __init__(self, widget:'PanelWidget', title:str, window_title:str, - done_callback:FunctionType=None, - # cancel_callback:FunctionType=None, - save_callback:FunctionType=None,has_save:bool=False): - # [Done] - # - OR - - # [Cancel] [Save] - super().__init__() - self.widget = widget - self.setWindowTitle(window_title) - self.setWindowModality(Qt.WindowModality.ApplicationModal) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,0,6,6) + saved = Signal() - self.title_widget = QLabel() - self.title_widget.setObjectName('fieldTitle') - self.title_widget.setWordWrap(True) - self.title_widget.setStyleSheet( - # 'background:blue;' - # 'text-align:center;' - 'font-weight:bold;' - 'font-size:14px;' - 'padding-top: 6px' - '') - self.title_widget.setText(title) - self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - + # TODO: Separate callbacks from the buttons you want, and just generally + # figure out what you want from this. + def __init__( + self, + widget: "PanelWidget", + title: str, + window_title: str, + done_callback: FunctionType = None, + # cancel_callback:FunctionType=None, + save_callback: FunctionType = None, + has_save: bool = False, + ): + # [Done] + # - OR - + # [Cancel] [Save] + super().__init__() + self.widget = widget + self.setWindowTitle(window_title) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 6) - self.button_container = QWidget() - self.button_layout = QHBoxLayout(self.button_container) - self.button_layout.setContentsMargins(6,6,6,6) - self.button_layout.addStretch(1) + self.title_widget = QLabel() + self.title_widget.setObjectName("fieldTitle") + self.title_widget.setWordWrap(True) + self.title_widget.setStyleSheet( + # 'background:blue;' + # 'text-align:center;' + "font-weight:bold;" "font-size:14px;" "padding-top: 6px" "" + ) + self.title_widget.setText(title) + self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - # self.cancel_button = QPushButton() - # self.cancel_button.setText('Cancel') + self.button_container = QWidget() + self.button_layout = QHBoxLayout(self.button_container) + self.button_layout.setContentsMargins(6, 6, 6, 6) + self.button_layout.addStretch(1) - if not (save_callback or has_save): - self.done_button = QPushButton() - self.done_button.setText('Done') - self.done_button.setAutoDefault(True) - self.done_button.clicked.connect(self.hide) - if done_callback: - self.done_button.clicked.connect(done_callback) - self.button_layout.addWidget(self.done_button) + # self.cancel_button = QPushButton() + # self.cancel_button.setText('Cancel') - if (save_callback or has_save): - self.cancel_button = QPushButton() - self.cancel_button.setText('Cancel') - self.cancel_button.clicked.connect(self.hide) - self.cancel_button.clicked.connect(widget.reset) - # self.cancel_button.clicked.connect(cancel_callback) - self.button_layout.addWidget(self.cancel_button) - - if (save_callback or has_save): - self.save_button = QPushButton() - self.save_button.setText('Save') - self.save_button.setAutoDefault(True) - self.save_button.clicked.connect(self.hide) - self.save_button.clicked.connect(self.saved.emit) - if done_callback: - self.save_button.clicked.connect(done_callback) - if save_callback: - self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) - self.button_layout.addWidget(self.save_button) - - widget.done.connect(lambda: save_callback(widget.get_content())) - - self.root_layout.addWidget(self.title_widget) - self.root_layout.addWidget(widget) - self.root_layout.setStretch(1,2) - self.root_layout.addWidget(self.button_container) + if not (save_callback or has_save): + self.done_button = QPushButton() + self.done_button.setText("Done") + self.done_button.setAutoDefault(True) + self.done_button.clicked.connect(self.hide) + if done_callback: + self.done_button.clicked.connect(done_callback) + self.button_layout.addWidget(self.done_button) + + if save_callback or has_save: + self.cancel_button = QPushButton() + self.cancel_button.setText("Cancel") + self.cancel_button.clicked.connect(self.hide) + self.cancel_button.clicked.connect(widget.reset) + # self.cancel_button.clicked.connect(cancel_callback) + self.button_layout.addWidget(self.cancel_button) + + if save_callback or has_save: + self.save_button = QPushButton() + self.save_button.setText("Save") + self.save_button.setAutoDefault(True) + self.save_button.clicked.connect(self.hide) + self.save_button.clicked.connect(self.saved.emit) + if done_callback: + self.save_button.clicked.connect(done_callback) + if save_callback: + self.save_button.clicked.connect( + lambda: save_callback(widget.get_content()) + ) + self.button_layout.addWidget(self.save_button) + + widget.done.connect(lambda: save_callback(widget.get_content())) + + self.root_layout.addWidget(self.title_widget) + self.root_layout.addWidget(widget) + self.root_layout.setStretch(1, 2) + self.root_layout.addWidget(self.button_container) class PanelWidget(QWidget): - """ - Used for widgets that go in a modal panel, ex. for editing or searching. - """ - done = Signal() - def __init__(self): - super().__init__() - def get_content(self)-> str: - pass - def reset(self): - pass + """ + Used for widgets that go in a modal panel, ex. for editing or searching. + """ + + done = Signal() + + def __init__(self): + super().__init__() + + def get_content(self) -> str: + pass + + def reset(self): + pass diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 52dcaf3..5ede8bd 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -13,795 +13,900 @@ import cv2 from PIL import Image, UnidentifiedImageError from PySide6.QtCore import Signal, Qt, QSize from PySide6.QtGui import QResizeEvent, QAction -from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, - QSplitter, QSizePolicy, QMessageBox) +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QFrame, + QSplitter, + QSizePolicy, + QMessageBox, +) from humanfriendly import format_size from src.core.library import Entry, ItemType, Library from src.core.ts_core import VIDEO_TYPES, IMAGE_TYPES from src.qt.helpers import FileOpenerLabel, FileOpenerHelper, open_file from src.qt.modals import AddFieldModal -from src.qt.widgets import (ThumbRenderer, FieldContainer, TagBoxWidget, TextWidget, PanelModal, EditTextBox, - EditTextLine, ItemThumb) +from src.qt.widgets import ( + ThumbRenderer, + FieldContainer, + TagBoxWidget, + TextWidget, + PanelModal, + EditTextBox, + EditTextLine, + ItemThumb, +) # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class PreviewPanel(QWidget): - """The Preview Panel Widget.""" - tags_updated = Signal() + """The Preview Panel Widget.""" - def __init__(self, library: Library, driver:'QtDriver'): - super().__init__() - self.lib = library - self.driver:QtDriver = driver - self.initialized = False - self.isOpen: bool = False - # self.filepath = None - # self.item = None # DEPRECATED, USE self.selected - self.common_fields = [] - self.mixed_fields = [] - self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items - self.tag_callback = None - self.containers: list[QWidget] = [] + tags_updated = Signal() - self.img_button_size: tuple[int, int] = (266, 266) - self.image_ratio: float = 1.0 + def __init__(self, library: Library, driver: "QtDriver"): + super().__init__() + self.lib = library + self.driver: QtDriver = driver + self.initialized = False + self.isOpen: bool = False + # self.filepath = None + # self.item = None # DEPRECATED, USE self.selected + self.common_fields = [] + self.mixed_fields = [] + self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items + self.tag_callback = None + self.containers: list[QWidget] = [] - root_layout = QHBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - - self.image_container = QWidget() - image_layout = QHBoxLayout(self.image_container) - image_layout.setContentsMargins(0, 0, 0, 0) + self.img_button_size: tuple[int, int] = (266, 266) + self.image_ratio: float = 1.0 - splitter = QSplitter() - splitter.setOrientation(Qt.Orientation.Vertical) - splitter.setHandleWidth(12) + root_layout = QHBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) - self.open_file_action = QAction('Open file', self) - self.open_explorer_action = QAction('Open file in explorer', self) + self.image_container = QWidget() + image_layout = QHBoxLayout(self.image_container) + image_layout.setContentsMargins(0, 0, 0, 0) - self.preview_img = QPushButton() - self.preview_img.setMinimumSize(*self.img_button_size) - self.preview_img.setFlat(True) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + splitter = QSplitter() + splitter.setOrientation(Qt.Orientation.Vertical) + splitter.setHandleWidth(12) - self.preview_img.addAction(self.open_file_action) - self.preview_img.addAction(self.open_explorer_action) + self.open_file_action = QAction("Open file", self) + self.open_explorer_action = QAction("Open file in explorer", self) - 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), - self.update_image_size((self.image_container.size().width(), self.image_container.size().height()), ratio))) + self.preview_img = QPushButton() + self.preview_img.setMinimumSize(*self.img_button_size) + self.preview_img.setFlat(True) + self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - splitter.splitterMoved.connect(lambda: self.update_image_size((self.image_container.size().width(), self.image_container.size().height()))) - splitter.addWidget(self.image_container) + self.preview_img.addAction(self.open_file_action) + self.preview_img.addAction(self.open_explorer_action) - image_layout.addWidget(self.preview_img) - image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) + 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), + self.update_image_size( + ( + self.image_container.size().width(), + self.image_container.size().height(), + ), + ratio, + ), + ) + ) - self.file_label = FileOpenerLabel('Filename') - self.file_label.setWordWrap(True) - self.file_label.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse) - self.file_label.setStyleSheet('font-weight: bold; font-size: 12px') + splitter.splitterMoved.connect( + lambda: self.update_image_size( + ( + self.image_container.size().width(), + self.image_container.size().height(), + ) + ) + ) + splitter.addWidget(self.image_container) - self.dimensions_label = QLabel('Dimensions') - self.dimensions_label.setWordWrap(True) - # self.dim_label.setTextInteractionFlags( - # Qt.TextInteractionFlag.TextSelectableByMouse) - self.dimensions_label.setStyleSheet(ItemThumb.small_text_style) - - # small_text_style = ( - # f'background-color:rgba(17, 15, 27, 192);' - # f'font-family:Oxanium;' - # f'font-weight:bold;' - # f'font-size:12px;' - # f'border-radius:3px;' - # f'padding-top: 4px;' - # f'padding-right: 1px;' - # f'padding-bottom: 1px;' - # f'padding-left: 1px;' - # ) + image_layout.addWidget(self.preview_img) + image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) - self.scroll_layout = QVBoxLayout() - self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.scroll_layout.setContentsMargins(6,1,6,6) + self.file_label = FileOpenerLabel("Filename") + self.file_label.setWordWrap(True) + self.file_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + self.file_label.setStyleSheet("font-weight: bold; font-size: 12px") - scroll_container: QWidget = QWidget() - scroll_container.setObjectName('entryScrollContainer') - scroll_container.setLayout(self.scroll_layout) - # scroll_container.setStyleSheet('background:#080716; border-radius:12px;') - scroll_container.setStyleSheet( - 'background:#00000000;' - 'border-style:none;' - f'QScrollBar::{{background:red;}}' - ) - + self.dimensions_label = QLabel("Dimensions") + self.dimensions_label.setWordWrap(True) + # self.dim_label.setTextInteractionFlags( + # Qt.TextInteractionFlag.TextSelectableByMouse) + self.dimensions_label.setStyleSheet(ItemThumb.small_text_style) - info_section = QWidget() - info_layout = QVBoxLayout(info_section) - info_layout.setContentsMargins(0,0,0,0) - info_layout.setSpacing(6) - self.setStyleSheet( - 'background:#00000000;' - f'QScrollBar::{{background:red;}}' - ) + # small_text_style = ( + # f'background-color:rgba(17, 15, 27, 192);' + # f'font-family:Oxanium;' + # f'font-weight:bold;' + # f'font-size:12px;' + # f'border-radius:3px;' + # f'padding-top: 4px;' + # f'padding-right: 1px;' + # f'padding-bottom: 1px;' + # f'padding-left: 1px;' + # ) - scroll_area = QScrollArea() - scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll_area.setWidgetResizable(True) - scroll_area.setFrameShadow(QFrame.Shadow.Plain) - scroll_area.setFrameShape(QFrame.Shape.NoFrame) - scroll_area.setStyleSheet( - 'background:#55000000;' - 'border-radius:12px;' - 'border-style:solid;' - 'border-width:1px;' - 'border-color:#11FFFFFF;' - # f'QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{border: none;background: none;}}' - # f'QScrollBar::left-arrow:horizontal, QScrollBar::right-arrow:horizontal, QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{border: none;background: none;color: none;}}' - f'QScrollBar::{{background:red;}}' - ) - scroll_area.setWidget(scroll_container) + self.scroll_layout = QVBoxLayout() + self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.scroll_layout.setContentsMargins(6, 1, 6, 6) - info_layout.addWidget(self.file_label) - info_layout.addWidget(self.dimensions_label) - info_layout.addWidget(scroll_area) - splitter.addWidget(info_section) + scroll_container: QWidget = QWidget() + scroll_container.setObjectName("entryScrollContainer") + scroll_container.setLayout(self.scroll_layout) + # scroll_container.setStyleSheet('background:#080716; border-radius:12px;') + scroll_container.setStyleSheet( + "background:#00000000;" + "border-style:none;" + f"QScrollBar::{{background:red;}}" + ) - root_layout.addWidget(splitter) - splitter.setStretchFactor(1, 2) - - self.afb_container = QWidget() - self.afb_layout = QVBoxLayout(self.afb_container) - self.afb_layout.setContentsMargins(0,12,0,0) + info_section = QWidget() + info_layout = QVBoxLayout(info_section) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(6) + self.setStyleSheet("background:#00000000;" f"QScrollBar::{{background:red;}}") - self.add_field_button = QPushButton() - self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_field_button.setMinimumSize(96, 28) - self.add_field_button.setMaximumSize(96, 28) - self.add_field_button.setText('Add Field') - self.add_field_button.setStyleSheet( - f'QPushButton{{' - # f'background: #1E1A33;' - # f'color: #CDA7F7;' - f'font-weight: bold;' - # f"border-color: #2B2547;" - f'border-radius: 6px;' - f'border-style:solid;' - # f'border-width:{math.ceil(1*self.devicePixelRatio())}px;' - 'background:#55000000;' - 'border-width:1px;' - 'border-color:#11FFFFFF;' - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' - # f'padding-bottom: 5px;' - # f'padding-left: 4px;' - f'font-size: 13px;' - f'}}' - f'QPushButton::hover' - f'{{' - f'background: #333333;' - f'}}') - self.afb_layout.addWidget(self.add_field_button) - self.afm = AddFieldModal(self.lib) - self.place_add_field_button() - self.update_image_size((self.image_container.size().width(), self.image_container.size().height())) + scroll_area = QScrollArea() + scroll_area.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShadow(QFrame.Shadow.Plain) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setStyleSheet( + "background:#55000000;" + "border-radius:12px;" + "border-style:solid;" + "border-width:1px;" + "border-color:#11FFFFFF;" + # f'QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{border: none;background: none;}}' + # f'QScrollBar::left-arrow:horizontal, QScrollBar::right-arrow:horizontal, QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{border: none;background: none;color: none;}}' + f"QScrollBar::{{background:red;}}" + ) + scroll_area.setWidget(scroll_container) - def resizeEvent(self, event: QResizeEvent) -> None: - self.update_image_size((self.image_container.size().width(), self.image_container.size().height())) - return super().resizeEvent(event) - - def get_preview_size(self) -> tuple[int, int]: - return (self.image_container.size().width(), self.image_container.size().height()) + info_layout.addWidget(self.file_label) + info_layout.addWidget(self.dimensions_label) + info_layout.addWidget(scroll_area) + splitter.addWidget(info_section) - def set_image_ratio(self, ratio:float): - # logging.info(f'Updating Ratio to: {ratio} #####################################################') - self.image_ratio = ratio - - def update_image_size(self, size:tuple[int, int], ratio:float = None): - if ratio: - self.set_image_ratio(ratio) - # self.img_button_size = size - # logging.info(f'') - # self.preview_img.setMinimumSize(64,64) + root_layout.addWidget(splitter) + splitter.setStretchFactor(1, 2) - adj_width = size[0] - adj_height = size[1] - # Landscape - if self.image_ratio > 1: - # logging.info('Landscape') - adj_height = size[0] * (1/self.image_ratio) - # Portrait - elif self.image_ratio <= 1: - # logging.info('Portrait') - adj_width = size[1] * self.image_ratio - - if adj_width > size[0]: - adj_height = adj_height * (size[0]/adj_width) - adj_width = size[0] - elif adj_height > size[1]: - adj_width = adj_width * (size[1]/adj_height) - adj_height = size[1] - - # adj_width = min(adj_width, self.image_container.size().width()) - # adj_height = min(adj_width, self.image_container.size().height()) + self.afb_container = QWidget() + self.afb_layout = QVBoxLayout(self.afb_container) + self.afb_layout.setContentsMargins(0, 12, 0, 0) - # self.preview_img.setMinimumSize(s) - # self.preview_img.setMaximumSize(s_max) - adj_size = QSize(adj_width, adj_height) - self.img_button_size = (adj_width, adj_height) - self.preview_img.setMaximumSize(adj_size) - self.preview_img.setIconSize(adj_size) - # self.preview_img.setMinimumSize(adj_size) + self.add_field_button = QPushButton() + self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_field_button.setMinimumSize(96, 28) + self.add_field_button.setMaximumSize(96, 28) + self.add_field_button.setText("Add Field") + self.add_field_button.setStyleSheet( + f"QPushButton{{" + # f'background: #1E1A33;' + # f'color: #CDA7F7;' + f"font-weight: bold;" + # f"border-color: #2B2547;" + f"border-radius: 6px;" + f"border-style:solid;" + # f'border-width:{math.ceil(1*self.devicePixelRatio())}px;' + "background:#55000000;" + "border-width:1px;" + "border-color:#11FFFFFF;" + # f'padding-top: 1.5px;' + # f'padding-right: 4px;' + # f'padding-bottom: 5px;' + # f'padding-left: 4px;' + f"font-size: 13px;" + f"}}" + f"QPushButton::hover" + f"{{" + f"background: #333333;" + f"}}" + ) + self.afb_layout.addWidget(self.add_field_button) + self.afm = AddFieldModal(self.lib) + self.place_add_field_button() + self.update_image_size( + (self.image_container.size().width(), self.image_container.size().height()) + ) - # if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10: - # if type(self.item) == Entry: - # filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}') - # self.tr.render_big(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio()) - - # logging.info(f' Img Aspect Ratio: {self.image_ratio}') - # logging.info(f' Max Button Size: {size}') - # logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}') - # logging.info(f'Final Button Size: {(adj_width, adj_height)}') - # logging.info(f'') - # logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}') - # logging.info(f'Button Size: {self.preview_img.size().toTuple()}') - - def place_add_field_button(self): - self.scroll_layout.addWidget(self.afb_container) - self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter) + def resizeEvent(self, event: QResizeEvent) -> None: + self.update_image_size( + (self.image_container.size().width(), self.image_container.size().height()) + ) + return super().resizeEvent(event) - try: - self.afm.done.disconnect() - self.add_field_button.clicked.disconnect() - except RuntimeError: - pass + def get_preview_size(self) -> tuple[int, int]: + return ( + self.image_container.size().width(), + self.image_container.size().height(), + ) - # self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets())) - self.afm.done.connect(lambda f: (self.add_field_to_selected(f), self.update_widgets())) - self.add_field_button.clicked.connect(self.afm.show) - - def add_field_to_selected(self, field_id: int): - """Adds an entry field to one or more selected items.""" - added = set() - for item_pair in self.selected: - if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added: - self.lib.add_field_to_entry(item_pair[1], field_id) - added.add(item_pair[1]) - + def set_image_ratio(self, ratio: float): + # logging.info(f'Updating Ratio to: {ratio} #####################################################') + self.image_ratio = ratio - # def update_widgets(self, item: Union[Entry, Collation, Tag]): - def update_widgets(self): - """ - Renders the panel's widgets with the newest data from the Library. - """ - logging.info(f'[ENTRY PANEL] UPDATE WIDGETS ({self.driver.selected})' ) - self.isOpen = True - # self.tag_callback = tag_callback if tag_callback else None - window_title = '' - - # 0 Selected Items - if not self.driver.selected: - if self.selected or not self.initialized: - self.file_label.setText(f"No Items Selected") - self.file_label.setFilePath('') - self.file_label.setCursor(Qt.CursorShape.ArrowCursor) + def update_image_size(self, size: tuple[int, int], ratio: float = None): + if ratio: + self.set_image_ratio(ratio) + # self.img_button_size = size + # logging.info(f'') + # self.preview_img.setMinimumSize(64,64) - self.dimensions_label.setText("") - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) + adj_width = size[0] + adj_height = size[1] + # Landscape + if self.image_ratio > 1: + # logging.info('Landscape') + adj_height = size[0] * (1 / self.image_ratio) + # Portrait + elif self.image_ratio <= 1: + # logging.info('Portrait') + adj_width = size[1] * self.image_ratio - ratio: float = self.devicePixelRatio() - self.tr.render_big(time.time(), '', (512, 512), ratio, True) - try: - self.preview_img.clicked.disconnect() - except RuntimeError: - pass - for i, c in enumerate(self.containers): - c.setHidden(True) + if adj_width > size[0]: + adj_height = adj_height * (size[0] / adj_width) + adj_width = size[0] + elif adj_height > size[1]: + adj_width = adj_width * (size[1] / adj_height) + adj_height = size[1] - self.selected = list(self.driver.selected) - self.add_field_button.setHidden(True) - - # 1 Selected Item - elif len(self.driver.selected) == 1: + # adj_width = min(adj_width, self.image_container.size().width()) + # adj_height = min(adj_width, self.image_container.size().height()) - # 1 Selected Entry - if self.driver.selected[0][0] == ItemType.ENTRY: - item: Entry = self.lib.get_entry(self.driver.selected[0][1]) - # If a new selection is made, update the thumbnail and filepath. - if not self.selected 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.file_label.setCursor(Qt.CursorShape.PointingHandCursor) + # self.preview_img.setMinimumSize(s) + # self.preview_img.setMaximumSize(s_max) + adj_size = QSize(adj_width, adj_height) + self.img_button_size = (adj_width, adj_height) + self.preview_img.setMaximumSize(adj_size) + self.preview_img.setIconSize(adj_size) + # self.preview_img.setMinimumSize(adj_size) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + # if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10: + # if type(self.item) == Entry: + # filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}') + # self.tr.render_big(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio()) - self.opener = FileOpenerHelper(filepath) - self.open_file_action.triggered.connect(self.opener.open_file) - self.open_explorer_action.triggered.connect(self.opener.open_explorer) + # logging.info(f' Img Aspect Ratio: {self.image_ratio}') + # logging.info(f' Max Button Size: {size}') + # logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}') + # logging.info(f'Final Button Size: {(adj_width, adj_height)}') + # logging.info(f'') + # logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}') + # logging.info(f'Button Size: {self.preview_img.size().toTuple()}') - # TODO: Do this somewhere else, this is just here temporarily. - extension = os.path.splitext(filepath)[1][1:].lower() - try: - image = None - if extension in IMAGE_TYPES: - image = Image.open(filepath) - if image.mode == 'RGBA': - new_bg = Image.new('RGB', image.size, color='#222222') - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - if image.mode != 'RGB': - image = image.convert(mode='RGB') - elif extension in VIDEO_TYPES: - video = cv2.VideoCapture(filepath) - video.set(cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2)) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) + def place_add_field_button(self): + self.scroll_layout.addWidget(self.afb_container) + self.scroll_layout.setAlignment( + self.afb_container, Qt.AlignmentFlag.AlignHCenter + ) - # Stats for specific file types are displayed here. - if extension in (IMAGE_TYPES + VIDEO_TYPES): - 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()}") + try: + self.afm.done.disconnect() + self.add_field_button.clicked.disconnect() + except RuntimeError: + pass - if not image: - self.dimensions_label.setText(f"{extension.upper()} • {format_size(os.stat(filepath).st_size)}") - raise UnidentifiedImageError - - except (UnidentifiedImageError, FileNotFoundError, cv2.error): - pass + # self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets())) + self.afm.done.connect( + lambda f: (self.add_field_to_selected(f), self.update_widgets()) + ) + self.add_field_button.clicked.connect(self.afm.show) - + def add_field_to_selected(self, field_id: int): + """Adds an entry field to one or more selected items.""" + added = set() + for item_pair in self.selected: + if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added: + self.lib.add_field_to_entry(item_pair[1], field_id) + added.add(item_pair[1]) - try: - self.preview_img.clicked.disconnect() - except RuntimeError: - pass - self.preview_img.clicked.connect( - lambda checked=False, filepath=filepath: open_file(filepath)) - - self.selected = list(self.driver.selected) - for i, f in enumerate(item.fields): - self.write_container(i, f) + # def update_widgets(self, item: Union[Entry, Collation, Tag]): + def update_widgets(self): + """ + Renders the panel's widgets with the newest data from the Library. + """ + logging.info(f"[ENTRY PANEL] UPDATE WIDGETS ({self.driver.selected})") + self.isOpen = True + # self.tag_callback = tag_callback if tag_callback else None + window_title = "" - # Hide leftover containers - if len(self.containers) > len(item.fields): - for i, c in enumerate(self.containers): - if i > (len(item.fields) - 1): - c.setHidden(True) - - self.add_field_button.setHidden(False) + # 0 Selected Items + if not self.driver.selected: + if self.selected or not self.initialized: + self.file_label.setText(f"No Items Selected") + self.file_label.setFilePath("") + self.file_label.setCursor(Qt.CursorShape.ArrowCursor) - # 1 Selected Collation - elif self.driver.selected[0][0] == ItemType.COLLATION: - pass + self.dimensions_label.setText("") + self.preview_img.setContextMenuPolicy( + Qt.ContextMenuPolicy.NoContextMenu + ) + self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - # 1 Selected Tag - elif self.driver.selected[0][0] == ItemType.TAG_GROUP: - pass + ratio: float = self.devicePixelRatio() + self.tr.render_big(time.time(), "", (512, 512), ratio, True) + try: + self.preview_img.clicked.disconnect() + except RuntimeError: + pass + for i, c in enumerate(self.containers): + c.setHidden(True) - # Multiple Selected Items - 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.setCursor(Qt.CursorShape.ArrowCursor) - self.file_label.setFilePath('') - self.dimensions_label.setText("") + self.selected = list(self.driver.selected) + self.add_field_button.setHidden(True) - self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) - self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) + # 1 Selected Item + elif len(self.driver.selected) == 1: + # 1 Selected Entry + if self.driver.selected[0][0] == ItemType.ENTRY: + item: Entry = self.lib.get_entry(self.driver.selected[0][1]) + # If a new selection is made, update the thumbnail and filepath. + if not self.selected 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.file_label.setCursor(Qt.CursorShape.PointingHandCursor) - ratio: float = self.devicePixelRatio() - self.tr.render_big(time.time(), '', (512, 512), ratio, True) - try: - self.preview_img.clicked.disconnect() - except RuntimeError: - pass + self.preview_img.setContextMenuPolicy( + Qt.ContextMenuPolicy.ActionsContextMenu + ) + self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - self.common_fields = [] - self.mixed_fields = [] - for i, item_pair in enumerate(self.driver.selected): - if item_pair[0] == ItemType.ENTRY: - item = self.lib.get_entry(item_pair[1]) - if i == 0: - for f in item.fields: - self.common_fields.append(f) - else: - common_to_remove = [] - for f in self.common_fields: - # Common field found (Same ID, identical content) - if f not in item.fields: - common_to_remove.append(f) - - # Mixed field found (Same ID, different content) - if self.lib.get_field_index_in_entry(item, self.lib.get_field_attr(f, 'id')): - # if self.lib.get_field_attr(f, 'type') == ('tag_box'): - # pass - # logging.info(f) - # logging.info(type(f)) - f_stripped = {self.lib.get_field_attr(f, 'id'):None} - if f_stripped not in self.mixed_fields and (f not in self.common_fields or f in common_to_remove): - # and (f not in self.common_fields or f in common_to_remove) - self.mixed_fields.append(f_stripped) - self.common_fields = [f for f in self.common_fields if f not in common_to_remove] - order: list[int] = ( - [0] + - [1, 2] + - [9, 17, 18, 19, 20] + - [8, 7, 6] + - [4] + - [3, 21] + - [10, 14, 11, 12, 13, 22] + - [5] - ) - self.mixed_fields = sorted(self.mixed_fields, key=lambda x: order.index(self.lib.get_field_attr(x, 'id'))) + self.opener = FileOpenerHelper(filepath) + self.open_file_action.triggered.connect(self.opener.open_file) + self.open_explorer_action.triggered.connect( + self.opener.open_explorer + ) - - - self.selected = list(self.driver.selected) - for i, f in enumerate(self.common_fields): - logging.info(f'ci:{i}, f:{f}') - self.write_container(i, f) - for i, f in enumerate(self.mixed_fields, start = len(self.common_fields)): - logging.info(f'mi:{i}, f:{f}') - self.write_container(i, f, mixed=True) - - # Hide leftover containers - if len(self.containers) > len(self.common_fields) + len(self.mixed_fields): - for i, c in enumerate(self.containers): - if i > (len(self.common_fields) + len(self.mixed_fields) - 1): - c.setHidden(True) - - self.add_field_button.setHidden(False) - - self.initialized = True + # TODO: Do this somewhere else, this is just here temporarily. + extension = os.path.splitext(filepath)[1][1:].lower() + try: + image = None + if extension in IMAGE_TYPES: + image = Image.open(filepath) + if image.mode == "RGBA": + new_bg = Image.new("RGB", image.size, color="#222222") + new_bg.paste(image, mask=image.getchannel(3)) + image = new_bg + if image.mode != "RGB": + image = image.convert(mode="RGB") + elif extension in VIDEO_TYPES: + video = cv2.VideoCapture(filepath) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + # Stats for specific file types are displayed here. + if extension in (IMAGE_TYPES + VIDEO_TYPES): + 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()}") - # # Uninitialized or New Item: - # if not self.item or self.item.id != item.id: - # # logging.info(f'Uninitialized or New Item ({item.id})') - # if type(item) == Entry: - # # New Entry: Render preview and update filename label - # filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}') - # window_title = filepath - # ratio: float = self.devicePixelRatio() - # self.tr.render_big(time.time(), filepath, (512, 512), ratio) - # self.file_label.setText("\u200b".join(filepath)) + if not image: + self.dimensions_label.setText( + f"{extension.upper()} • {format_size(os.stat(filepath).st_size)}" + ) + raise UnidentifiedImageError - # # TODO: Deal with this later. - # # https://stackoverflow.com/questions/64252654/pyqt5-drag-and-drop-into-system-file-explorer-with-delayed-encoding - # # https://doc.qt.io/qtforpython-5/PySide2/QtCore/QMimeData.html#more - # # drag = QDrag(self.preview_img) - # # mime = QMimeData() - # # mime.setUrls([filepath]) - # # drag.setMimeData(mime) - # # drag.exec_(Qt.DropAction.CopyAction) + except (UnidentifiedImageError, FileNotFoundError, cv2.error): + pass - # try: - # self.preview_img.clicked.disconnect() - # except RuntimeError: - # pass - # self.preview_img.clicked.connect( - # lambda checked=False, filepath=filepath: open_file(filepath)) - - # for i, f in enumerate(item.fields): - # self.write_container(item, i, f) + try: + self.preview_img.clicked.disconnect() + except RuntimeError: + pass + self.preview_img.clicked.connect( + lambda checked=False, filepath=filepath: open_file(filepath) + ) - # self.item = item + self.selected = list(self.driver.selected) + for i, f in enumerate(item.fields): + self.write_container(i, f) - # # try: - # # self.tags_updated.disconnect() - # # except RuntimeError: - # # pass - # # if self.tag_callback: - # # # logging.info(f'[UPDATE CONTAINER] Updating Callback for {item.id}: {self.tag_callback}') - # # self.tags_updated.connect(self.tag_callback) + # Hide leftover containers + if len(self.containers) > len(item.fields): + for i, c in enumerate(self.containers): + if i > (len(item.fields) - 1): + c.setHidden(True) - + self.add_field_button.setHidden(False) - # # Initialized, Updating: - # elif self.item and self.item.id == item.id: - # # logging.info(f'Initialized Item, Updating! ({item.id})') - # for i, f in enumerate(item.fields): - # self.write_container(item, i, f) - - # # Hide leftover containers - # if len(self.containers) > len(self.item.fields): - # for i, c in enumerate(self.containers): - # if i > (len(self.item.fields) - 1): - # c.setHidden(True) + # 1 Selected Collation + elif self.driver.selected[0][0] == ItemType.COLLATION: + pass - + # 1 Selected Tag + elif self.driver.selected[0][0] == ItemType.TAG_GROUP: + pass - self.setWindowTitle(window_title) - self.show() + # Multiple Selected Items + 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.setCursor(Qt.CursorShape.ArrowCursor) + self.file_label.setFilePath("") + self.dimensions_label.setText("") - def set_tags_updated_slot(self, slot: object): - """ - Replacement for tag_callback. - """ - try: - self.tags_updated.disconnect() - except RuntimeError: - pass - logging.info(f'[UPDATE CONTAINER] Setting tags updated slot') - self.tags_updated.connect(slot) + self.preview_img.setContextMenuPolicy( + Qt.ContextMenuPolicy.NoContextMenu + ) + self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) - # def write_container(self, item:Union[Entry, Collation, Tag], index, field): - def write_container(self, index, field, mixed=False): - """Updates/Creates data for a FieldContainer.""" - # logging.info(f'[ENTRY PANEL] WRITE CONTAINER') - # Remove 'Add Field' button from scroll_layout, to be re-added later. - self.scroll_layout.takeAt(self.scroll_layout.count()-1).widget() - container: FieldContainer = None - if len(self.containers) < (index + 1): - container = FieldContainer() - self.containers.append(container) - self.scroll_layout.addWidget(container) - else: - 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')) - # container.set_editable(False) - container.set_inline(False) - title = f"{self.lib.get_field_attr(field, 'name')} (Tag Box)" - if not mixed: - item = self.lib.get_entry(self.selected[0][1]) # TODO TODO TODO: TEMPORARY - if type(container.get_inner_widget()) == TagBoxWidget: - inner_container: TagBoxWidget = container.get_inner_widget() - inner_container.set_item(item) - inner_container.set_tags(self.lib.get_field_attr(field, 'content')) - try: - inner_container.updated.disconnect() - except RuntimeError: - pass - # inner_container.updated.connect(lambda f=self.filepath, i=item: self.write_container(item, index, field)) - else: - 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(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) - else: - text = 'Mixed Data' - title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Tag Box)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - container.set_copy_callback(None) - container.set_edit_callback(None) - container.set_remove_callback(None) - - - self.tags_updated.emit() - # self.dynamic_widgets.append(inner_container) - elif self.lib.get_field_attr(field, 'type') in 'text_line': - # logging.info(f'WRITING TEXTLINE FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, 'name')) - # container.set_editable(True) - container.set_inline(False) - # Normalize line endings in any text content. - text: str = '' - if not mixed: - text = self.lib.get_field_attr( - field, 'content').replace('\r', '\n') - else: - text = 'Mixed Data' - title = f"{self.lib.get_field_attr(field, 'name')} (Text Line)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - # if type(item) == Entry: - if not mixed: - 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(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(field), self.update_widgets()) - container.set_remove_callback(lambda: self.remove_message_box( - prompt=prompt, - callback=callback)) - container.set_copy_callback(None) - else: - container.set_edit_callback(None) - container.set_copy_callback(None) - container.set_remove_callback(None) - # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) - - elif self.lib.get_field_attr(field, 'type') in 'text_box': - # logging.info(f'WRITING TEXTBOX FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, 'name')) - # container.set_editable(True) - container.set_inline(False) - # Normalize line endings in any text content. - text: str = '' - if not mixed: - text = self.lib.get_field_attr( - field, 'content').replace('\r', '\n') - else: - text = 'Mixed Data' - title = f"{self.lib.get_field_attr(field, 'name')} (Text Box)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - # if type(item) == Entry: - if not mixed: - 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(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(field), self.update_widgets()) - container.set_remove_callback(lambda: self.remove_message_box( - prompt=prompt, - callback=callback)) - else: - container.set_edit_callback(None) - container.set_copy_callback(None) - container.set_remove_callback(None) - elif self.lib.get_field_attr(field, 'type') == 'collation': - # logging.info(f'WRITING COLLATION FOR ITEM {item.id}') - container.set_title(self.lib.get_field_attr(field, 'name')) - # container.set_editable(True) - container.set_inline(False) - collation = self.lib.get_collation(self.lib.get_field_attr(field, 'content')) - title = f"{self.lib.get_field_attr(field, 'name')} (Collation)" - text: str = (f'{collation.title} ({len(collation.e_ids_and_pages)} Items)') - if len(self.selected) == 1: - text += f' - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}' - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - # if type(item) == Entry: - container.set_copy_callback(None) - # 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(field), self.update_widgets()) - container.set_remove_callback(lambda: self.remove_message_box( - prompt=prompt, - callback=callback)) - elif self.lib.get_field_attr(field, 'type') == 'datetime': - # logging.info(f'WRITING DATETIME FOR ITEM {item.id}') - if not mixed: - try: - container.set_title(self.lib.get_field_attr(field, 'name')) - # container.set_editable(False) - container.set_inline(False) - # TODO: Localize this and/or add preferences. - date = dt.strptime(self.lib.get_field_attr( - field, 'content'), '%Y-%m-%d %H:%M:%S') - title = f"{self.lib.get_field_attr(field, 'name')} (Date)" - inner_container = TextWidget(title, date.strftime('%D - %r')) - container.set_inner_widget(inner_container) - except: - container.set_title(self.lib.get_field_attr(field, 'name')) - # container.set_editable(False) - container.set_inline(False) - title = f"{self.lib.get_field_attr(field, 'name')} (Date) (Unknown Format)" - inner_container = TextWidget(title, str(self.lib.get_field_attr(field, 'content'))) - # if type(item) == Entry: - container.set_copy_callback(None) - 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(field), self.update_widgets()) - container.set_remove_callback(lambda: self.remove_message_box( - prompt=prompt, - callback=callback)) - else: - text = 'Mixed Data' - title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Date)" - inner_container = TextWidget(title, text) - container.set_inner_widget(inner_container) - container.set_copy_callback(None) - container.set_edit_callback(None) - container.set_remove_callback(None) - else: - # logging.info(f'[ENTRY PANEL] Unknown Type: {self.lib.get_field_attr(field, "type")}') - container.set_title(self.lib.get_field_attr(field, 'name')) - # container.set_editable(False) - container.set_inline(False) - title = f"{self.lib.get_field_attr(field, 'name')} (Unknown Field Type)" - inner_container = TextWidget(title, str(self.lib.get_field_attr(field, 'content'))) - container.set_inner_widget(inner_container) - # if type(item) == Entry: - container.set_copy_callback(None) - 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(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, - callback=callback)) - container.setHidden(False) - self.place_add_field_button() - - def remove_field(self, field:object): - """Removes a field from all selected Entries, given a field object.""" - for item_pair in self.selected: - if item_pair[0] == ItemType.ENTRY: - 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 - - def update_field(self, field:object, content): - """Removes a field from all selected Entries, given a field object.""" - field = dict(field) - for item_pair in self.selected: - if item_pair[0] == ItemType.ENTRY: - entry = self.lib.get_entry(item_pair[1]) - try: - logging.info(field) - index = entry.fields.index(field) - self.lib.update_entry_field(entry.id, index, content, 'replace') - except ValueError: - logging.info(f'[PREVIEW PANEL][ERROR] Tried to update field from Entry ({entry.id}) that never had it') - pass + ratio: float = self.devicePixelRatio() + self.tr.render_big(time.time(), "", (512, 512), ratio, True) + try: + self.preview_img.clicked.disconnect() + except RuntimeError: + pass - def remove_message_box(self, prompt:str, callback:FunctionType) -> int: - remove_mb = QMessageBox() - remove_mb.setText(prompt) - remove_mb.setWindowTitle('Remove Field') - remove_mb.setIcon(QMessageBox.Icon.Warning) - cancel_button = remove_mb.addButton('&Cancel', QMessageBox.ButtonRole.DestructiveRole) - remove_button = remove_mb.addButton('&Remove', QMessageBox.ButtonRole.RejectRole) - # remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel) - remove_mb.setDefaultButton(cancel_button) - result = remove_mb.exec_() - # logging.info(result) - if result == 1: - callback() + self.common_fields = [] + self.mixed_fields = [] + for i, item_pair in enumerate(self.driver.selected): + if item_pair[0] == ItemType.ENTRY: + item = self.lib.get_entry(item_pair[1]) + if i == 0: + for f in item.fields: + self.common_fields.append(f) + else: + common_to_remove = [] + for f in self.common_fields: + # Common field found (Same ID, identical content) + if f not in item.fields: + common_to_remove.append(f) + + # Mixed field found (Same ID, different content) + if self.lib.get_field_index_in_entry( + item, self.lib.get_field_attr(f, "id") + ): + # if self.lib.get_field_attr(f, 'type') == ('tag_box'): + # pass + # logging.info(f) + # logging.info(type(f)) + f_stripped = { + self.lib.get_field_attr(f, "id"): None + } + if f_stripped not in self.mixed_fields and ( + f not in self.common_fields + or f in common_to_remove + ): + # and (f not in self.common_fields or f in common_to_remove) + self.mixed_fields.append(f_stripped) + self.common_fields = [ + f for f in self.common_fields if f not in common_to_remove + ] + order: list[int] = ( + [0] + + [1, 2] + + [9, 17, 18, 19, 20] + + [8, 7, 6] + + [4] + + [3, 21] + + [10, 14, 11, 12, 13, 22] + + [5] + ) + self.mixed_fields = sorted( + self.mixed_fields, + key=lambda x: order.index(self.lib.get_field_attr(x, "id")), + ) + + self.selected = list(self.driver.selected) + for i, f in enumerate(self.common_fields): + logging.info(f"ci:{i}, f:{f}") + self.write_container(i, f) + for i, f in enumerate(self.mixed_fields, start=len(self.common_fields)): + logging.info(f"mi:{i}, f:{f}") + self.write_container(i, f, mixed=True) + + # Hide leftover containers + if len(self.containers) > len(self.common_fields) + len(self.mixed_fields): + for i, c in enumerate(self.containers): + if i > (len(self.common_fields) + len(self.mixed_fields) - 1): + c.setHidden(True) + + self.add_field_button.setHidden(False) + + self.initialized = True + + # # Uninitialized or New Item: + # if not self.item or self.item.id != item.id: + # # logging.info(f'Uninitialized or New Item ({item.id})') + # if type(item) == Entry: + # # New Entry: Render preview and update filename label + # filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}') + # window_title = filepath + # ratio: float = self.devicePixelRatio() + # self.tr.render_big(time.time(), filepath, (512, 512), ratio) + # self.file_label.setText("\u200b".join(filepath)) + + # # TODO: Deal with this later. + # # https://stackoverflow.com/questions/64252654/pyqt5-drag-and-drop-into-system-file-explorer-with-delayed-encoding + # # https://doc.qt.io/qtforpython-5/PySide2/QtCore/QMimeData.html#more + # # drag = QDrag(self.preview_img) + # # mime = QMimeData() + # # mime.setUrls([filepath]) + # # drag.setMimeData(mime) + # # drag.exec_(Qt.DropAction.CopyAction) + + # try: + # self.preview_img.clicked.disconnect() + # except RuntimeError: + # pass + # self.preview_img.clicked.connect( + # lambda checked=False, filepath=filepath: open_file(filepath)) + + # for i, f in enumerate(item.fields): + # self.write_container(item, i, f) + + # self.item = item + + # # try: + # # self.tags_updated.disconnect() + # # except RuntimeError: + # # pass + # # if self.tag_callback: + # # # logging.info(f'[UPDATE CONTAINER] Updating Callback for {item.id}: {self.tag_callback}') + # # self.tags_updated.connect(self.tag_callback) + + # # Initialized, Updating: + # elif self.item and self.item.id == item.id: + # # logging.info(f'Initialized Item, Updating! ({item.id})') + # for i, f in enumerate(item.fields): + # self.write_container(item, i, f) + + # # Hide leftover containers + # if len(self.containers) > len(self.item.fields): + # for i, c in enumerate(self.containers): + # if i > (len(self.item.fields) - 1): + # c.setHidden(True) + + self.setWindowTitle(window_title) + self.show() + + def set_tags_updated_slot(self, slot: object): + """ + Replacement for tag_callback. + """ + try: + self.tags_updated.disconnect() + except RuntimeError: + pass + logging.info(f"[UPDATE CONTAINER] Setting tags updated slot") + self.tags_updated.connect(slot) + + # def write_container(self, item:Union[Entry, Collation, Tag], index, field): + def write_container(self, index, field, mixed=False): + """Updates/Creates data for a FieldContainer.""" + # logging.info(f'[ENTRY PANEL] WRITE CONTAINER') + # Remove 'Add Field' button from scroll_layout, to be re-added later. + self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() + container: FieldContainer = None + if len(self.containers) < (index + 1): + container = FieldContainer() + self.containers.append(container) + self.scroll_layout.addWidget(container) + else: + 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")) + # container.set_editable(False) + container.set_inline(False) + title = f"{self.lib.get_field_attr(field, 'name')} (Tag Box)" + if not mixed: + item = self.lib.get_entry( + self.selected[0][1] + ) # TODO TODO TODO: TEMPORARY + if type(container.get_inner_widget()) == TagBoxWidget: + inner_container: TagBoxWidget = container.get_inner_widget() + inner_container.set_item(item) + inner_container.set_tags(self.lib.get_field_attr(field, "content")) + try: + inner_container.updated.disconnect() + except RuntimeError: + pass + # inner_container.updated.connect(lambda f=self.filepath, i=item: self.write_container(item, index, field)) + else: + 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(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) + else: + text = "Mixed Data" + title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Tag Box)" + inner_container = TextWidget(title, text) + container.set_inner_widget(inner_container) + container.set_copy_callback(None) + container.set_edit_callback(None) + container.set_remove_callback(None) + + self.tags_updated.emit() + # self.dynamic_widgets.append(inner_container) + elif self.lib.get_field_attr(field, "type") in "text_line": + # logging.info(f'WRITING TEXTLINE FOR ITEM {item.id}') + container.set_title(self.lib.get_field_attr(field, "name")) + # container.set_editable(True) + container.set_inline(False) + # Normalize line endings in any text content. + text: str = "" + if not mixed: + text = self.lib.get_field_attr(field, "content").replace("\r", "\n") + else: + text = "Mixed Data" + title = f"{self.lib.get_field_attr(field, 'name')} (Text Line)" + inner_container = TextWidget(title, text) + container.set_inner_widget(inner_container) + # if type(item) == Entry: + if not mixed: + 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(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(field), self.update_widgets()) + container.set_remove_callback( + lambda: self.remove_message_box(prompt=prompt, callback=callback) + ) + container.set_copy_callback(None) + else: + container.set_edit_callback(None) + container.set_copy_callback(None) + container.set_remove_callback(None) + # container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item))) + + elif self.lib.get_field_attr(field, "type") in "text_box": + # logging.info(f'WRITING TEXTBOX FOR ITEM {item.id}') + container.set_title(self.lib.get_field_attr(field, "name")) + # container.set_editable(True) + container.set_inline(False) + # Normalize line endings in any text content. + text: str = "" + if not mixed: + text = self.lib.get_field_attr(field, "content").replace("\r", "\n") + else: + text = "Mixed Data" + title = f"{self.lib.get_field_attr(field, 'name')} (Text Box)" + inner_container = TextWidget(title, text) + container.set_inner_widget(inner_container) + # if type(item) == Entry: + if not mixed: + 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(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(field), self.update_widgets()) + container.set_remove_callback( + lambda: self.remove_message_box(prompt=prompt, callback=callback) + ) + else: + container.set_edit_callback(None) + container.set_copy_callback(None) + container.set_remove_callback(None) + elif self.lib.get_field_attr(field, "type") == "collation": + # logging.info(f'WRITING COLLATION FOR ITEM {item.id}') + container.set_title(self.lib.get_field_attr(field, "name")) + # container.set_editable(True) + container.set_inline(False) + collation = self.lib.get_collation( + self.lib.get_field_attr(field, "content") + ) + title = f"{self.lib.get_field_attr(field, 'name')} (Collation)" + text: str = f"{collation.title} ({len(collation.e_ids_and_pages)} Items)" + if len(self.selected) == 1: + text += f" - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}" + inner_container = TextWidget(title, text) + container.set_inner_widget(inner_container) + # if type(item) == Entry: + container.set_copy_callback(None) + # 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(field), self.update_widgets()) + container.set_remove_callback( + lambda: self.remove_message_box(prompt=prompt, callback=callback) + ) + elif self.lib.get_field_attr(field, "type") == "datetime": + # logging.info(f'WRITING DATETIME FOR ITEM {item.id}') + if not mixed: + try: + container.set_title(self.lib.get_field_attr(field, "name")) + # container.set_editable(False) + container.set_inline(False) + # TODO: Localize this and/or add preferences. + date = dt.strptime( + self.lib.get_field_attr(field, "content"), "%Y-%m-%d %H:%M:%S" + ) + title = f"{self.lib.get_field_attr(field, 'name')} (Date)" + inner_container = TextWidget(title, date.strftime("%D - %r")) + container.set_inner_widget(inner_container) + except: + container.set_title(self.lib.get_field_attr(field, "name")) + # container.set_editable(False) + container.set_inline(False) + title = f"{self.lib.get_field_attr(field, 'name')} (Date) (Unknown Format)" + inner_container = TextWidget( + title, str(self.lib.get_field_attr(field, "content")) + ) + # if type(item) == Entry: + container.set_copy_callback(None) + 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(field), self.update_widgets()) + container.set_remove_callback( + lambda: self.remove_message_box(prompt=prompt, callback=callback) + ) + else: + text = "Mixed Data" + title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Date)" + inner_container = TextWidget(title, text) + container.set_inner_widget(inner_container) + container.set_copy_callback(None) + container.set_edit_callback(None) + container.set_remove_callback(None) + else: + # logging.info(f'[ENTRY PANEL] Unknown Type: {self.lib.get_field_attr(field, "type")}') + container.set_title(self.lib.get_field_attr(field, "name")) + # container.set_editable(False) + container.set_inline(False) + title = f"{self.lib.get_field_attr(field, 'name')} (Unknown Field Type)" + inner_container = TextWidget( + title, str(self.lib.get_field_attr(field, "content")) + ) + container.set_inner_widget(inner_container) + # if type(item) == Entry: + container.set_copy_callback(None) + 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(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, callback=callback) + ) + container.setHidden(False) + self.place_add_field_button() + + def remove_field(self, field: object): + """Removes a field from all selected Entries, given a field object.""" + for item_pair in self.selected: + if item_pair[0] == ItemType.ENTRY: + 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 + + def update_field(self, field: object, content): + """Removes a field from all selected Entries, given a field object.""" + field = dict(field) + for item_pair in self.selected: + if item_pair[0] == ItemType.ENTRY: + entry = self.lib.get_entry(item_pair[1]) + try: + logging.info(field) + index = entry.fields.index(field) + self.lib.update_entry_field(entry.id, index, content, "replace") + except ValueError: + logging.info( + f"[PREVIEW PANEL][ERROR] Tried to update field from Entry ({entry.id}) that never had it" + ) + pass + + def remove_message_box(self, prompt: str, callback: FunctionType) -> int: + remove_mb = QMessageBox() + remove_mb.setText(prompt) + remove_mb.setWindowTitle("Remove Field") + remove_mb.setIcon(QMessageBox.Icon.Warning) + cancel_button = remove_mb.addButton( + "&Cancel", QMessageBox.ButtonRole.DestructiveRole + ) + remove_button = remove_mb.addButton( + "&Remove", QMessageBox.ButtonRole.RejectRole + ) + # remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel) + remove_mb.setDefaultButton(cancel_button) + result = remove_mb.exec_() + # logging.info(result) + if result == 1: + callback() diff --git a/tagstudio/src/qt/widgets/progress.py b/tagstudio/src/qt/widgets/progress.py index 4278b44..ac14d87 100644 --- a/tagstudio/src/qt/widgets/progress.py +++ b/tagstudio/src/qt/widgets/progress.py @@ -10,24 +10,34 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QProgressDialog class ProgressWidget(QWidget): - """Prebuilt thread-safe progress bar widget.""" - def __init__(self, window_title:str, label_text:str, cancel_button_text:Optional[str], minimum:int, maximum:int): - super().__init__() - self.root = QVBoxLayout(self) - self.pb = QProgressDialog( - labelText=label_text, - minimum=minimum, - cancelButtonText=cancel_button_text, - maximum=maximum - ) - self.root.addWidget(self.pb) - self.setFixedSize(432, 112) - self.setWindowFlags(self.pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) - self.setWindowTitle(window_title) - self.setWindowModality(Qt.WindowModality.ApplicationModal) - - def update_label(self, text:str): - self.pb.setLabelText(text) + """Prebuilt thread-safe progress bar widget.""" - def update_progress(self, value:int): - self.pb.setValue(value) + def __init__( + self, + window_title: str, + label_text: str, + cancel_button_text: Optional[str], + minimum: int, + maximum: int, + ): + super().__init__() + self.root = QVBoxLayout(self) + self.pb = QProgressDialog( + labelText=label_text, + minimum=minimum, + cancelButtonText=cancel_button_text, + maximum=maximum, + ) + self.root.addWidget(self.pb) + self.setFixedSize(432, 112) + self.setWindowFlags( + self.pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint + ) + self.setWindowTitle(window_title) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + def update_label(self, text: str): + self.pb.setLabelText(text) + + def update_progress(self, value: int): + self.pb.setValue(value) diff --git a/tagstudio/src/qt/widgets/tag.py b/tagstudio/src/qt/widgets/tag.py index 4232fb3..91324e1 100644 --- a/tagstudio/src/qt/widgets/tag.py +++ b/tagstudio/src/qt/widgets/tag.py @@ -17,223 +17,236 @@ from src.core.library import Library, Tag from src.core.palette import ColorType, get_tag_color -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" class TagWidget(QWidget): - edit_icon_128: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/edit_icon_128.png')).resize((math.floor(14*1.25),math.floor(14*1.25))) - edit_icon_128.load() - on_remove = Signal() - on_click = Signal() - on_edit = Signal() + edit_icon_128: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/edit_icon_128.png" + ) + ).resize((math.floor(14 * 1.25), math.floor(14 * 1.25))) + edit_icon_128.load() + on_remove = Signal() + on_click = Signal() + on_edit = Signal() - def __init__(self, library:Library, tag:Tag, has_edit:bool, has_remove:bool, on_remove_callback:FunctionType=None, on_click_callback:FunctionType=None, on_edit_callback:FunctionType=None) -> None: - super().__init__() - self.lib = library - self.tag = tag - self.has_edit:bool = has_edit - self.has_remove:bool = has_remove - # self.bg_label = QLabel() - # self.setStyleSheet('background-color:blue;') - - # if on_click_callback: - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.base_layout = QVBoxLayout(self) - self.base_layout.setObjectName('baseLayout') - self.base_layout.setContentsMargins(0, 0, 0, 0) + def __init__( + self, + library: Library, + tag: Tag, + has_edit: bool, + has_remove: bool, + on_remove_callback: FunctionType = None, + on_click_callback: FunctionType = None, + on_edit_callback: FunctionType = None, + ) -> None: + super().__init__() + self.lib = library + self.tag = tag + self.has_edit: bool = has_edit + self.has_remove: bool = has_remove + # self.bg_label = QLabel() + # self.setStyleSheet('background-color:blue;') - self.bg_button = QPushButton(self) - self.bg_button.setFlat(True) - self.bg_button.setText(tag.display_name(self.lib).replace('&', '&&')) - if has_edit: - edit_action = QAction('Edit', self) - edit_action.triggered.connect(on_edit_callback) - edit_action.triggered.connect(self.on_edit.emit) - self.bg_button.addAction(edit_action) - # if on_click_callback: - self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - # if has_remove: - # remove_action = QAction('Remove', self) - # # remove_action.triggered.connect(on_remove_callback) - # remove_action.triggered.connect(self.on_remove.emit()) - # self.bg_button.addAction(remove_action) - search_for_tag_action = QAction('Search for Tag', self) - # search_for_tag_action.triggered.connect(on_click_callback) - search_for_tag_action.triggered.connect(self.on_click.emit) - self.bg_button.addAction(search_for_tag_action) - add_to_search_action = QAction('Add to Search', self) - self.bg_button.addAction(add_to_search_action) + # if on_click_callback: + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.base_layout = QVBoxLayout(self) + self.base_layout.setObjectName("baseLayout") + self.base_layout.setContentsMargins(0, 0, 0, 0) - self.inner_layout = QHBoxLayout() - self.inner_layout.setObjectName('innerLayout') - self.inner_layout.setContentsMargins(2, 2, 2, 2) - # self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignRight) - - # self.inner_container = QWidget() - # self.inner_container.setLayout(self.inner_layout) - # self.base_layout.addWidget(self.inner_container) - self.bg_button.setLayout(self.inner_layout) - self.bg_button.setMinimumSize(math.ceil(22*1.5), 22) + self.bg_button = QPushButton(self) + self.bg_button.setFlat(True) + self.bg_button.setText(tag.display_name(self.lib).replace("&", "&&")) + if has_edit: + edit_action = QAction("Edit", self) + edit_action.triggered.connect(on_edit_callback) + edit_action.triggered.connect(self.on_edit.emit) + self.bg_button.addAction(edit_action) + # if on_click_callback: + self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + # if has_remove: + # remove_action = QAction('Remove', self) + # # remove_action.triggered.connect(on_remove_callback) + # remove_action.triggered.connect(self.on_remove.emit()) + # self.bg_button.addAction(remove_action) + search_for_tag_action = QAction("Search for Tag", self) + # search_for_tag_action.triggered.connect(on_click_callback) + search_for_tag_action.triggered.connect(self.on_click.emit) + self.bg_button.addAction(search_for_tag_action) + add_to_search_action = QAction("Add to Search", self) + self.bg_button.addAction(add_to_search_action) - # self.bg_button.setStyleSheet( - # f'QPushButton {{' - # f'border: 2px solid #8f8f91;' - # f'border-radius: 6px;' - # f'background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,stop: 0 {ColorType.PRIMARY}, stop: 1 {ColorType.BORDER});' - # f'min-width: 80px;}}') + self.inner_layout = QHBoxLayout() + self.inner_layout.setObjectName("innerLayout") + self.inner_layout.setContentsMargins(2, 2, 2, 2) + # self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignRight) - self.bg_button.setStyleSheet( - # f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};' - f'QPushButton{{' - f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};' - # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' - # f"border-color:{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:solid;' - f'border-width: {math.ceil(1*self.devicePixelRatio())}px;' - # f'border-top:2px solid {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};' - # f'border-bottom:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' - # f'border-left:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' - # f'border-right:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' - # f'padding-top: 0.5px;' - f'padding-right: 4px;' - f'padding-bottom: 1px;' - f'padding-left: 4px;' - f'font-size: 13px' - f'}}' - f'QPushButton::hover{{' - # f'background: {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};' - # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' - # f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};" - # f"color: {get_tag_color(ColorType.TEXT, tag.color)};" - f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};" - f'}}') + # self.inner_container = QWidget() + # self.inner_container.setLayout(self.inner_layout) + # self.base_layout.addWidget(self.inner_container) + self.bg_button.setLayout(self.inner_layout) + self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22) - # self.renderer = ThumbRenderer() - # self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i), - # self.update_size( - # ts, size=s), - # self.set_extension(ext))) - - # self.bg_button.setLayout(self.base_layout) - - self.base_layout.addWidget(self.bg_button) - # self.setMinimumSize(self.bg_button.size()) + # self.bg_button.setStyleSheet( + # f'QPushButton {{' + # f'border: 2px solid #8f8f91;' + # f'border-radius: 6px;' + # f'background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,stop: 0 {ColorType.PRIMARY}, stop: 1 {ColorType.BORDER});' + # f'min-width: 80px;}}') - # logging.info(tag.color) - if has_remove: - self.remove_button = QPushButton(self) - self.remove_button.setFlat(True) - self.remove_button.setText('–') - self.remove_button.setHidden(True) - self.remove_button.setStyleSheet(f'color: {get_tag_color(ColorType.PRIMARY, tag.color)};' - f"background: {get_tag_color(ColorType.TEXT, tag.color)};" - # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # f"border-color: {get_tag_color(ColorType.BORDER, tag.color)};" - f'font-weight: 800;' - # f"border-color:{'black' if color not in [ - # 'black', 'gray', 'dark gray', - # 'cool gray', 'warm gray', 'blue', - # 'purple', 'violet'] else 'white'};" - f'border-radius: 4px;' - # f'border-style:solid;' - f'border-width:0;' - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' - f'padding-bottom: 4px;' - # f'padding-left: 4px;' - f'font-size: 14px') - self.remove_button.setMinimumSize(19,19) - self.remove_button.setMaximumSize(19,19) - # self.remove_button.clicked.connect(on_remove_callback) - self.remove_button.clicked.connect(self.on_remove.emit) + self.bg_button.setStyleSheet( + # f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};' + f"QPushButton{{" + f"background: {get_tag_color(ColorType.PRIMARY, tag.color)};" + # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' + # f"border-color:{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:solid;" + f"border-width: {math.ceil(1*self.devicePixelRatio())}px;" + # f'border-top:2px solid {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};' + # f'border-bottom:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' + # f'border-left:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' + # f'border-right:2px solid {get_tag_color(ColorType.BORDER, tag.color)};' + # f'padding-top: 0.5px;' + f"padding-right: 4px;" + f"padding-bottom: 1px;" + f"padding-left: 4px;" + f"font-size: 13px" + f"}}" + f"QPushButton::hover{{" + # f'background: {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};' + # f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});' + # f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};" + # f"color: {get_tag_color(ColorType.TEXT, tag.color)};" + f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};" + f"}}" + ) - # NOTE: No more edit button! Just make it a right-click option. - # self.edit_button = QPushButton(self) - # self.edit_button.setFlat(True) - # self.edit_button.setText('Edit') - # self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128))) - # self.edit_button.setIconSize(QSize(14,14)) - # self.edit_button.setHidden(True) - # self.edit_button.setStyleSheet(f'color: {color};' - # f"background: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # f"border-color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" - # f'font-weight: 600;' - # # f"border-color:{'black' if color not in [ - # # 'black', 'gray', 'dark gray', - # # 'cool gray', 'warm gray', 'blue', - # # 'purple', 'violet'] else 'white'};" - # # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}' - # # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}' - # f'border-radius: 4px;' - # # f'border-style:solid;' - # # f'border-width:1px;' - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' - # f'padding-bottom: 3px;' - # f'padding-left: 4px;' - # f'font-size: 14px') - # self.edit_button.setMinimumSize(18,18) - # # self.edit_button.setMaximumSize(18,18) + # self.renderer = ThumbRenderer() + # self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i), + # self.update_size( + # ts, size=s), + # self.set_extension(ext))) + # self.bg_button.setLayout(self.base_layout) - # self.inner_layout.addWidget(self.edit_button) - if has_remove: - self.inner_layout.addWidget(self.remove_button) - self.inner_layout.addStretch(1) - + self.base_layout.addWidget(self.bg_button) + # self.setMinimumSize(self.bg_button.size()) - # NOTE: Do this if you don't want the tag to stretch, like in a search. - # self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width()) - - # self.set_click(on_click_callback) - self.bg_button.clicked.connect(self.on_click.emit) + # logging.info(tag.color) + if has_remove: + self.remove_button = QPushButton(self) + self.remove_button.setFlat(True) + self.remove_button.setText("–") + self.remove_button.setHidden(True) + self.remove_button.setStyleSheet( + f"color: {get_tag_color(ColorType.PRIMARY, tag.color)};" + f"background: {get_tag_color(ColorType.TEXT, tag.color)};" + # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" + # f"border-color: {get_tag_color(ColorType.BORDER, tag.color)};" + f"font-weight: 800;" + # f"border-color:{'black' if color not in [ + # 'black', 'gray', 'dark gray', + # 'cool gray', 'warm gray', 'blue', + # 'purple', 'violet'] else 'white'};" + f"border-radius: 4px;" + # f'border-style:solid;' + f"border-width:0;" + # f'padding-top: 1.5px;' + # f'padding-right: 4px;' + f"padding-bottom: 4px;" + # f'padding-left: 4px;' + f"font-size: 14px" + ) + self.remove_button.setMinimumSize(19, 19) + self.remove_button.setMaximumSize(19, 19) + # self.remove_button.clicked.connect(on_remove_callback) + self.remove_button.clicked.connect(self.on_remove.emit) - # self.setMinimumSize(50,20) + # NOTE: No more edit button! Just make it a right-click option. + # self.edit_button = QPushButton(self) + # self.edit_button.setFlat(True) + # self.edit_button.setText('Edit') + # self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128))) + # self.edit_button.setIconSize(QSize(14,14)) + # self.edit_button.setHidden(True) + # self.edit_button.setStyleSheet(f'color: {color};' + # f"background: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" + # # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" + # f"border-color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};" + # f'font-weight: 600;' + # # f"border-color:{'black' if color not in [ + # # 'black', 'gray', 'dark gray', + # # 'cool gray', 'warm gray', 'blue', + # # 'purple', 'violet'] else 'white'};" + # # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}' + # # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}' + # f'border-radius: 4px;' + # # f'border-style:solid;' + # # f'border-width:1px;' + # f'padding-top: 1.5px;' + # f'padding-right: 4px;' + # f'padding-bottom: 3px;' + # f'padding-left: 4px;' + # f'font-size: 14px') + # self.edit_button.setMinimumSize(18,18) + # # self.edit_button.setMaximumSize(18,18) - # def set_name(self, name:str): - # self.bg_label.setText(str) + # self.inner_layout.addWidget(self.edit_button) + if has_remove: + self.inner_layout.addWidget(self.remove_button) + self.inner_layout.addStretch(1) - # def on_remove(self): - # if self.item and self.item[0] == ItemType.ENTRY: - # if self.field_index >= 0: - # self.lib.get_entry(self.item[1]).remove_tag(self.tag.id, self.field_index) - # else: - # self.lib.get_entry(self.item[1]).remove_tag(self.tag.id) + # NOTE: Do this if you don't want the tag to stretch, like in a search. + # self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width()) - # def set_click(self, callback): - # try: - # self.bg_button.clicked.disconnect() - # except RuntimeError: - # pass - # if callback: - # self.bg_button.clicked.connect(callback) + # self.set_click(on_click_callback) + self.bg_button.clicked.connect(self.on_click.emit) - # def set_click(self, function): - # try: - # self.bg.clicked.disconnect() - # except RuntimeError: - # pass - # # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath)) - # # self.bg.clicked.connect(function) - - def enterEvent(self, event: QEnterEvent) -> None: - if self.has_remove: - self.remove_button.setHidden(False) - # self.edit_button.setHidden(False) - self.update() - return super().enterEvent(event) + # self.setMinimumSize(50,20) - def leaveEvent(self, event: QEvent) -> None: - if self.has_remove: - self.remove_button.setHidden(True) - # self.edit_button.setHidden(True) - self.update() - return super().leaveEvent(event) + # def set_name(self, name:str): + # self.bg_label.setText(str) + + # def on_remove(self): + # if self.item and self.item[0] == ItemType.ENTRY: + # if self.field_index >= 0: + # self.lib.get_entry(self.item[1]).remove_tag(self.tag.id, self.field_index) + # else: + # self.lib.get_entry(self.item[1]).remove_tag(self.tag.id) + + # def set_click(self, callback): + # try: + # self.bg_button.clicked.disconnect() + # except RuntimeError: + # pass + # if callback: + # self.bg_button.clicked.connect(callback) + + # def set_click(self, function): + # try: + # self.bg.clicked.disconnect() + # except RuntimeError: + # pass + # # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath)) + # # self.bg.clicked.connect(function) + + def enterEvent(self, event: QEnterEvent) -> None: + if self.has_remove: + self.remove_button.setHidden(False) + # self.edit_button.setHidden(False) + self.update() + return super().enterEvent(event) + + def leaveEvent(self, event: QEvent) -> None: + if self.has_remove: + self.remove_button.setHidden(True) + # self.edit_button.setHidden(True) + self.update() + return super().leaveEvent(event) diff --git a/tagstudio/src/qt/widgets/tag_box.py b/tagstudio/src/qt/widgets/tag_box.py index 631865f..a6fd71c 100644 --- a/tagstudio/src/qt/widgets/tag_box.py +++ b/tagstudio/src/qt/widgets/tag_box.py @@ -17,145 +17,168 @@ from src.qt.modals import BuildTagPanel, TagSearchPanel # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver + from src.qt.ts_qt import QtDriver 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) - self.item = item - self.lib = library - self.driver = driver # Used for creating tag click callbacks that search entries for that tag. - self.field_index = field_index - self.tags:list[int] = tags - self.setObjectName('tagBox') - self.base_layout = FlowLayout() - self.base_layout.setGridEfficiency(False) - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self.base_layout) + updated = Signal() - self.add_button = QPushButton() - self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.add_button.setMinimumSize(23, 23) - self.add_button.setMaximumSize(23, 23) - self.add_button.setText('+') - self.add_button.setStyleSheet( - f'QPushButton{{' - # f'background: #1E1A33;' - # f'color: #CDA7F7;' - f'font-weight: bold;' - # f"border-color: #2B2547;" - f'border-radius: 6px;' - f'border-style:solid;' - f'border-width:{math.ceil(1*self.devicePixelRatio())}px;' - # f'padding-top: 1.5px;' - # f'padding-right: 4px;' - f'padding-bottom: 5px;' - # f'padding-left: 4px;' - f'font-size: 20px;' - f'}}' - f'QPushButton::hover' - f'{{' - # f'background: #2B2547;' - f'}}') - tsp = TagSearchPanel(self.lib) - tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) - self.add_modal = PanelModal(tsp, title, 'Add Tags') - self.add_button.clicked.connect(lambda: (tsp.update_tags() ,self.add_modal.show())) + def __init__( + self, + item, + title, + field_index, + library: Library, + tags: list[int], + driver: "QtDriver", + ) -> None: + super().__init__(title) + # QObject.__init__(self) + self.item = item + self.lib = library + self.driver = driver # Used for creating tag click callbacks that search entries for that tag. + self.field_index = field_index + self.tags: list[int] = tags + self.setObjectName("tagBox") + self.base_layout = FlowLayout() + self.base_layout.setGridEfficiency(False) + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.base_layout) - self.set_tags(tags) - # self.add_button.setHidden(True) + self.add_button = QPushButton() + self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_button.setMinimumSize(23, 23) + self.add_button.setMaximumSize(23, 23) + self.add_button.setText("+") + self.add_button.setStyleSheet( + f"QPushButton{{" + # f'background: #1E1A33;' + # f'color: #CDA7F7;' + f"font-weight: bold;" + # f"border-color: #2B2547;" + f"border-radius: 6px;" + f"border-style:solid;" + f"border-width:{math.ceil(1*self.devicePixelRatio())}px;" + # f'padding-top: 1.5px;' + # f'padding-right: 4px;' + f"padding-bottom: 5px;" + # f'padding-left: 4px;' + f"font-size: 20px;" + f"}}" + f"QPushButton::hover" + f"{{" + # f'background: #2B2547;' + f"}}" + ) + tsp = TagSearchPanel(self.lib) + tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x)) + self.add_modal = PanelModal(tsp, title, "Add Tags") + self.add_button.clicked.connect( + lambda: (tsp.update_tags(), self.add_modal.show()) + ) - def set_item(self, item): - self.item = item + self.set_tags(tags) + # self.add_button.setHidden(True) - def set_tags(self, tags:list[int]): - logging.info(f'[TAG BOX WIDGET] SET TAGS: T:{tags} for E:{self.item.id}') - is_recycled = False - if self.base_layout.itemAt(0): - # logging.info(type(self.base_layout.itemAt(0).widget())) - while self.base_layout.itemAt(0) and self.base_layout.itemAt(1): - # logging.info(f"I'm deleting { self.base_layout.itemAt(0).widget()}") - self.base_layout.takeAt(0).widget().deleteLater() - is_recycled = True - for tag in tags: - # TODO: Remove space from the special search here (tag_id:x) once that system is finalized. - # tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True, - # on_remove_callback=lambda checked=False, t=tag: (self.lib.get_entry(self.item.id).remove_tag(self.lib, t, self.field_index), self.updated.emit()), - # on_click_callback=lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q)), - # on_edit_callback=lambda checked=False, t=tag: (self.edit_tag(t)) - # ) - 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.remove_tag(t))) - tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t))) - self.base_layout.addWidget(tw) - self.tags = tags + def set_item(self, item): + self.item = item - # Move or add the '+' button. - if is_recycled: - self.base_layout.addWidget(self.base_layout.takeAt(0).widget()) - else: - self.base_layout.addWidget(self.add_button) - - # Handles an edge case where there are no more tags and the '+' button - # 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 set_tags(self, tags: list[int]): + logging.info(f"[TAG BOX WIDGET] SET TAGS: T:{tags} for E:{self.item.id}") + is_recycled = False + if self.base_layout.itemAt(0): + # logging.info(type(self.base_layout.itemAt(0).widget())) + while self.base_layout.itemAt(0) and self.base_layout.itemAt(1): + # logging.info(f"I'm deleting { self.base_layout.itemAt(0).widget()}") + self.base_layout.takeAt(0).widget().deleteLater() + is_recycled = True + for tag in tags: + # TODO: Remove space from the special search here (tag_id:x) once that system is finalized. + # tw = TagWidget(self.lib, self.lib.get_tag(tag), True, True, + # on_remove_callback=lambda checked=False, t=tag: (self.lib.get_entry(self.item.id).remove_tag(self.lib, t, self.field_index), self.updated.emit()), + # on_click_callback=lambda checked=False, q=f'tag_id: {tag}': (self.driver.main_window.searchField.setText(q), self.driver.filter_items(q)), + # on_edit_callback=lambda checked=False, t=tag: (self.edit_tag(t)) + # ) + 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.remove_tag(t))) + tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t))) + self.base_layout.addWidget(tw) + self.tags = tags + # Move or add the '+' button. + if is_recycled: + self.base_layout.addWidget(self.base_layout.takeAt(0).widget()) + else: + self.base_layout.addWidget(self.add_button) - 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.driver.preview_panel.update_widgets), - 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.lib.update_tag(btp.build_tag())) - # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) - self.edit_modal.show() + # Handles an edge case where there are no more tags and the '+' button + # 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)) + self.edit_modal = PanelModal( + btp, + self.lib.get_tag(tag_id).display_name(self.lib), + "Edit Tag", + done_callback=(self.driver.preview_panel.update_widgets), + 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.lib.update_tag(btp.build_tag())) + # panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag)) + self.edit_modal.show() - def add_tag_callback(self, tag_id): - # 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}') - 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() + def add_tag_callback(self, tag_id): + # 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}" + ) + 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() - # if tag_id not in self.tags: - # self.tags.append(tag_id) - # self.set_tags(self.tags) - # elif type((x[0]) == ThumbButton): + # 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() + # 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 edit_tag_callback(self, tag: Tag): + self.lib.update_tag(tag) - # def show_add_button(self, value:bool): - # self.add_button.setHidden(not value) + 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 show_add_button(self, value:bool): + # self.add_button.setHidden(not value) diff --git a/tagstudio/src/qt/widgets/text.py b/tagstudio/src/qt/widgets/text.py index be8fc24..12cb883 100644 --- a/tagstudio/src/qt/widgets/text.py +++ b/tagstudio/src/qt/widgets/text.py @@ -9,23 +9,23 @@ from src.qt.widgets import FieldWidget class TextWidget(FieldWidget): + def __init__(self, title, text: str) -> None: + super().__init__(title) + # self.item = item + self.setObjectName("textBox") + # self.setStyleSheet('background-color:purple;') + self.base_layout = QHBoxLayout() + self.base_layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.base_layout) + self.text_label = QLabel() + # self.text_label.textFormat(Qt.TextFormat.RichText) + self.text_label.setStyleSheet("font-size: 12px") + self.text_label.setWordWrap(True) + self.text_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + self.base_layout.addWidget(self.text_label) + self.set_text(text) - def __init__(self, title, text:str) -> None: - super().__init__(title) - # self.item = item - self.setObjectName('textBox') - # self.setStyleSheet('background-color:purple;') - self.base_layout = QHBoxLayout() - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self.base_layout) - self.text_label = QLabel() - # self.text_label.textFormat(Qt.TextFormat.RichText) - self.text_label.setStyleSheet('font-size: 12px') - self.text_label.setWordWrap(True) - self.text_label.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse) - self.base_layout.addWidget(self.text_label) - self.set_text(text) - - def set_text(self, text:str): - self.text_label.setText(text) + def set_text(self, text: str): + self.text_label.setText(text) diff --git a/tagstudio/src/qt/widgets/text_box_edit.py b/tagstudio/src/qt/widgets/text_box_edit.py index a636aab..cb95208 100644 --- a/tagstudio/src/qt/widgets/text_box_edit.py +++ b/tagstudio/src/qt/widgets/text_box_edit.py @@ -9,19 +9,19 @@ from src.qt.widgets import PanelWidget class EditTextBox(PanelWidget): - def __init__(self, text): - super().__init__() - # self.setLayout() - self.setMinimumSize(480, 480) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,0,6,0) - self.text = text - self.text_edit = QPlainTextEdit() - self.text_edit.setPlainText(text) - self.root_layout.addWidget(self.text_edit) - - def get_content(self)-> str: - return self.text_edit.toPlainText() - - def reset(self): - self.text_edit.setPlainText(self.text) + def __init__(self, text): + super().__init__() + # self.setLayout() + self.setMinimumSize(480, 480) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.text = text + self.text_edit = QPlainTextEdit() + self.text_edit.setPlainText(text) + self.root_layout.addWidget(self.text_edit) + + def get_content(self) -> str: + return self.text_edit.toPlainText() + + def reset(self): + self.text_edit.setPlainText(self.text) diff --git a/tagstudio/src/qt/widgets/text_line_edit.py b/tagstudio/src/qt/widgets/text_line_edit.py index 280b5fa..2158b97 100644 --- a/tagstudio/src/qt/widgets/text_line_edit.py +++ b/tagstudio/src/qt/widgets/text_line_edit.py @@ -9,20 +9,20 @@ from src.qt.widgets import PanelWidget class EditTextLine(PanelWidget): - def __init__(self, text): - super().__init__() - # self.setLayout() - self.setMinimumWidth(480) - self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(6,0,6,0) - self.text = text - self.text_edit = QLineEdit() - self.text_edit.setText(text) - self.text_edit.returnPressed.connect(self.done.emit) - self.root_layout.addWidget(self.text_edit) - - def get_content(self)-> str: - return self.text_edit.text() - - def reset(self): - self.text_edit.setText(self.text) + def __init__(self, text): + super().__init__() + # self.setLayout() + self.setMinimumWidth(480) + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(6, 0, 6, 0) + self.text = text + self.text_edit = QLineEdit() + self.text_edit.setText(text) + self.text_edit.returnPressed.connect(self.done.emit) + self.root_layout.addWidget(self.text_edit) + + def get_content(self) -> str: + return self.text_edit.text() + + def reset(self): + self.text_edit.setText(self.text) diff --git a/tagstudio/src/qt/widgets/thumb_button.py b/tagstudio/src/qt/widgets/thumb_button.py index dbe16e0..d6fb68f 100644 --- a/tagstudio/src/qt/widgets/thumb_button.py +++ b/tagstudio/src/qt/widgets/thumb_button.py @@ -10,64 +10,79 @@ from PySide6.QtWidgets import QWidget, QPushButton class ThumbButton(QPushButton): - def __init__(self, parent:QWidget, thumb_size:tuple[int,int]) -> None: - super().__init__(parent) - self.thumb_size:tuple[int,int] = thumb_size - self.hovered = False - self.selected = False + def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: + super().__init__(parent) + self.thumb_size: tuple[int, int] = thumb_size + self.hovered = False + self.selected = False - # self.clicked.connect(lambda checked: self.set_selected(True)) - - def paintEvent(self, event:QEvent) -> None: - super().paintEvent(event) - if self.hovered or self.selected: - painter = QPainter() - painter.begin(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - # painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - path = QPainterPath() - width = 3 - radius = 6 - path.addRoundedRect(QtCore.QRectF(width/2,width/2,self.thumb_size[0]-width, self.thumb_size[1]-width), radius, radius) + # self.clicked.connect(lambda checked: self.set_selected(True)) - # color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6') - # pen = QPen(color, width) - # painter.setPen(pen) - # # brush.setColor(fill) - # painter.drawPath(path) + def paintEvent(self, event: QEvent) -> None: + super().paintEvent(event) + if self.hovered or self.selected: + painter = QPainter() + painter.begin(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + # painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) + path = QPainterPath() + width = 3 + radius = 6 + path.addRoundedRect( + QtCore.QRectF( + width / 2, + width / 2, + self.thumb_size[0] - width, + self.thumb_size[1] - width, + ), + radius, + radius, + ) - if self.selected: - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight) - color = QColor('#bb4ff0') - color.setAlphaF(0.5) - pen = QPen(color, width) - painter.setPen(pen) - painter.fillPath(path, color) - painter.drawPath(path) + # color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6') + # pen = QPen(color, width) + # painter.setPen(pen) + # # brush.setColor(fill) + # painter.drawPath(path) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color = QColor('#bb4ff0') if not self.hovered else QColor('#55bbf6') - pen = QPen(color, width) - painter.setPen(pen) - painter.drawPath(path) - elif self.hovered: - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color = QColor('#55bbf6') - pen = QPen(color, width) - painter.setPen(pen) - painter.drawPath(path) - painter.end() - - def enterEvent(self, event: QEnterEvent) -> None: - self.hovered = True - self.repaint() - return super().enterEvent(event) + if self.selected: + painter.setCompositionMode( + QPainter.CompositionMode.CompositionMode_HardLight + ) + color = QColor("#bb4ff0") + color.setAlphaF(0.5) + pen = QPen(color, width) + painter.setPen(pen) + painter.fillPath(path, color) + painter.drawPath(path) - def leaveEvent(self, event: QEvent) -> None: - self.hovered = False - self.repaint() - return super().leaveEvent(event) + painter.setCompositionMode( + QPainter.CompositionMode.CompositionMode_Source + ) + color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6") + pen = QPen(color, width) + painter.setPen(pen) + painter.drawPath(path) + elif self.hovered: + painter.setCompositionMode( + QPainter.CompositionMode.CompositionMode_Source + ) + color = QColor("#55bbf6") + pen = QPen(color, width) + painter.setPen(pen) + painter.drawPath(path) + painter.end() - def set_selected(self, value:bool) -> None: - self.selected = value - self.repaint() + def enterEvent(self, event: QEnterEvent) -> None: + self.hovered = True + self.repaint() + return super().enterEvent(event) + + def leaveEvent(self, event: QEvent) -> None: + self.hovered = False + self.repaint() + return super().leaveEvent(event) + + def set_selected(self, value: bool) -> None: + self.selected = value + self.repaint() diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index be60030..3763032 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -10,412 +10,493 @@ import os from pathlib import Path import cv2 -from PIL import Image, ImageChops, UnidentifiedImageError, ImageQt, ImageDraw, ImageFont, ImageEnhance, ImageOps +from PIL import ( + Image, + ImageChops, + UnidentifiedImageError, + ImageQt, + ImageDraw, + ImageFont, + ImageEnhance, + ImageOps, +) from PySide6.QtCore import QObject, Signal, QSize from PySide6.QtGui import QPixmap from src.core.ts_core import PLAINTEXT_TYPES, VIDEO_TYPES, IMAGE_TYPES -ERROR = f'[ERROR]' -WARNING = f'[WARNING]' -INFO = f'[INFO]' +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class ThumbRenderer(QObject): - # finished = Signal() - updated = Signal(float, QPixmap, QSize, str) - updated_ratio = Signal(float) - # updatedImage = Signal(QPixmap) - # updatedSize = Signal(QSize) + # finished = Signal() + updated = Signal(float, QPixmap, QSize, str) + updated_ratio = Signal(float) + # updatedImage = Signal(QPixmap) + # updatedSize = Signal(QSize) - thumb_mask_512: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_mask_512.png')) - thumb_mask_512.load() + thumb_mask_512: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_mask_512.png" + ) + ) + thumb_mask_512.load() - thumb_mask_hl_512: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_mask_hl_512.png')) - thumb_mask_hl_512.load() + thumb_mask_hl_512: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_mask_hl_512.png" + ) + ) + thumb_mask_hl_512.load() - thumb_loading_512: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_loading_512.png')) - thumb_loading_512.load() + thumb_loading_512: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_loading_512.png" + ) + ) + thumb_loading_512.load() - thumb_broken_512: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_broken_512.png')) - thumb_broken_512.load() + thumb_broken_512: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_broken_512.png" + ) + ) + thumb_broken_512.load() - thumb_file_default_512: Image.Image = Image.open(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_file_default_512.png')) - thumb_file_default_512.load() + thumb_file_default_512: Image.Image = Image.open( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/images/thumb_file_default_512.png" + ) + ) + thumb_file_default_512.load() - # thumb_debug: Image.Image = Image.open(os.path.normpath( - # f'{Path(__file__).parent.parent.parent}/resources/qt/images/temp.jpg')) - # thumb_debug.load() + # thumb_debug: Image.Image = Image.open(os.path.normpath( + # f'{Path(__file__).parent.parent.parent}/resources/qt/images/temp.jpg')) + # thumb_debug.load() - # TODO: Make dynamic font sized given different pixel ratios - font_pixel_ratio: float = 1 - ext_font = ImageFont.truetype(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), math.floor(12*font_pixel_ratio)) + # TODO: Make dynamic font sized given different pixel ratios + font_pixel_ratio: float = 1 + ext_font = ImageFont.truetype( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf" + ), + math.floor(12 * font_pixel_ratio), + ) - def render(self, timestamp: float, filepath, base_size: tuple[int, int], pixelRatio: float, isLoading=False): - """Renders an entry/element thumbnail for the GUI.""" - adj_size: int = 1 - image = None - pixmap = None - final = None - extension: str = None - broken_thumb = False - # adj_font_size = math.floor(12 * pixelRatio) - if ThumbRenderer.font_pixel_ratio != pixelRatio: - ThumbRenderer.font_pixel_ratio = pixelRatio - ThumbRenderer.ext_font = ImageFont.truetype(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), math.floor(12*ThumbRenderer.font_pixel_ratio)) + def render( + self, + timestamp: float, + filepath, + base_size: tuple[int, int], + pixelRatio: float, + isLoading=False, + ): + """Renders an entry/element thumbnail for the GUI.""" + adj_size: int = 1 + image = None + pixmap = None + final = None + extension: str = None + broken_thumb = False + # adj_font_size = math.floor(12 * pixelRatio) + if ThumbRenderer.font_pixel_ratio != pixelRatio: + ThumbRenderer.font_pixel_ratio = pixelRatio + ThumbRenderer.ext_font = ImageFont.truetype( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf" + ), + math.floor(12 * ThumbRenderer.font_pixel_ratio), + ) - if isLoading or filepath: - adj_size = math.ceil(base_size[0] * pixelRatio) + if isLoading or filepath: + adj_size = math.ceil(base_size[0] * pixelRatio) - if isLoading: - li: Image.Image = ThumbRenderer.thumb_loading_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) - qim = ImageQt.ImageQt(li) - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixelRatio) - elif filepath: - mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3) - hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) + if isLoading: + li: Image.Image = ThumbRenderer.thumb_loading_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) + qim = ImageQt.ImageQt(li) + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixelRatio) + elif filepath: + mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ).getchannel(3) + hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) - extension = os.path.splitext(filepath)[1][1:].lower() + extension = os.path.splitext(filepath)[1][1:].lower() - try: - # Images ======================================================= - if extension in IMAGE_TYPES: - image = Image.open(filepath) - # image = self.thumb_debug - if image.mode == 'RGBA': - # logging.info(image.getchannel(3).tobytes()) - new_bg = Image.new('RGB', image.size, color='#222222') - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - if image.mode != 'RGB': - image = image.convert(mode='RGB') + try: + # Images ======================================================= + if extension in IMAGE_TYPES: + image = Image.open(filepath) + # image = self.thumb_debug + if image.mode == "RGBA": + # logging.info(image.getchannel(3).tobytes()) + new_bg = Image.new("RGB", image.size, color="#222222") + new_bg.paste(image, mask=image.getchannel(3)) + image = new_bg + if image.mode != "RGB": + image = image.convert(mode="RGB") - image = ImageOps.exif_transpose(image) + image = ImageOps.exif_transpose(image) - # Videos ======================================================= - elif extension in VIDEO_TYPES: - video = cv2.VideoCapture(filepath) - video.set(cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2)) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) + # Videos ======================================================= + elif extension in VIDEO_TYPES: + video = cv2.VideoCapture(filepath) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) - # Plain Text =================================================== - elif extension in PLAINTEXT_TYPES: - try: - text: str = extension - with open(filepath, 'r', encoding='utf-8') as text_file: - text = text_file.read(256) - bg = Image.new('RGB',(256,256), color='#222222') - draw = ImageDraw.Draw(bg) - draw.text((16,16), text, file=(255,255,255)) - image = bg - except: - logging.info(f'[ThumbRenderer][ERROR]: Coulnd\'t render thumbnail for {filepath}') - # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) + # Plain Text =================================================== + elif extension in PLAINTEXT_TYPES: + try: + text: str = extension + with open(filepath, "r", encoding="utf-8") as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color="#222222") + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, file=(255, 255, 255)) + image = bg + except: + logging.info( + f"[ThumbRenderer][ERROR]: Coulnd't render thumbnail for {filepath}" + ) + # No Rendered Thumbnail ======================================== + else: + image = ThumbRenderer.thumb_file_default_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) - if not image: - raise UnidentifiedImageError + if not image: + raise UnidentifiedImageError - orig_x, orig_y = image.size - new_x, new_y = (adj_size, adj_size) - - if orig_x > orig_y: - new_x = adj_size - new_y = math.ceil(adj_size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = adj_size - new_x = math.ceil(adj_size * (orig_x / orig_y)) + orig_x, orig_y = image.size + new_x, new_y = (adj_size, adj_size) - # img_ratio = new_x / new_y - image = image.resize( - (new_x, new_y), resample=Image.Resampling.BILINEAR) + if orig_x > orig_y: + new_x = adj_size + new_y = math.ceil(adj_size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = adj_size + new_x = math.ceil(adj_size * (orig_x / orig_y)) - if image.size != (adj_size, adj_size): - # Old 1 color method. - # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) - # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) - # bg.thumbnail((1, 1)) - # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) + # img_ratio = new_x / new_y + image = image.resize((new_x, new_y), resample=Image.Resampling.BILINEAR) - # Small gradient background. Looks decent, and is only a one-liner. - # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR) + if image.size != (adj_size, adj_size): + # Old 1 color method. + # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) + # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) + # bg.thumbnail((1, 1)) + # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) - # Four-Corner Gradient Background. - # Not exactly a one-liner, but it's (subjectively) really cool. - tl = image.getpixel((0, 0)) - tr = image.getpixel(((image.size[0]-1), 0)) - bl = image.getpixel((0, (image.size[1]-1))) - br = image.getpixel(((image.size[0]-1), (image.size[1]-1))) - bg = Image.new(mode='RGB', size=(2, 2)) - bg.paste(tl, (0, 0, 2, 2)) - bg.paste(tr, (1, 0, 2, 2)) - bg.paste(bl, (0, 1, 2, 2)) - bg.paste(br, (1, 1, 2, 2)) - bg = bg.resize((adj_size, adj_size), - resample=Image.Resampling.BICUBIC) + # Small gradient background. Looks decent, and is only a one-liner. + # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR) - bg.paste(image, box=( - (adj_size-image.size[0])//2, (adj_size-image.size[1])//2)) + # Four-Corner Gradient Background. + # Not exactly a one-liner, but it's (subjectively) really cool. + tl = image.getpixel((0, 0)) + tr = image.getpixel(((image.size[0] - 1), 0)) + bl = image.getpixel((0, (image.size[1] - 1))) + br = image.getpixel(((image.size[0] - 1), (image.size[1] - 1))) + bg = Image.new(mode="RGB", size=(2, 2)) + bg.paste(tl, (0, 0, 2, 2)) + bg.paste(tr, (1, 0, 2, 2)) + bg.paste(bl, (0, 1, 2, 2)) + bg.paste(br, (1, 1, 2, 2)) + bg = bg.resize( + (adj_size, adj_size), resample=Image.Resampling.BICUBIC + ) - bg.putalpha(mask) - final = bg + bg.paste( + image, + box=( + (adj_size - image.size[0]) // 2, + (adj_size - image.size[1]) // 2, + ), + ) - else: - image.putalpha(mask) - final = image + bg.putalpha(mask) + final = bg - hl_soft = hl.copy() - hl_soft.putalpha(ImageEnhance.Brightness( - hl.getchannel(3)).enhance(.5)) - final.paste(ImageChops.soft_light(final, hl_soft), - mask=hl_soft.getchannel(3)) + else: + image.putalpha(mask) + final = image - # hl_add = hl.copy() - # hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25)) - # final.paste(hl_add, mask=hl_add.getchannel(3)) + hl_soft = hl.copy() + hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5)) + final.paste( + ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3) + ) - except (UnidentifiedImageError, FileNotFoundError, cv2.error): - broken_thumb = True - final = ThumbRenderer.thumb_broken_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) + # hl_add = hl.copy() + # hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25)) + # final.paste(hl_add, mask=hl_add.getchannel(3)) - qim = ImageQt.ImageQt(final) - if image: - image.close() - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixelRatio) + except (UnidentifiedImageError, FileNotFoundError, cv2.error): + broken_thumb = True + final = ThumbRenderer.thumb_broken_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) - if pixmap: - self.updated.emit(timestamp, pixmap, QSize(*base_size), extension) + qim = ImageQt.ImageQt(final) + if image: + image.close() + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixelRatio) - else: - self.updated.emit(timestamp, QPixmap(), - QSize(*base_size), extension) + if pixmap: + self.updated.emit(timestamp, pixmap, QSize(*base_size), extension) - def render_big(self, timestamp: float, filepath, base_size: tuple[int, int], pixelRatio: float, isLoading=False): - """Renders a large, non-square entry/element thumbnail for the GUI.""" - adj_size: int = 1 - image: Image.Image = None - pixmap: QPixmap = None - final: Image.Image = None - extension: str = None - broken_thumb = False - img_ratio = 1 - # adj_font_size = math.floor(12 * pixelRatio) - if ThumbRenderer.font_pixel_ratio != pixelRatio: - ThumbRenderer.font_pixel_ratio = pixelRatio - ThumbRenderer.ext_font = ImageFont.truetype(os.path.normpath( - f'{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf'), math.floor(12*ThumbRenderer.font_pixel_ratio)) + else: + self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension) - if isLoading or filepath: - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixelRatio) + def render_big( + self, + timestamp: float, + filepath, + base_size: tuple[int, int], + pixelRatio: float, + isLoading=False, + ): + """Renders a large, non-square entry/element thumbnail for the GUI.""" + adj_size: int = 1 + image: Image.Image = None + pixmap: QPixmap = None + final: Image.Image = None + extension: str = None + broken_thumb = False + img_ratio = 1 + # adj_font_size = math.floor(12 * pixelRatio) + if ThumbRenderer.font_pixel_ratio != pixelRatio: + ThumbRenderer.font_pixel_ratio = pixelRatio + ThumbRenderer.ext_font = ImageFont.truetype( + os.path.normpath( + f"{Path(__file__).parent.parent.parent.parent}/resources/qt/fonts/Oxanium-Bold.ttf" + ), + math.floor(12 * ThumbRenderer.font_pixel_ratio), + ) - if isLoading: - adj_size = math.ceil((512 * pixelRatio)) - final: Image.Image = ThumbRenderer.thumb_loading_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) - qim = ImageQt.ImageQt(final) - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixelRatio) - self.updated_ratio.emit(1) - - elif filepath: - # mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( - # (adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3) - # hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( - # (adj_size, adj_size), resample=Image.Resampling.BILINEAR) + if isLoading or filepath: + adj_size = math.ceil(max(base_size[0], base_size[1]) * pixelRatio) - extension = os.path.splitext(filepath)[1][1:].lower() + if isLoading: + adj_size = math.ceil((512 * pixelRatio)) + final: Image.Image = ThumbRenderer.thumb_loading_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) + qim = ImageQt.ImageQt(final) + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixelRatio) + self.updated_ratio.emit(1) - try: - # Images ======================================================= - if extension in IMAGE_TYPES: - image = Image.open(filepath) - # image = self.thumb_debug - if image.mode == 'RGBA': - # logging.info(image.getchannel(3).tobytes()) - new_bg = Image.new('RGB', image.size, color='#222222') - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - if image.mode != 'RGB': - image = image.convert(mode='RGB') - - image = ImageOps.exif_transpose(image) + elif filepath: + # mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( + # (adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3) + # hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( + # (adj_size, adj_size), resample=Image.Resampling.BILINEAR) - # Videos ======================================================= - elif extension in VIDEO_TYPES: - video = cv2.VideoCapture(filepath) - video.set(cv2.CAP_PROP_POS_FRAMES, - (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2)) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - # Plain Text =================================================== - elif extension in PLAINTEXT_TYPES: - try: - text: str = extension - with open(filepath, 'r', encoding='utf-8') as text_file: - text = text_file.read(256) - bg = Image.new('RGB',(256,256), color='#222222') - draw = ImageDraw.Draw(bg) - draw.text((16,16), text, file=(255,255,255)) - image = bg - except: - logging.info(f'[ThumbRenderer][ERROR]: Coulnd\'t render thumbnail for {filepath}') - # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) + extension = os.path.splitext(filepath)[1][1:].lower() - if not image: - raise UnidentifiedImageError + try: + # Images ======================================================= + if extension in IMAGE_TYPES: + image = Image.open(filepath) + # image = self.thumb_debug + if image.mode == "RGBA": + # logging.info(image.getchannel(3).tobytes()) + new_bg = Image.new("RGB", image.size, color="#222222") + new_bg.paste(image, mask=image.getchannel(3)) + image = new_bg + if image.mode != "RGB": + image = image.convert(mode="RGB") - orig_x, orig_y = image.size - if orig_x < adj_size and orig_y < adj_size: - new_x, new_y = (adj_size, adj_size) - if orig_x > orig_y: - new_x = adj_size - new_y = math.ceil(adj_size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = adj_size - new_x = math.ceil(adj_size * (orig_x / orig_y)) - else: - new_x, new_y = (adj_size, adj_size) - if orig_x > orig_y: - new_x = adj_size - new_y = math.ceil(adj_size * (orig_y / orig_x)) - elif orig_y > orig_x: - new_y = adj_size - new_x = math.ceil(adj_size * (orig_x / orig_y)) + image = ImageOps.exif_transpose(image) - self.updated_ratio.emit(new_x / new_y) - image = image.resize( - (new_x, new_y), resample=Image.Resampling.BILINEAR) + # Videos ======================================================= + elif extension in VIDEO_TYPES: + video = cv2.VideoCapture(filepath) + video.set( + cv2.CAP_PROP_POS_FRAMES, + (video.get(cv2.CAP_PROP_FRAME_COUNT) // 2), + ) + success, frame = video.read() + if not success: + # Depending on the video format, compression, and frame + # count, seeking halfway does not work and the thumb + # must be pulled from the earliest available frame. + video.set(cv2.CAP_PROP_POS_FRAMES, 0) + success, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.fromarray(frame) + # Plain Text =================================================== + elif extension in PLAINTEXT_TYPES: + try: + text: str = extension + with open(filepath, "r", encoding="utf-8") as text_file: + text = text_file.read(256) + bg = Image.new("RGB", (256, 256), color="#222222") + draw = ImageDraw.Draw(bg) + draw.text((16, 16), text, file=(255, 255, 255)) + image = bg + except: + logging.info( + f"[ThumbRenderer][ERROR]: Coulnd't render thumbnail for {filepath}" + ) + # No Rendered Thumbnail ======================================== + else: + image = ThumbRenderer.thumb_file_default_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) - # image = image.resize( - # (new_x, new_y), resample=Image.Resampling.BILINEAR) + if not image: + raise UnidentifiedImageError - # if image.size != (adj_size, adj_size): - # # Old 1 color method. - # # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) - # # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) - # # bg.thumbnail((1, 1)) - # # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) + orig_x, orig_y = image.size + if orig_x < adj_size and orig_y < adj_size: + new_x, new_y = (adj_size, adj_size) + if orig_x > orig_y: + new_x = adj_size + new_y = math.ceil(adj_size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = adj_size + new_x = math.ceil(adj_size * (orig_x / orig_y)) + else: + new_x, new_y = (adj_size, adj_size) + if orig_x > orig_y: + new_x = adj_size + new_y = math.ceil(adj_size * (orig_y / orig_x)) + elif orig_y > orig_x: + new_y = adj_size + new_x = math.ceil(adj_size * (orig_x / orig_y)) - # # Small gradient background. Looks decent, and is only a one-liner. - # # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR) + self.updated_ratio.emit(new_x / new_y) + image = image.resize((new_x, new_y), resample=Image.Resampling.BILINEAR) - # # Four-Corner Gradient Background. - # # Not exactly a one-liner, but it's (subjectively) really cool. - # tl = image.getpixel((0, 0)) - # tr = image.getpixel(((image.size[0]-1), 0)) - # bl = image.getpixel((0, (image.size[1]-1))) - # br = image.getpixel(((image.size[0]-1), (image.size[1]-1))) - # bg = Image.new(mode='RGB', size=(2, 2)) - # bg.paste(tl, (0, 0, 2, 2)) - # bg.paste(tr, (1, 0, 2, 2)) - # bg.paste(bl, (0, 1, 2, 2)) - # bg.paste(br, (1, 1, 2, 2)) - # bg = bg.resize((adj_size, adj_size), - # resample=Image.Resampling.BICUBIC) + # image = image.resize( + # (new_x, new_y), resample=Image.Resampling.BILINEAR) - # bg.paste(image, box=( - # (adj_size-image.size[0])//2, (adj_size-image.size[1])//2)) + # if image.size != (adj_size, adj_size): + # # Old 1 color method. + # # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) + # # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) + # # bg.thumbnail((1, 1)) + # # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) - # bg.putalpha(mask) - # final = bg + # # Small gradient background. Looks decent, and is only a one-liner. + # # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR) - # else: - # image.putalpha(mask) - # final = image + # # Four-Corner Gradient Background. + # # Not exactly a one-liner, but it's (subjectively) really cool. + # tl = image.getpixel((0, 0)) + # tr = image.getpixel(((image.size[0]-1), 0)) + # bl = image.getpixel((0, (image.size[1]-1))) + # br = image.getpixel(((image.size[0]-1), (image.size[1]-1))) + # bg = Image.new(mode='RGB', size=(2, 2)) + # bg.paste(tl, (0, 0, 2, 2)) + # bg.paste(tr, (1, 0, 2, 2)) + # bg.paste(bl, (0, 1, 2, 2)) + # bg.paste(br, (1, 1, 2, 2)) + # bg = bg.resize((adj_size, adj_size), + # resample=Image.Resampling.BICUBIC) - # hl_soft = hl.copy() - # hl_soft.putalpha(ImageEnhance.Brightness( - # hl.getchannel(3)).enhance(.5)) - # final.paste(ImageChops.soft_light(final, hl_soft), - # mask=hl_soft.getchannel(3)) + # bg.paste(image, box=( + # (adj_size-image.size[0])//2, (adj_size-image.size[1])//2)) - # hl_add = hl.copy() - # hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25)) - # final.paste(hl_add, mask=hl_add.getchannel(3)) - scalar = 4 - rec: Image.Image = Image.new('RGB', tuple( - [d * scalar for d in image.size]), 'black') - draw = ImageDraw.Draw(rec) - draw.rounded_rectangle( - (0, 0)+rec.size, (base_size[0]//32) * scalar * pixelRatio, fill='red') - rec = rec.resize( - tuple([d // scalar for d in rec.size]), resample=Image.Resampling.BILINEAR) - # final = image - final = Image.new('RGBA', image.size, (0, 0, 0, 0)) - # logging.info(rec.size) - # logging.info(image.size) - final.paste(image, mask=rec.getchannel(0)) + # bg.putalpha(mask) + # final = bg - except (UnidentifiedImageError, FileNotFoundError, cv2.error): - broken_thumb = True - self.updated_ratio.emit(1) - final = ThumbRenderer.thumb_broken_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR) + # else: + # image.putalpha(mask) + # final = image - # if extension in VIDEO_TYPES + ['gif', 'apng'] or broken_thumb: - # idk = ImageDraw.Draw(final) - # # idk.textlength(file_type) - # ext_offset_x = idk.textlength( - # text=extension.upper(), font=ThumbRenderer.ext_font) / 2 - # ext_offset_x = math.floor(ext_offset_x * (1/pixelRatio)) - # x_margin = math.floor( - # (adj_size-((base_size[0]//6)+ext_offset_x) * pixelRatio)) - # y_margin = math.floor( - # (adj_size-((base_size[0]//8)) * pixelRatio)) - # stroke_width = round(2 * pixelRatio) - # fill = 'white' if not broken_thumb else '#E32B41' - # idk.text((x_margin, y_margin), extension.upper( - # ), fill=fill, font=ThumbRenderer.ext_font, stroke_width=stroke_width, stroke_fill=(0, 0, 0)) + # hl_soft = hl.copy() + # hl_soft.putalpha(ImageEnhance.Brightness( + # hl.getchannel(3)).enhance(.5)) + # final.paste(ImageChops.soft_light(final, hl_soft), + # mask=hl_soft.getchannel(3)) - qim = ImageQt.ImageQt(final) - if image: - image.close() - pixmap = QPixmap.fromImage(qim) - pixmap.setDevicePixelRatio(pixelRatio) + # hl_add = hl.copy() + # hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25)) + # final.paste(hl_add, mask=hl_add.getchannel(3)) + scalar = 4 + rec: Image.Image = Image.new( + "RGB", tuple([d * scalar for d in image.size]), "black" + ) + draw = ImageDraw.Draw(rec) + draw.rounded_rectangle( + (0, 0) + rec.size, + (base_size[0] // 32) * scalar * pixelRatio, + fill="red", + ) + rec = rec.resize( + tuple([d // scalar for d in rec.size]), + resample=Image.Resampling.BILINEAR, + ) + # final = image + final = Image.new("RGBA", image.size, (0, 0, 0, 0)) + # logging.info(rec.size) + # logging.info(image.size) + final.paste(image, mask=rec.getchannel(0)) - if pixmap: - # logging.info(final.size) - # self.updated.emit(pixmap, QSize(*final.size)) - self.updated.emit(timestamp, pixmap, QSize(math.ceil( - adj_size * 1/pixelRatio), math.ceil(final.size[1] * 1/pixelRatio)), extension) + except (UnidentifiedImageError, FileNotFoundError, cv2.error): + broken_thumb = True + self.updated_ratio.emit(1) + final = ThumbRenderer.thumb_broken_512.resize( + (adj_size, adj_size), resample=Image.Resampling.BILINEAR + ) - else: - self.updated.emit(timestamp, QPixmap(), - QSize(*base_size), extension) + # if extension in VIDEO_TYPES + ['gif', 'apng'] or broken_thumb: + # idk = ImageDraw.Draw(final) + # # idk.textlength(file_type) + # ext_offset_x = idk.textlength( + # text=extension.upper(), font=ThumbRenderer.ext_font) / 2 + # ext_offset_x = math.floor(ext_offset_x * (1/pixelRatio)) + # x_margin = math.floor( + # (adj_size-((base_size[0]//6)+ext_offset_x) * pixelRatio)) + # y_margin = math.floor( + # (adj_size-((base_size[0]//8)) * pixelRatio)) + # stroke_width = round(2 * pixelRatio) + # fill = 'white' if not broken_thumb else '#E32B41' + # idk.text((x_margin, y_margin), extension.upper( + # ), fill=fill, font=ThumbRenderer.ext_font, stroke_width=stroke_width, stroke_fill=(0, 0, 0)) + + qim = ImageQt.ImageQt(final) + if image: + image.close() + pixmap = QPixmap.fromImage(qim) + pixmap.setDevicePixelRatio(pixelRatio) + + if pixmap: + # logging.info(final.size) + # self.updated.emit(pixmap, QSize(*final.size)) + self.updated.emit( + timestamp, + pixmap, + QSize( + math.ceil(adj_size * 1 / pixelRatio), + math.ceil(final.size[1] * 1 / pixelRatio), + ), + extension, + ) + + else: + self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension) diff --git a/tagstudio/tag_studio.py b/tagstudio/tag_studio.py index 54039eb..e98cf8c 100644 --- a/tagstudio/tag_studio.py +++ b/tagstudio/tag_studio.py @@ -17,43 +17,59 @@ def main(): # Parse arguments. parser = argparse.ArgumentParser() - parser.add_argument('--open', dest='open', type=str, - help='Path to a TagStudio Library folder to open on start.') - parser.add_argument('-o', dest='open', type=str, - help='Path to a TagStudio Library folder to open on start.') + parser.add_argument( + "--open", + dest="open", + type=str, + help="Path to a TagStudio Library folder to open on start.", + ) + parser.add_argument( + "-o", + dest="open", + type=str, + help="Path to a TagStudio Library folder to open on start.", + ) # parser.add_argument('--browse', dest='browse', action='store_true', # help='Jumps to entry browsing on startup.') # parser.add_argument('--external_preview', dest='external_preview', action='store_true', # help='Outputs current preview thumbnail to a live-updating file.') - parser.add_argument('--debug', dest='debug', action='store_true', - help='Reveals additional internal data useful for debugging.') - parser.add_argument('--ui', dest='ui', type=str, - help='User interface option for TagStudio. Options: qt, cli (Default: qt)') + parser.add_argument( + "--debug", + dest="debug", + action="store_true", + help="Reveals additional internal data useful for debugging.", + ) + parser.add_argument( + "--ui", + dest="ui", + type=str, + help="User interface option for TagStudio. Options: qt, cli (Default: qt)", + ) args = parser.parse_args() core = TagStudioCore() # The TagStudio Core instance. UI agnostic. - driver = None # The UI driver instance. - ui_name: str = 'unknown' # Display name for the UI, used in logs. + driver = None # The UI driver instance. + ui_name: str = "unknown" # Display name for the UI, used in logs. # Driver selection based on parameters. - if args.ui and args.ui == 'qt': + if args.ui and args.ui == "qt": driver = QtDriver(core, args) - ui_name='Qt' - elif args.ui and args.ui == 'cli': + ui_name = "Qt" + elif args.ui and args.ui == "cli": driver = CliDriver(core, args) - ui_name='CLI' + ui_name = "CLI" else: driver = QtDriver(core, args) - ui_name='Qt' + ui_name = "Qt" # Run the chosen frontend driver. try: driver.start() except Exception: traceback.print_exc() - print(f'\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...') + print(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...") input() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tagstudio/tests/core/test_tags.py b/tagstudio/tests/core/test_tags.py index 33c8e89..f3cd579 100644 --- a/tagstudio/tests/core/test_tags.py +++ b/tagstudio/tests/core/test_tags.py @@ -3,10 +3,16 @@ from src.core.library import Tag class TestTags: def test_construction(self): - tag = Tag(id=1, name='Tag Name', shorthand='TN', aliases=[ - 'First A', 'Second A'], subtags_ids=[2, 3, 4], color='') - assert (tag) + tag = Tag( + id=1, + name="Tag Name", + shorthand="TN", + aliases=["First A", "Second A"], + subtags_ids=[2, 3, 4], + color="", + ) + assert tag def test_empty_construction(self): - tag = Tag(id=1, name='', shorthand='', aliases=[], subtags_ids=[], color='') - assert (tag) \ No newline at end of file + tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="") + assert tag