mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-31 15:19:10 +00:00
Compare commits
9 Commits
v9.5.0-pr2
...
v9.5.0-pr3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986ccabc81 | ||
|
|
297fdf22e8 | ||
|
|
319ef9a5fe | ||
|
|
6b646f8955 | ||
|
|
a2b9237be4 | ||
|
|
abc7cc3915 | ||
|
|
a3df70bb8d | ||
|
|
466af1e6a6 | ||
|
|
26d3b1908b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -88,9 +88,7 @@ profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
|
||||
@@ -44,15 +44,30 @@ If you wish to launch the source version of TagStudio outside of your IDE:
|
||||
> [!TIP]
|
||||
> On Linux and macOS, you can launch the `tagstudio.sh` script to skip the following process, minus the `requirements-dev.txt` installation step. _Using the script is fine if you just want to launch the program from source._
|
||||
|
||||
1. In the root repository directory, create a python virtual environment:
|
||||
1. Make sure you're using the correct Python version:
|
||||
- If the output matches `Python 3.12.x` (where the x is any number) then you're using the correct Python version and can skip to step 2. Otherwise, you can install the correct Python version from the [Python](https://www.python.org/downloads/) website, or you can use a tool like [pyenv](https://github.com/pyenv/pyenv/) to install the correct version without changes to your system:
|
||||
1. Follow pyenv's [install instructions](https://github.com/pyenv/pyenv/?tab=readme-ov-file#installation) for your system.
|
||||
2. Install the appropriate Python version with pyenv by running `pyenv install 3.12` (This will **not** mess with your existing Python installation).
|
||||
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.12`.
|
||||
- You could alternatively use `pyenv shell 3.12` or `pyenv global 3.12` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
|
||||
|
||||
2. In the root repository directory, create a python virtual environment:
|
||||
`python3 -m venv .venv`
|
||||
2. Activate your environment:
|
||||
3. Activate your environment:
|
||||
|
||||
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
|
||||
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
|
||||
- Linux/macOS: `source .venv/bin/activate`
|
||||
Depending on your system, the regular activation script *might* not work on alternative shells. In this case, refer to the table below for supported shells:
|
||||
|Shell |Script |
|
||||
|-------:|:------------------------|
|
||||
|Bash/ZSH|`.venv/bin/activate` |
|
||||
|Fish |`.venv/bin/activate.fish`|
|
||||
|CSH/TCSH|`.venv/bin/activate.csh` |
|
||||
|PWSH |`.venv/bin/activate.ps1` |
|
||||
|
||||
|
||||
3. Install the required packages:
|
||||
4. Install the required packages:
|
||||
|
||||
- `pip install -r requirements.txt`
|
||||
- If developing (includes Ruff and Mypy): `pip install -r requirements-dev.txt`
|
||||
@@ -61,6 +76,8 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or
|
||||
|
||||
### Manually Launching (Outside of an IDE)
|
||||
|
||||
If you encounter errors about the Python version, or seemingly vague script errors, [pyenv](https://github.com/pyenv/pyenv/) may solve your issue. See step 1 of [Creating a Python Virtual Environment](#creating-a-python-virtual-environment).
|
||||
|
||||
- **Windows** (start_win.bat)
|
||||
|
||||
- To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.5.0-pre2"
|
||||
version = "9.5.0-pre3"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ PySide6_Addons==6.8.0.1
|
||||
PySide6_Essentials==6.8.0.1
|
||||
PySide6==6.8.0.1
|
||||
rawpy==0.22.0
|
||||
Send2Trash==1.8.3
|
||||
SQLAlchemy==2.0.34
|
||||
structlog==24.4.0
|
||||
typing_extensions>=3.10.0.0,<=4.11.0
|
||||
|
||||
@@ -78,7 +78,7 @@ app = BUNDLE(
|
||||
name='TagStudio.app',
|
||||
icon=icon,
|
||||
bundle_identifier='com.cyanvoxel.tagstudio',
|
||||
version='9.5.0-pr2',
|
||||
version='9.5.0-pr3',
|
||||
info_plist={
|
||||
'NSAppleScriptEnabled': False,
|
||||
'NSPrincipalClass': 'NSApplication',
|
||||
|
||||
BIN
tagstudio/resources/qt/videos/placeholder.mp4
Normal file
BIN
tagstudio/resources/qt/videos/placeholder.mp4
Normal file
Binary file not shown.
@@ -1 +1,13 @@
|
||||
{}
|
||||
{
|
||||
"app.pre_release": "Forududgivelse",
|
||||
"color.title.no_color": "Ingen Farve",
|
||||
"drop_import.description": "De følgende filer har allerede eksisterende stier i biblioteket",
|
||||
"drop_import.duplicates_choice.plural": "Følgende {mængde} filer passer allerede til stier der eksistere i biblioteket.",
|
||||
"drop_import.duplicates_choice.singular": "Den følgende fil matcher en allerede eksisterende sti i biblioteket.",
|
||||
"drop_import.progress.label.initial": "Importere nye filer...",
|
||||
"drop_import.progress.label.plural": "Importere nye filer...\n{mængde] Filer importeret.{suffiks}",
|
||||
"drop_import.progress.label.singular": "Importere nye filer...\n1 fil importeret.{suffiks}",
|
||||
"drop_import.progress.window_title": "Importer Filer",
|
||||
"drop_import.title": "Konflikterende Fil(er)",
|
||||
"edit.tag_manager": "Håndtere Tags"
|
||||
}
|
||||
|
||||
@@ -157,6 +157,9 @@
|
||||
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
|
||||
"macros.running.dialog.title": "Running Macros on New Entries",
|
||||
"media_player.autoplay": "Autoplay",
|
||||
"menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Move Files to {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Move File to {trash_term}",
|
||||
"menu.edit.ignore_list": "Ignore Files and Folders",
|
||||
"menu.edit.manage_file_extensions": "Manage File Extensions",
|
||||
"menu.edit.manage_tags": "Manage Tags",
|
||||
@@ -195,6 +198,11 @@
|
||||
"sorting.direction.ascending": "Ascending",
|
||||
"sorting.direction.descending": "Descending",
|
||||
"splash.opening_library": "Opening Library \"{library_path}\"...",
|
||||
"status.deleted_file_plural": "Deleted {count} files!",
|
||||
"status.deleted_file_singular": "Deleted 1 file!",
|
||||
"status.deleted_none": "No files deleted.",
|
||||
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
|
||||
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
|
||||
"status.library_backup_in_progress": "Saving Library Backup...",
|
||||
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
|
||||
"status.library_closed": "Library Closed ({time_span})",
|
||||
@@ -212,6 +220,7 @@
|
||||
"tag.add.plural": "Add Tags",
|
||||
"tag.add": "Add Tag",
|
||||
"tag.aliases": "Aliases",
|
||||
"tag.all_tags": "All Tags",
|
||||
"tag.choose_color": "Choose Tag Color",
|
||||
"tag.color": "Color",
|
||||
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
|
||||
@@ -228,6 +237,19 @@
|
||||
"tag.search_for_tag": "Search for Tag",
|
||||
"tag.shorthand": "Shorthand",
|
||||
"tag.tag_name_required": "Tag Name (Required)",
|
||||
"tag.view_limit": "View Limit:",
|
||||
"trash.context.ambiguous": "Move file(s) to {trash_term}",
|
||||
"trash.context.plural": "Move files to {trash_term}",
|
||||
"trash.context.singular": "Move file to {trash_term}",
|
||||
"trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio <i>AND</i> your file system!",
|
||||
"trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio <i>AND</i> your file system!",
|
||||
"trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
|
||||
"trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
|
||||
"trash.dialog.permanent_delete_warning": "<b>WARNING!</b> If this file can't be moved to the {trash_term}, <b>it will be <b>permanently deleted!</b>",
|
||||
"trash.dialog.title.plural": "Delete Files",
|
||||
"trash.dialog.title.singular": "Delete File",
|
||||
"trash.name.generic": "Trash",
|
||||
"trash.name.windows": "Recycle Bin",
|
||||
"view.size.0": "Mini",
|
||||
"view.size.1": "Small",
|
||||
"view.size.2": "Medium",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"drop_import.progress.label.singular": "Új fájlok importálása folyamatban…\n1 fájl importálva.{suffix}",
|
||||
"drop_import.progress.window_title": "Fájlok importálása",
|
||||
"drop_import.title": "Fájlütközés",
|
||||
"edit.copy_fields": "Mezők másolása",
|
||||
"edit.paste_fields": "Mezők beillesztése",
|
||||
"edit.tag_manager": "Címkék kezelése",
|
||||
"entries.duplicate.merge": "Egyező elemek &egyesítése",
|
||||
"entries.duplicate.merge.label": "Egyező elemek egyesítése folyamatban…",
|
||||
@@ -203,6 +205,7 @@
|
||||
"status.library_version_found": "Tényleges érték:",
|
||||
"status.library_version_mismatch": "A könyvtár és a program verziója nem egyezik.",
|
||||
"status.results": "találat",
|
||||
"status.results.invalid_syntax": "Szintaktikai hiba:",
|
||||
"status.results_found": "{count} találat ({time_span})",
|
||||
"tag.add": "Címke hozzáadása",
|
||||
"tag.add.plural": "Címkék hozzáadása",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
VERSION: str = "9.5.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "Pre-Release 2" # Usually "" or "Pre-Release"
|
||||
VERSION_BRANCH: str = "Pre-Release 3" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from os import makedirs
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
from warnings import catch_warnings
|
||||
|
||||
@@ -69,6 +70,10 @@ from .joins import TagEntry, TagParent
|
||||
from .models import Entry, Folder, Namespace, Preferences, Tag, TagAlias, TagColorGroup, ValueType
|
||||
from .visitors import SQLBoolExpressionBuilder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy import Select
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
@@ -259,7 +264,7 @@ class Library:
|
||||
for k, v in field.items():
|
||||
# Old tag fields get added as tags
|
||||
if k in LEGACY_TAG_FIELD_IDS:
|
||||
self.add_tags_to_entry(entry_id=entry.id + 1, tag_ids=v)
|
||||
self.add_tags_to_entries(entry_ids=entry.id + 1, tag_ids=v)
|
||||
else:
|
||||
self.add_field_to_entry(
|
||||
entry_id=(entry.id + 1), # JSON IDs start at 0 instead of 1
|
||||
@@ -513,30 +518,49 @@ class Library:
|
||||
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
|
||||
) -> Entry | None:
|
||||
"""Load entry and join with all joins and all tags."""
|
||||
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
|
||||
# those into a final Entry object (if using "with" args). This was done due to it being
|
||||
# much more efficient than the existing join query, however there likely exists a single
|
||||
# query that can accomplish the same task without exhibiting the same slowdown.
|
||||
with Session(self.engine) as session:
|
||||
statement = select(Entry).where(Entry.id == entry_id)
|
||||
tags: set[Tag] | None = None
|
||||
tag_stmt: Select[tuple[Tag]]
|
||||
entry_stmt = select(Entry).where(Entry.id == entry_id).limit(1)
|
||||
if with_fields:
|
||||
statement = (
|
||||
statement.outerjoin(Entry.text_fields)
|
||||
entry_stmt = (
|
||||
entry_stmt.outerjoin(Entry.text_fields)
|
||||
.outerjoin(Entry.datetime_fields)
|
||||
.options(selectinload(Entry.text_fields), selectinload(Entry.datetime_fields))
|
||||
)
|
||||
# if with_tags:
|
||||
# entry_stmt = entry_stmt.outerjoin(Entry.tags).options(selectinload(Entry.tags))
|
||||
if with_tags:
|
||||
statement = (
|
||||
statement.outerjoin(Entry.tags)
|
||||
.outerjoin(TagAlias)
|
||||
.options(
|
||||
selectinload(Entry.tags).options(
|
||||
joinedload(Tag.aliases),
|
||||
joinedload(Tag.parent_tags),
|
||||
)
|
||||
tag_stmt = select(Tag).where(
|
||||
and_(
|
||||
TagEntry.tag_id == Tag.id,
|
||||
TagEntry.entry_id == entry_id,
|
||||
)
|
||||
)
|
||||
entry = session.scalar(statement)
|
||||
|
||||
start_time = time.time()
|
||||
entry = session.scalar(entry_stmt)
|
||||
if with_tags:
|
||||
tags = set(session.scalars(tag_stmt)) # pyright: ignore [reportPossiblyUnboundVariable]
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f"[Library] Time it took to get entry: "
|
||||
f"{format_timespan(end_time-start_time, max_units=5)}",
|
||||
with_fields=with_fields,
|
||||
with_tags=with_tags,
|
||||
)
|
||||
if not entry:
|
||||
return None
|
||||
session.expunge(entry)
|
||||
make_transient(entry)
|
||||
|
||||
# Recombine the separately queried tags with the base entry object.
|
||||
if with_tags and tags:
|
||||
entry.tags = tags
|
||||
return entry
|
||||
|
||||
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
|
||||
@@ -692,13 +716,15 @@ class Library:
|
||||
with Session(self.engine) as session:
|
||||
return session.query(exists().where(Entry.path == path)).scalar()
|
||||
|
||||
def get_paths(self, glob: str | None = None) -> list[str]:
|
||||
def get_paths(self, glob: str | None = None, limit: int = -1) -> list[str]:
|
||||
path_strings: list[str] = []
|
||||
with Session(self.engine) as session:
|
||||
paths = session.scalars(select(Entry.path)).unique()
|
||||
if limit > 0:
|
||||
paths = session.scalars(select(Entry.path).limit(limit)).unique()
|
||||
else:
|
||||
paths = session.scalars(select(Entry.path)).unique()
|
||||
path_strings = list(map(lambda x: x.as_posix(), paths))
|
||||
|
||||
return path_strings
|
||||
return path_strings
|
||||
|
||||
def search_library(
|
||||
self,
|
||||
@@ -765,16 +791,16 @@ class Library:
|
||||
|
||||
return res
|
||||
|
||||
def search_tags(self, name: str | None) -> list[set[Tag]]:
|
||||
def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]:
|
||||
"""Return a list of Tag records matching the query."""
|
||||
tag_limit = 100
|
||||
|
||||
with Session(self.engine) as session:
|
||||
query = select(Tag).outerjoin(TagAlias)
|
||||
query = select(Tag).outerjoin(TagAlias).order_by(func.lower(Tag.name))
|
||||
query = query.options(
|
||||
selectinload(Tag.parent_tags),
|
||||
selectinload(Tag.aliases),
|
||||
).limit(tag_limit)
|
||||
)
|
||||
if limit > 0:
|
||||
query = query.limit(limit)
|
||||
|
||||
if name:
|
||||
query = query.where(
|
||||
@@ -806,6 +832,7 @@ class Library:
|
||||
logger.info(
|
||||
"searching tags",
|
||||
search=name,
|
||||
limit=limit,
|
||||
statement=str(query),
|
||||
results=len(res),
|
||||
)
|
||||
@@ -1088,41 +1115,49 @@ class Library:
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def add_tags_to_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
|
||||
"""Add one or more tags to an entry."""
|
||||
tag_ids = [tag_ids] if isinstance(tag_ids, int) else tag_ids
|
||||
def add_tags_to_entries(
|
||||
self, entry_ids: int | list[int], tag_ids: int | list[int] | set[int]
|
||||
) -> bool:
|
||||
"""Add one or more tags to one or more entries."""
|
||||
entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids
|
||||
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
for tag_id in tag_ids:
|
||||
try:
|
||||
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
for tag_id in tag_ids_:
|
||||
for entry_id in entry_ids_:
|
||||
try:
|
||||
session.add(TagEntry(tag_id=tag_id, entry_id=entry_id))
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.warning("[add_tags_to_entry]", warning=e)
|
||||
logger.warning("[Library][add_tags_to_entries]", warning=e)
|
||||
session.rollback()
|
||||
return False
|
||||
return True
|
||||
|
||||
def remove_tags_from_entry(self, entry_id: int, tag_ids: int | list[int] | set[int]) -> bool:
|
||||
"""Remove one or more tags from an entry."""
|
||||
def remove_tags_from_entries(
|
||||
self, entry_ids: int | list[int], tag_ids: int | list[int] | set[int]
|
||||
) -> bool:
|
||||
"""Remove one or more tags from one or more entries."""
|
||||
entry_ids_ = [entry_ids] if isinstance(entry_ids, int) else entry_ids
|
||||
tag_ids_ = [tag_ids] if isinstance(tag_ids, int) else tag_ids
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
for tag_id in tag_ids_:
|
||||
tag_entry = session.scalars(
|
||||
select(TagEntry).where(
|
||||
and_(
|
||||
TagEntry.tag_id == tag_id,
|
||||
TagEntry.entry_id == entry_id,
|
||||
for entry_id in entry_ids_:
|
||||
tag_entry = session.scalars(
|
||||
select(TagEntry).where(
|
||||
and_(
|
||||
TagEntry.tag_id == tag_id,
|
||||
TagEntry.entry_id == entry_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if tag_entry:
|
||||
session.delete(tag_entry)
|
||||
session.commit()
|
||||
).first()
|
||||
if tag_entry:
|
||||
session.delete(tag_entry)
|
||||
session.flush()
|
||||
session.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
@@ -1330,7 +1365,7 @@ class Library:
|
||||
value=field.value,
|
||||
)
|
||||
tag_ids = [tag.id for tag in from_entry.tags]
|
||||
self.add_tags_to_entry(into_entry.id, tag_ids)
|
||||
self.add_tags_to_entries(into_entry.id, tag_ids)
|
||||
self.remove_entries([from_entry.id])
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import ColumnElement, and_, distinct, func, or_, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.operators import ilike_op
|
||||
from src.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
|
||||
from src.core.query_lang import BaseVisitor
|
||||
from src.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, ORList, Property
|
||||
@@ -14,7 +16,7 @@ from src.core.query_lang.ast import ANDList, Constraint, ConstraintType, Not, OR
|
||||
from .joins import TagEntry
|
||||
from .models import Entry, Tag, TagAlias
|
||||
|
||||
# workaround to have autocompletion in the Editor
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
from .library import Library
|
||||
else:
|
||||
@@ -97,7 +99,29 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
|
||||
elif node.type == ConstraintType.TagID:
|
||||
return self.__entry_matches_tag_ids([int(node.value)])
|
||||
elif node.type == ConstraintType.Path:
|
||||
return Entry.path.op("GLOB")(node.value)
|
||||
ilike = False
|
||||
glob = False
|
||||
|
||||
# Smartcase check
|
||||
if node.value == node.value.lower():
|
||||
ilike = True
|
||||
if node.value.startswith("*") or node.value.endswith("*"):
|
||||
glob = True
|
||||
|
||||
if ilike and glob:
|
||||
logger.info("ConstraintType.Path", ilike=True, glob=True)
|
||||
return func.lower(Entry.path).op("GLOB")(f"{node.value.lower()}")
|
||||
elif ilike:
|
||||
logger.info("ConstraintType.Path", ilike=True, glob=False)
|
||||
return ilike_op(Entry.path, f"%{node.value}%")
|
||||
elif glob:
|
||||
logger.info("ConstraintType.Path", ilike=False, glob=True)
|
||||
return Entry.path.op("GLOB")(node.value)
|
||||
else:
|
||||
logger.info(
|
||||
"ConstraintType.Path", ilike=False, glob=False, re=re.escape(node.value)
|
||||
)
|
||||
return Entry.path.regexp_match(re.escape(node.value))
|
||||
elif node.type == ConstraintType.MediaType:
|
||||
extensions: set[str] = set[str]()
|
||||
for media_cat in MediaCategories.ALL_CATEGORIES:
|
||||
|
||||
30
tagstudio/src/qt/helpers/file_deleter.py
Normal file
30
tagstudio/src/qt/helpers/file_deleter.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from send2trash import send2trash
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
def delete_file(path: str | Path) -> bool:
|
||||
"""Send a file to the system trash.
|
||||
|
||||
Args:
|
||||
path (str | Path): The path of the file to delete.
|
||||
"""
|
||||
_path = Path(path)
|
||||
try:
|
||||
logging.info(f"[delete_file] Sending to Trash: {_path}")
|
||||
send2trash(_path)
|
||||
return True
|
||||
except PermissionError as e:
|
||||
logging.error(f"[delete_file][ERROR] PermissionError: {e}")
|
||||
except FileNotFoundError:
|
||||
logging.error(f"[delete_file][ERROR] File Not Found: {_path}")
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return False
|
||||
@@ -1,8 +1,10 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
@@ -16,7 +18,10 @@ from PySide6.QtWidgets import (
|
||||
from src.core.library import Library
|
||||
from src.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# NOTE: This class doesn't inherit from PanelWidget? Seems like it predates that system?
|
||||
class AddFieldModal(QWidget):
|
||||
done = Signal(list)
|
||||
|
||||
@@ -35,11 +40,7 @@ class AddFieldModal(QWidget):
|
||||
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.setStyleSheet("font-weight:bold;" "font-size:14px;" "padding-top: 6px;")
|
||||
Translations.translate_qobject(self.title_widget, "library.field.add")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
@@ -50,18 +51,13 @@ class AddFieldModal(QWidget):
|
||||
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 = QPushButton()
|
||||
Translations.translate_qobject(self.cancel_button, "generic.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()
|
||||
Translations.translate_qobject(self.save_button, "generic.add")
|
||||
# self.save_button.setAutoDefault(True)
|
||||
self.save_button.setDefault(True)
|
||||
self.save_button.clicked.connect(self.hide)
|
||||
self.save_button.clicked.connect(
|
||||
@@ -74,8 +70,6 @@ class AddFieldModal(QWidget):
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.list_widget)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
@@ -85,5 +79,13 @@ class AddFieldModal(QWidget):
|
||||
item = QListWidgetItem(f"{df.name} ({df.type.value})")
|
||||
item.setData(Qt.ItemDataRole.UserRole, df.key)
|
||||
self.list_widget.addItem(item)
|
||||
self.list_widget.setFocus()
|
||||
|
||||
super().show()
|
||||
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.cancel_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import typing
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt, QThreadPool, Signal
|
||||
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -20,7 +21,7 @@ from src.qt.translations import Translations
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
@@ -111,3 +112,11 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
||||
self.done.emit(),
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.cancel_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Copyright (C) 2025
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import enum
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt, QUrl
|
||||
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -232,3 +234,11 @@ class DropImportModal(QWidget):
|
||||
)
|
||||
index += 1
|
||||
return filepath.name
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.cancel_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import typing
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
@@ -20,7 +21,7 @@ from src.qt.modals.mirror_entities import MirrorEntriesModal
|
||||
from src.qt.translations import Translations
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
@@ -135,3 +136,11 @@ class FixDupeFilesModal(QWidget):
|
||||
self.dupe_count.setText(
|
||||
Translations.translate_formatted("file.duplicates.matches", count=count)
|
||||
)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.done_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import typing
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
from src.core.library import Library
|
||||
@@ -16,7 +17,7 @@ from src.qt.translations import Translations
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
@@ -144,3 +145,11 @@ class FixUnlinkedEntriesModal(QWidget):
|
||||
"entries.unlinked.missing_count.some", count=self.missing_count
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.done_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
|
||||
import math
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
@@ -25,7 +27,7 @@ from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.translations import Translations
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -73,7 +75,7 @@ def folders_to_tags(library: Library):
|
||||
|
||||
tag = add_folders_to_tree(library, tree, folders).tag
|
||||
if tag and not entry.has_tag(tag):
|
||||
library.add_tags_to_entry(entry.id, tag.id)
|
||||
library.add_tags_to_entries(entry.id, tag.id)
|
||||
|
||||
logger.info("Done")
|
||||
|
||||
@@ -104,7 +106,7 @@ def generate_preview_data(library: Library) -> BranchData:
|
||||
branch.dirs[tag.name] = BranchData(tag=tag)
|
||||
branch = branch.dirs[tag.name]
|
||||
|
||||
def _add_folders_to_tree(items: typing.Sequence[str]) -> BranchData:
|
||||
def _add_folders_to_tree(items: Sequence[str]) -> BranchData:
|
||||
branch = tree
|
||||
for folder in items:
|
||||
if folder not in branch.dirs:
|
||||
@@ -245,6 +247,14 @@ class FoldersToTagsModal(QWidget):
|
||||
if isinstance(child, TreeItem):
|
||||
child.set_all_branches(hidden)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.close()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
|
||||
class TreeItem(QWidget):
|
||||
def __init__(self, data: BranchData, parent_tag: Tag | None = None):
|
||||
|
||||
@@ -18,19 +18,19 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
# TODO: Once this class is removed, the `is_tag_chooser` option of `TagSearchPanel`
|
||||
# will most likely be enabled in every case
|
||||
# and the possibilty of disabling it can therefore be removed
|
||||
# and the possibility of disabling it can therefore be removed
|
||||
|
||||
|
||||
class TagDatabasePanel(TagSearchPanel):
|
||||
def __init__(self, library: Library):
|
||||
def __init__(self, driver, library: Library):
|
||||
super().__init__(library, is_tag_chooser=False)
|
||||
self.driver = driver
|
||||
|
||||
self.create_tag_button = QPushButton()
|
||||
Translations.translate_qobject(self.create_tag_button, "tag.create")
|
||||
self.create_tag_button.clicked.connect(lambda: self.build_tag(self.search_field.text()))
|
||||
|
||||
self.root_layout.addWidget(self.create_tag_button)
|
||||
self.update_tags()
|
||||
|
||||
def build_tag(self, name: str):
|
||||
panel = BuildTagPanel(self.lib)
|
||||
@@ -39,7 +39,7 @@ class TagDatabasePanel(TagSearchPanel):
|
||||
has_save=True,
|
||||
)
|
||||
Translations.translate_with_setter(self.modal.setTitle, "tag.new")
|
||||
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.add")
|
||||
Translations.translate_with_setter(self.modal.setWindowTitle, "tag.new")
|
||||
if name.strip():
|
||||
panel.name_field.setText(name)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import typing
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import src.qt.modals.build_tag as build_tag
|
||||
import structlog
|
||||
@@ -11,8 +13,10 @@ from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
@@ -21,7 +25,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
from src.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.library.alchemy.enums import TagColorEnum
|
||||
from src.core.library.alchemy.enums import FilterState, TagColorEnum
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
@@ -32,7 +36,7 @@ from src.qt.widgets.tag import (
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
|
||||
|
||||
@@ -44,6 +48,11 @@ class TagSearchPanel(PanelWidget):
|
||||
is_tag_chooser: bool
|
||||
exclude: list[int]
|
||||
|
||||
_limit_items: list[int | str] = [25, 50, 100, 250, 500, Translations["tag.all_tags"]]
|
||||
_default_limit_idx: int = 0 # 50 Tag Limit (Default)
|
||||
cur_limit_idx: int = _default_limit_idx
|
||||
tag_limit: int | str = _limit_items[_default_limit_idx]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
@@ -52,14 +61,37 @@ class TagSearchPanel(PanelWidget):
|
||||
):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = None
|
||||
self.exclude = exclude or []
|
||||
|
||||
self.is_tag_chooser = is_tag_chooser
|
||||
self.create_button_in_layout: bool = False
|
||||
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.limit_container = QWidget()
|
||||
self.limit_layout = QHBoxLayout(self.limit_container)
|
||||
self.limit_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.limit_layout.setSpacing(12)
|
||||
self.limit_layout.addStretch(1)
|
||||
|
||||
self.limit_title = QLabel()
|
||||
Translations.translate_qobject(self.limit_title, "tag.view_limit")
|
||||
self.limit_layout.addWidget(self.limit_title)
|
||||
|
||||
self.limit_combobox = QComboBox()
|
||||
self.limit_combobox.setEditable(False)
|
||||
self.limit_combobox.addItems([str(x) for x in TagSearchPanel._limit_items])
|
||||
self.limit_combobox.setCurrentIndex(TagSearchPanel._default_limit_idx)
|
||||
self.limit_combobox.currentIndexChanged.connect(self.update_limit)
|
||||
self.previous_limit: int = (
|
||||
TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
|
||||
)
|
||||
self.limit_layout.addWidget(self.limit_combobox)
|
||||
self.limit_layout.addStretch(1)
|
||||
|
||||
self.search_field = QLineEdit()
|
||||
self.search_field.setObjectName("searchField")
|
||||
self.search_field.setMinimumSize(QSize(0, 32))
|
||||
@@ -79,53 +111,19 @@ class TagSearchPanel(PanelWidget):
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
self.root_layout.addWidget(self.limit_container)
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
|
||||
def __build_tag_widget(self, tag: Tag):
|
||||
has_remove_button = False
|
||||
if not self.is_tag_chooser:
|
||||
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
|
||||
|
||||
tag_widget = TagWidget(
|
||||
tag,
|
||||
library=self.lib,
|
||||
has_edit=True,
|
||||
has_remove=has_remove_button,
|
||||
)
|
||||
|
||||
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
|
||||
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
|
||||
|
||||
# NOTE: A solution to this would be to pass the driver to TagSearchPanel, however that
|
||||
# creates an exponential amount of work trying to fix the preexisting tests.
|
||||
|
||||
# tag_widget.search_for_tag_action.triggered.connect(
|
||||
# lambda checked=False, tag_id=tag.id: (
|
||||
# self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
|
||||
# self.driver.filter_items(FilterState.from_tag_id(tag_id)),
|
||||
# )
|
||||
# )
|
||||
|
||||
tag_id = tag.id
|
||||
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
|
||||
return tag_widget
|
||||
|
||||
def build_create_tag_button(self, query: str | None):
|
||||
"""Constructs a Create Tag Button."""
|
||||
container = QWidget()
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
def set_driver(self, driver):
|
||||
"""Set the QtDriver for this search panel. Used for main window operations."""
|
||||
self.driver = driver
|
||||
|
||||
def build_create_button(self, query: str | None):
|
||||
"""Constructs a "Create & Add Tag" QPushButton."""
|
||||
create_button = QPushButton(self)
|
||||
Translations.translate_qobject(create_button, "tag.create_add", query=query)
|
||||
create_button.setFlat(True)
|
||||
|
||||
inner_layout = QHBoxLayout()
|
||||
inner_layout.setObjectName("innerLayout")
|
||||
inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
create_button.setLayout(inner_layout)
|
||||
create_button.setMinimumSize(22, 22)
|
||||
|
||||
create_button.setStyleSheet(
|
||||
@@ -156,10 +154,7 @@ class TagSearchPanel(PanelWidget):
|
||||
f"}}"
|
||||
)
|
||||
|
||||
create_button.clicked.connect(lambda: self.create_and_add_tag(query))
|
||||
row.addWidget(create_button)
|
||||
|
||||
return container
|
||||
return create_button
|
||||
|
||||
def create_and_add_tag(self, name: str):
|
||||
"""Opens "Create Tag" panel to create and add a new tag with given name."""
|
||||
@@ -188,26 +183,34 @@ class TagSearchPanel(PanelWidget):
|
||||
|
||||
self.build_tag_modal.name_field.setText(name)
|
||||
self.add_tag_modal.saved.connect(on_tag_modal_saved)
|
||||
self.add_tag_modal.save_button.setFocus()
|
||||
self.add_tag_modal.show()
|
||||
|
||||
def update_tags(self, query: str | None = None):
|
||||
logger.info("[Tag Search Super Class] Updating Tags")
|
||||
"""Update the tag list given a search query."""
|
||||
logger.info("[TagSearchPanel] Updating Tags")
|
||||
|
||||
# TODO: Look at recycling rather than deleting and re-initializing
|
||||
while self.scroll_layout.count():
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
# Remove the "Create & Add" button if one exists
|
||||
create_button: QPushButton | None = None
|
||||
if self.create_button_in_layout and self.scroll_layout.count():
|
||||
create_button = self.scroll_layout.takeAt(self.scroll_layout.count() - 1).widget() # type: ignore
|
||||
create_button.deleteLater()
|
||||
self.create_button_in_layout = False
|
||||
|
||||
# Get results for the search query
|
||||
query_lower = "" if not query else query.lower()
|
||||
tag_results: list[set[Tag]] = self.lib.search_tags(name=query)
|
||||
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
|
||||
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}
|
||||
# Only use the tag limit if it's an actual number (aka not "All Tags")
|
||||
tag_limit = TagSearchPanel.tag_limit if isinstance(TagSearchPanel.tag_limit, int) else -1
|
||||
tag_results: list[set[Tag]] = self.lib.search_tags(name=query, limit=tag_limit)
|
||||
if self.exclude:
|
||||
tag_results[0] = {t for t in tag_results[0] if t.id not in self.exclude}
|
||||
tag_results[1] = {t for t in tag_results[1] if t.id not in self.exclude}
|
||||
|
||||
# Sort and prioritize the results
|
||||
results_0 = list(tag_results[0])
|
||||
results_0.sort(key=lambda tag: tag.name.lower())
|
||||
results_1 = list(tag_results[1])
|
||||
results_1.sort(key=lambda tag: tag.name.lower())
|
||||
raw_results = list(results_0 + results_1)[:100]
|
||||
raw_results = list(results_0 + results_1)
|
||||
priority_results: set[Tag] = set()
|
||||
all_results: list[Tag] = []
|
||||
|
||||
@@ -219,18 +222,99 @@ class TagSearchPanel(PanelWidget):
|
||||
all_results = sorted(list(priority_results), key=lambda tag: len(tag.name)) + [
|
||||
r for r in raw_results if r not in priority_results
|
||||
]
|
||||
if tag_limit > 0:
|
||||
all_results = all_results[:tag_limit]
|
||||
|
||||
if all_results:
|
||||
self.first_tag_id = None
|
||||
self.first_tag_id = all_results[0].id if len(all_results) > 0 else all_results[0].id
|
||||
for tag in all_results:
|
||||
self.scroll_layout.addWidget(self.__build_tag_widget(tag))
|
||||
|
||||
else:
|
||||
self.first_tag_id = None
|
||||
|
||||
# Update every tag widget with the new search result data
|
||||
norm_previous = self.previous_limit if self.previous_limit > 0 else len(self.lib.tags)
|
||||
norm_limit = tag_limit if tag_limit > 0 else len(self.lib.tags)
|
||||
range_limit = max(norm_previous, norm_limit)
|
||||
for i in range(0, range_limit):
|
||||
tag = None
|
||||
with contextlib.suppress(IndexError):
|
||||
tag = all_results[i]
|
||||
self.set_tag_widget(tag=tag, index=i)
|
||||
self.previous_limit = tag_limit
|
||||
|
||||
# Add back the "Create & Add" button
|
||||
if query and query.strip():
|
||||
c = self.build_create_tag_button(query)
|
||||
self.scroll_layout.addWidget(c)
|
||||
cb: QPushButton = self.build_create_button(query)
|
||||
with catch_warnings(record=True):
|
||||
cb.clicked.disconnect()
|
||||
cb.clicked.connect(lambda: self.create_and_add_tag(query or ""))
|
||||
Translations.translate_qobject(cb, "tag.create_add", query=query)
|
||||
self.scroll_layout.addWidget(cb)
|
||||
self.create_button_in_layout = True
|
||||
|
||||
def set_tag_widget(self, tag: Tag | None, index: int):
|
||||
"""Set the tag of a tag widget at a specific index."""
|
||||
# Create any new tag widgets needed up to the given index
|
||||
if self.scroll_layout.count() <= index:
|
||||
while self.scroll_layout.count() <= index:
|
||||
new_tw = TagWidget(tag=None, has_edit=True, has_remove=True, library=self.lib)
|
||||
new_tw.setHidden(True)
|
||||
self.scroll_layout.addWidget(new_tw)
|
||||
|
||||
# Assign the tag to the widget at the given index.
|
||||
tag_widget: TagWidget = self.scroll_layout.itemAt(index).widget() # type: ignore
|
||||
tag_widget.set_tag(tag)
|
||||
|
||||
# Set tag widget viability and potentially return early
|
||||
tag_widget.setHidden(bool(not tag))
|
||||
if not tag:
|
||||
return
|
||||
|
||||
# Configure any other aspects of the tag widget
|
||||
has_remove_button = False
|
||||
if not self.is_tag_chooser:
|
||||
has_remove_button = tag.id not in range(RESERVED_TAG_START, RESERVED_TAG_END)
|
||||
tag_widget.has_remove = has_remove_button
|
||||
|
||||
with catch_warnings(record=True):
|
||||
tag_widget.on_edit.disconnect()
|
||||
tag_widget.on_remove.disconnect()
|
||||
tag_widget.bg_button.clicked.disconnect()
|
||||
|
||||
tag_id = tag.id
|
||||
tag_widget.on_edit.connect(lambda t=tag: self.edit_tag(t))
|
||||
tag_widget.on_remove.connect(lambda t=tag: self.remove_tag(t))
|
||||
tag_widget.bg_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
|
||||
|
||||
if self.driver:
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda checked=False, tag_id=tag.id: (
|
||||
self.driver.main_window.searchField.setText(f"tag_id:{tag_id}"),
|
||||
self.driver.filter_items(FilterState.from_tag_id(tag_id)),
|
||||
)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
else:
|
||||
tag_widget.search_for_tag_action.setEnabled(False)
|
||||
|
||||
def update_limit(self, index: int):
|
||||
logger.info("[TagSearchPanel] Updating tag limit")
|
||||
TagSearchPanel.cur_limit_idx = index
|
||||
|
||||
if index < len(self._limit_items) - 1:
|
||||
TagSearchPanel.tag_limit = int(self._limit_items[index])
|
||||
else:
|
||||
TagSearchPanel.tag_limit = -1
|
||||
|
||||
# Method was called outside the limit_combobox callback
|
||||
if index != self.limit_combobox.currentIndex():
|
||||
self.limit_combobox.setCurrentIndex(index)
|
||||
|
||||
if self.previous_limit == TagSearchPanel.tag_limit:
|
||||
return
|
||||
|
||||
self.update_tags(self.search_field.text())
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text:
|
||||
@@ -246,21 +330,23 @@ class TagSearchPanel(PanelWidget):
|
||||
self.parentWidget().hide()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa N802
|
||||
self.update_limit(TagSearchPanel.cur_limit_idx)
|
||||
self.update_tags()
|
||||
self.scroll_area.verticalScrollBar().setValue(0)
|
||||
self.search_field.setText("")
|
||||
self.search_field.setFocus()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
# When Escape is pressed, focus back on the search box.
|
||||
# If focus is already on the search box, close the modal.
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
if self.search_field.hasFocus():
|
||||
self.parentWidget().hide()
|
||||
return super().keyPressEvent(event)
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.search_field.selectAll()
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
def remove_tag(self, tag: Tag):
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
@@ -9,10 +9,17 @@ import platform
|
||||
from src.qt.translations import Translations
|
||||
|
||||
|
||||
class PlatformStrings:
|
||||
open_file_str: str = Translations["file.open_location.generic"]
|
||||
|
||||
def open_file_str() -> str:
|
||||
if platform.system() == "Windows":
|
||||
open_file_str = Translations["file.open_location.windows"]
|
||||
return Translations["file.open_location.windows"]
|
||||
elif platform.system() == "Darwin":
|
||||
open_file_str = Translations["file.open_location.mac"]
|
||||
return Translations["file.open_location.mac"]
|
||||
else:
|
||||
return Translations["file.open_location.generic"]
|
||||
|
||||
|
||||
def trash_term() -> str:
|
||||
if platform.system() == "Windows":
|
||||
return Translations["trash.name.windows"]
|
||||
else:
|
||||
return Translations["trash.name.generic"]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
"""A Qt driver for TagStudio."""
|
||||
|
||||
import contextlib
|
||||
import ctypes
|
||||
import dataclasses
|
||||
import math
|
||||
@@ -16,6 +17,7 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from warnings import catch_warnings
|
||||
|
||||
# this import has side-effect of import PySide resources
|
||||
import src.qt.resources_rc # noqa: F401
|
||||
@@ -66,6 +68,7 @@ from src.core.library.alchemy.enums import (
|
||||
from src.core.library.alchemy.fields import _FieldID
|
||||
from src.core.library.alchemy.library import Entry, LibraryStatus
|
||||
from src.core.media_types import MediaCategories
|
||||
from src.core.palette import ColorType, UiColor, get_ui_color
|
||||
from src.core.query_lang.util import ParsingError
|
||||
from src.core.ts_core import TagStudioCore
|
||||
from src.core.utils.refresh_dir import RefreshDirTracker
|
||||
@@ -73,6 +76,7 @@ from src.core.utils.web import strip_web_protocol
|
||||
from src.qt.cache_manager import CacheManager
|
||||
from src.qt.flowlayout import FlowLayout
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.helpers.file_deleter import delete_file
|
||||
from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.main_window import Ui_MainWindow
|
||||
from src.qt.modals.about import AboutModal
|
||||
@@ -85,6 +89,7 @@ from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
|
||||
from src.qt.modals.folders_to_tags import FoldersToTagsModal
|
||||
from src.qt.modals.tag_database import TagDatabasePanel
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
from src.qt.platform_strings import trash_term
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
from src.qt.splash import Splash
|
||||
from src.qt.translations import Translations
|
||||
@@ -136,6 +141,8 @@ class QtDriver(DriverMixin, QObject):
|
||||
SIGTERM = Signal()
|
||||
|
||||
preview_panel: PreviewPanel
|
||||
tag_manager_panel: PanelModal
|
||||
file_extension_panel: PanelModal | None = None
|
||||
tag_search_panel: TagSearchPanel
|
||||
add_tag_modal: PanelModal
|
||||
|
||||
@@ -211,7 +218,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
def init_workers(self):
|
||||
"""Init workers for rendering thumbnails."""
|
||||
if not self.thumb_threads:
|
||||
max_threads = os.cpu_count()
|
||||
max_threads = os.cpu_count() or 1
|
||||
for i in range(max_threads):
|
||||
thread = Consumer(self.thumb_job_queue)
|
||||
thread.setObjectName(f"ThumbRenderer_{i}")
|
||||
@@ -291,8 +298,20 @@ class QtDriver(DriverMixin, QObject):
|
||||
icon.addFile(str(icon_path))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
# Initialize the main window's tag search panel
|
||||
# Initialize the Tag Manager panel
|
||||
self.tag_manager_panel = PanelModal(
|
||||
widget=TagDatabasePanel(self, self.lib),
|
||||
done_callback=lambda: self.preview_panel.update_widgets(update_preview=False),
|
||||
has_save=False,
|
||||
)
|
||||
Translations.translate_with_setter(self.tag_manager_panel.setTitle, "tag_manager.title")
|
||||
Translations.translate_with_setter(
|
||||
self.tag_manager_panel.setWindowTitle, "tag_manager.title"
|
||||
)
|
||||
|
||||
# Initialize the Tag Search panel
|
||||
self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True)
|
||||
self.tag_search_panel.set_driver(self)
|
||||
self.add_tag_modal = PanelModal(
|
||||
widget=self.tag_search_panel,
|
||||
title=Translations.translate_formatted("tag.add.plural"),
|
||||
@@ -355,12 +374,12 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
save_library_backup_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(save_library_backup_action, "menu.file.save_backup")
|
||||
save_library_backup_action.triggered.connect(
|
||||
self.save_library_backup_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.save_library_backup_action, "menu.file.save_backup")
|
||||
self.save_library_backup_action.triggered.connect(
|
||||
lambda: self.callback_library_needed_check(self.backup_library)
|
||||
)
|
||||
save_library_backup_action.setShortcut(
|
||||
self.save_library_backup_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
@@ -369,65 +388,71 @@ class QtDriver(DriverMixin, QObject):
|
||||
QtCore.Qt.Key.Key_S,
|
||||
)
|
||||
)
|
||||
save_library_backup_action.setStatusTip("Ctrl+Shift+S")
|
||||
file_menu.addAction(save_library_backup_action)
|
||||
self.save_library_backup_action.setStatusTip("Ctrl+Shift+S")
|
||||
self.save_library_backup_action.setEnabled(False)
|
||||
file_menu.addAction(self.save_library_backup_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
add_new_files_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(add_new_files_action, "menu.file.refresh_directories")
|
||||
add_new_files_action.triggered.connect(
|
||||
self.refresh_dir_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.refresh_dir_action, "menu.file.refresh_directories")
|
||||
self.refresh_dir_action.triggered.connect(
|
||||
lambda: self.callback_library_needed_check(self.add_new_files_callback)
|
||||
)
|
||||
add_new_files_action.setShortcut(
|
||||
self.refresh_dir_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(add_new_files_action)
|
||||
self.refresh_dir_action.setStatusTip("Ctrl+R")
|
||||
self.refresh_dir_action.setEnabled(False)
|
||||
file_menu.addAction(self.refresh_dir_action)
|
||||
file_menu.addSeparator()
|
||||
|
||||
close_library_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(close_library_action, "menu.file.close_library")
|
||||
close_library_action.triggered.connect(self.close_library)
|
||||
file_menu.addAction(close_library_action)
|
||||
self.close_library_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.close_library_action, "menu.file.close_library")
|
||||
self.close_library_action.triggered.connect(self.close_library)
|
||||
self.close_library_action.setEnabled(False)
|
||||
file_menu.addAction(self.close_library_action)
|
||||
file_menu.addSeparator()
|
||||
|
||||
# Edit Menu ============================================================
|
||||
new_tag_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(new_tag_action, "menu.edit.new_tag")
|
||||
new_tag_action.triggered.connect(lambda: self.add_tag_action_callback())
|
||||
new_tag_action.setShortcut(
|
||||
self.new_tag_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.new_tag_action, "menu.edit.new_tag")
|
||||
self.new_tag_action.triggered.connect(lambda: self.add_tag_action_callback())
|
||||
self.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)
|
||||
self.new_tag_action.setToolTip("Ctrl+T")
|
||||
self.new_tag_action.setEnabled(False)
|
||||
edit_menu.addAction(self.new_tag_action)
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
select_all_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(select_all_action, "select.all")
|
||||
select_all_action.triggered.connect(self.select_all_action_callback)
|
||||
select_all_action.setShortcut(
|
||||
self.select_all_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.select_all_action, "select.all")
|
||||
self.select_all_action.triggered.connect(self.select_all_action_callback)
|
||||
self.select_all_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_A,
|
||||
)
|
||||
)
|
||||
select_all_action.setToolTip("Ctrl+A")
|
||||
edit_menu.addAction(select_all_action)
|
||||
self.select_all_action.setToolTip("Ctrl+A")
|
||||
self.select_all_action.setEnabled(False)
|
||||
edit_menu.addAction(self.select_all_action)
|
||||
|
||||
clear_select_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(clear_select_action, "select.clear")
|
||||
clear_select_action.triggered.connect(self.clear_select_action_callback)
|
||||
clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
|
||||
clear_select_action.setToolTip("Esc")
|
||||
edit_menu.addAction(clear_select_action)
|
||||
self.clear_select_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.clear_select_action, "select.clear")
|
||||
self.clear_select_action.triggered.connect(self.clear_select_action_callback)
|
||||
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.clear_select_action.setEnabled(False)
|
||||
edit_menu.addAction(self.clear_select_action)
|
||||
|
||||
self.copy_buffer: dict = {"fields": [], "tags": []}
|
||||
|
||||
@@ -477,24 +502,36 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
manage_file_extensions_action = QAction(menu_bar)
|
||||
self.delete_file_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(
|
||||
manage_file_extensions_action, "menu.edit.manage_file_extensions"
|
||||
self.delete_file_action, "menu.delete_selected_files_ambiguous", trash_term=trash_term()
|
||||
)
|
||||
manage_file_extensions_action.triggered.connect(self.show_file_extension_modal)
|
||||
edit_menu.addAction(manage_file_extensions_action)
|
||||
self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f))
|
||||
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
|
||||
self.delete_file_action.setEnabled(False)
|
||||
edit_menu.addAction(self.delete_file_action)
|
||||
|
||||
tag_database_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(tag_database_action, "menu.edit.manage_tags")
|
||||
tag_database_action.triggered.connect(lambda: self.show_tag_database())
|
||||
tag_database_action.setShortcut(
|
||||
edit_menu.addSeparator()
|
||||
|
||||
self.manage_file_ext_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(
|
||||
self.manage_file_ext_action, "menu.edit.manage_file_extensions"
|
||||
)
|
||||
edit_menu.addAction(self.manage_file_ext_action)
|
||||
self.manage_file_ext_action.setEnabled(False)
|
||||
|
||||
self.tag_manager_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.tag_manager_action, "menu.edit.manage_tags")
|
||||
self.tag_manager_action.triggered.connect(self.tag_manager_panel.show)
|
||||
self.tag_manager_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_M,
|
||||
)
|
||||
)
|
||||
save_library_backup_action.setStatusTip("Ctrl+M")
|
||||
edit_menu.addAction(tag_database_action)
|
||||
self.tag_manager_action.setEnabled(False)
|
||||
self.tag_manager_action.setToolTip("Ctrl+M")
|
||||
edit_menu.addAction(self.tag_manager_action)
|
||||
|
||||
# View Menu ============================================================
|
||||
show_libs_list_action = QAction(menu_bar)
|
||||
@@ -524,32 +561,37 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
self.unlinked_modal.show()
|
||||
|
||||
fix_unlinked_entries_action = QAction(menu_bar)
|
||||
self.fix_unlinked_entries_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(
|
||||
fix_unlinked_entries_action, "menu.tools.fix_unlinked_entries"
|
||||
self.fix_unlinked_entries_action, "menu.tools.fix_unlinked_entries"
|
||||
)
|
||||
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
|
||||
tools_menu.addAction(fix_unlinked_entries_action)
|
||||
self.fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
|
||||
self.fix_unlinked_entries_action.setEnabled(False)
|
||||
tools_menu.addAction(self.fix_unlinked_entries_action)
|
||||
|
||||
def create_dupe_files_modal():
|
||||
if not hasattr(self, "dupe_modal"):
|
||||
self.dupe_modal = FixDupeFilesModal(self.lib, self)
|
||||
self.dupe_modal.show()
|
||||
|
||||
fix_dupe_files_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(fix_dupe_files_action, "menu.tools.fix_duplicate_files")
|
||||
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
|
||||
tools_menu.addAction(fix_dupe_files_action)
|
||||
self.fix_dupe_files_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.fix_dupe_files_action, "menu.tools.fix_duplicate_files")
|
||||
self.fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
|
||||
self.fix_dupe_files_action.setEnabled(False)
|
||||
tools_menu.addAction(self.fix_dupe_files_action)
|
||||
|
||||
tools_menu.addSeparator()
|
||||
|
||||
# TODO: Move this to a settings screen.
|
||||
clear_thumb_cache_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(clear_thumb_cache_action, "settings.clear_thumb_cache.title")
|
||||
clear_thumb_cache_action.triggered.connect(
|
||||
self.clear_thumb_cache_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(
|
||||
self.clear_thumb_cache_action, "settings.clear_thumb_cache.title"
|
||||
)
|
||||
self.clear_thumb_cache_action.triggered.connect(
|
||||
lambda: CacheManager.clear_cache(self.lib.library_dir)
|
||||
)
|
||||
tools_menu.addAction(clear_thumb_cache_action)
|
||||
self.clear_thumb_cache_action.setEnabled(False)
|
||||
tools_menu.addAction(self.clear_thumb_cache_action)
|
||||
|
||||
# create_collage_action = QAction("Create Collage", menu_bar)
|
||||
# create_collage_action.triggered.connect(lambda: self.create_collage())
|
||||
@@ -570,10 +612,11 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.folders_modal = FoldersToTagsModal(self.lib, self)
|
||||
self.folders_modal.show()
|
||||
|
||||
folders_to_tags_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(folders_to_tags_action, "menu.macros.folders_to_tags")
|
||||
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
|
||||
macros_menu.addAction(folders_to_tags_action)
|
||||
self.folders_to_tags_action = QAction(menu_bar)
|
||||
Translations.translate_qobject(self.folders_to_tags_action, "menu.macros.folders_to_tags")
|
||||
self.folders_to_tags_action.triggered.connect(create_folders_tags_modal)
|
||||
self.folders_to_tags_action.setEnabled(False)
|
||||
macros_menu.addAction(self.folders_to_tags_action)
|
||||
|
||||
# Help Menu ============================================================
|
||||
def create_about_modal():
|
||||
@@ -736,6 +779,27 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.splash.finish(self.main_window)
|
||||
|
||||
def init_file_extension_manager(self):
|
||||
"""Initialize the File Extension panel."""
|
||||
if self.file_extension_panel:
|
||||
with catch_warnings(record=True):
|
||||
self.manage_file_ext_action.triggered.disconnect()
|
||||
self.file_extension_panel.saved.disconnect()
|
||||
self.file_extension_panel.deleteLater()
|
||||
self.file_extension_panel = None
|
||||
|
||||
panel = FileExtensionModal(self.lib)
|
||||
self.file_extension_panel = PanelModal(
|
||||
panel,
|
||||
has_save=True,
|
||||
)
|
||||
Translations.translate_with_setter(self.file_extension_panel.setTitle, "ignore_list.title")
|
||||
Translations.translate_with_setter(
|
||||
self.file_extension_panel.setWindowTitle, "ignore_list.title"
|
||||
)
|
||||
self.file_extension_panel.saved.connect(lambda: (panel.save(), self.filter_items()))
|
||||
self.manage_file_ext_action.triggered.connect(self.file_extension_panel.show)
|
||||
|
||||
def show_grid_filenames(self, value: bool):
|
||||
for thumb in self.item_thumbs:
|
||||
thumb.set_filename_visibility(value)
|
||||
@@ -790,13 +854,31 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.main_window.setWindowTitle(self.base_title)
|
||||
|
||||
self.selected = []
|
||||
self.frame_content = []
|
||||
self.selected.clear()
|
||||
self.frame_content.clear()
|
||||
[x.set_mode(None) for x in self.item_thumbs]
|
||||
|
||||
self.set_clipboard_menu_viability()
|
||||
self.set_select_actions_visibility()
|
||||
|
||||
self.preview_panel.update_widgets()
|
||||
self.main_window.toggle_landing_page(enabled=True)
|
||||
self.main_window.pagination.setHidden(True)
|
||||
try:
|
||||
self.save_library_backup_action.setEnabled(False)
|
||||
self.close_library_action.setEnabled(False)
|
||||
self.refresh_dir_action.setEnabled(False)
|
||||
self.tag_manager_action.setEnabled(False)
|
||||
self.manage_file_ext_action.setEnabled(False)
|
||||
self.new_tag_action.setEnabled(False)
|
||||
self.fix_unlinked_entries_action.setEnabled(False)
|
||||
self.fix_dupe_files_action.setEnabled(False)
|
||||
self.clear_thumb_cache_action.setEnabled(False)
|
||||
self.folders_to_tags_action.setEnabled(False)
|
||||
except AttributeError:
|
||||
logger.warning(
|
||||
"[Library] Could not disable library management menu actions. Is this in a test?"
|
||||
)
|
||||
|
||||
# NOTE: Doesn't try to disable during tests
|
||||
if self.add_tag_to_selected_action:
|
||||
@@ -855,13 +937,13 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_clipboard_menu_viability()
|
||||
self.set_add_to_selected_visibility()
|
||||
self.set_select_actions_visibility()
|
||||
|
||||
self.preview_panel.update_widgets(update_preview=False)
|
||||
|
||||
def clear_select_action_callback(self):
|
||||
self.selected.clear()
|
||||
self.set_add_to_selected_visibility()
|
||||
self.set_select_actions_visibility()
|
||||
for item in self.item_thumbs:
|
||||
item.thumb_button.set_selected(False)
|
||||
|
||||
@@ -870,30 +952,142 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def add_tags_to_selected_callback(self, tag_ids: list[int]):
|
||||
for entry_id in self.selected:
|
||||
self.lib.add_tags_to_entry(entry_id, tag_ids)
|
||||
self.lib.add_tags_to_entries(self.selected, tag_ids)
|
||||
|
||||
def show_tag_database(self):
|
||||
self.modal = PanelModal(
|
||||
widget=TagDatabasePanel(self.lib),
|
||||
done_callback=lambda: self.preview_panel.update_widgets(update_preview=False),
|
||||
has_save=False,
|
||||
def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None):
|
||||
"""Callback to send on or more files to the system trash.
|
||||
|
||||
If 0-1 items are currently selected, the origin_path is used to delete the file
|
||||
from the originating context menu item.
|
||||
If there are currently multiple items selected,
|
||||
then the selection buffer is used to determine the files to be deleted.
|
||||
|
||||
Args:
|
||||
origin_path(str): The file path associated with the widget making the call.
|
||||
May or may not be the file targeted, depending on the selection rules.
|
||||
origin_id(id): The entry ID associated with the widget making the call.
|
||||
"""
|
||||
entry: Entry | None = None
|
||||
pending: list[tuple[int, Path]] = []
|
||||
deleted_count: int = 0
|
||||
|
||||
if len(self.selected) <= 1 and origin_path:
|
||||
origin_id_ = origin_id
|
||||
if not origin_id_:
|
||||
with contextlib.suppress(IndexError):
|
||||
origin_id_ = self.selected[0]
|
||||
|
||||
pending.append((origin_id_, Path(origin_path)))
|
||||
elif (len(self.selected) > 1) or (len(self.selected) <= 1):
|
||||
for item in self.selected:
|
||||
entry = self.lib.get_entry(item)
|
||||
filepath: Path = entry.path
|
||||
pending.append((item, filepath))
|
||||
|
||||
if pending:
|
||||
return_code = self.delete_file_confirmation(len(pending), pending[0][1])
|
||||
# If there was a confirmation and not a cancellation
|
||||
if (
|
||||
return_code == QMessageBox.ButtonRole.DestructiveRole.value
|
||||
and return_code != QMessageBox.ButtonRole.ActionRole.value
|
||||
):
|
||||
for i, tup in enumerate(pending):
|
||||
e_id, f = tup
|
||||
if (origin_path == f) or (not origin_path):
|
||||
self.preview_panel.thumb.stop_file_use()
|
||||
if delete_file(self.lib.library_dir / f):
|
||||
self.main_window.statusbar.showMessage(
|
||||
Translations.translate_formatted(
|
||||
"status.deleting_file", i=i, count=len(pending), path=f
|
||||
)
|
||||
)
|
||||
self.main_window.statusbar.repaint()
|
||||
self.lib.remove_entries([e_id])
|
||||
|
||||
deleted_count += 1
|
||||
self.selected.clear()
|
||||
|
||||
if deleted_count > 0:
|
||||
self.filter_items()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
if len(self.selected) <= 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
|
||||
elif len(self.selected) <= 1 and deleted_count == 1:
|
||||
self.main_window.statusbar.showMessage(
|
||||
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
|
||||
elif len(self.selected) > 1 and deleted_count < len(self.selected):
|
||||
self.main_window.statusbar.showMessage(
|
||||
Translations.translate_formatted(
|
||||
"status.deleted_partial_warning", count=deleted_count
|
||||
)
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count == len(self.selected):
|
||||
self.main_window.statusbar.showMessage(
|
||||
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
|
||||
)
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
|
||||
"""A confirmation dialogue box for deleting files.
|
||||
|
||||
Args:
|
||||
count(int): The number of files to be deleted.
|
||||
filename(Path | None): The filename to show if only one file is to be deleted.
|
||||
"""
|
||||
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the
|
||||
# Recycle Bin. This is done without any warning, so this message is currently the
|
||||
# best way I've got to inform the user.
|
||||
# https://github.com/arsenetar/send2trash/issues/28
|
||||
# This warning is applied to all platforms until at least macOS and Linux can be verified
|
||||
# to not exhibit this same behavior.
|
||||
perm_warning_msg = Translations.translate_formatted(
|
||||
"trash.dialog.permanent_delete_warning", trash_term=trash_term()
|
||||
)
|
||||
Translations.translate_with_setter(self.modal.setTitle, "tag_manager.title")
|
||||
Translations.translate_with_setter(self.modal.setWindowTitle, "tag_manager.title")
|
||||
self.modal.show()
|
||||
|
||||
def show_file_extension_modal(self):
|
||||
panel = FileExtensionModal(self.lib)
|
||||
self.modal = PanelModal(
|
||||
panel,
|
||||
has_save=True,
|
||||
perm_warning: str = (
|
||||
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>"
|
||||
f"{perm_warning_msg}</h4>"
|
||||
)
|
||||
Translations.translate_with_setter(self.modal.setTitle, "ignore_list.title")
|
||||
Translations.translate_with_setter(self.modal.setWindowTitle, "ignore_list.title")
|
||||
|
||||
self.modal.saved.connect(lambda: (panel.save(), self.filter_items()))
|
||||
self.modal.show()
|
||||
msg = QMessageBox()
|
||||
msg.setStyleSheet("font-weight:normal;")
|
||||
msg.setTextFormat(Qt.TextFormat.RichText)
|
||||
msg.setWindowTitle(
|
||||
Translations["trash.title.singular"]
|
||||
if count == 1
|
||||
else Translations["trash.title.plural"]
|
||||
)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
if count <= 1:
|
||||
msg_text = Translations.translate_formatted(
|
||||
"trash.dialog.move.confirmation.singular", trash_term=trash_term()
|
||||
)
|
||||
msg.setText(
|
||||
f"<h3>{msg_text}</h3>"
|
||||
f"<h4>{Translations["trash.dialog.disambiguation_warning.singular"]}</h4>"
|
||||
f"{filename if filename else ''}"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
elif count > 1:
|
||||
msg_text = Translations.translate_formatted(
|
||||
"trash.dialog.move.confirmation.plural",
|
||||
count=count,
|
||||
trash_term=trash_term(),
|
||||
)
|
||||
msg.setText(
|
||||
f"<h3>{msg_text}</h3>"
|
||||
f"<h4>{Translations["trash.dialog.disambiguation_warning.plural"]}</h4>"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
|
||||
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
|
||||
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
|
||||
msg.setDefaultButton(yes_button)
|
||||
|
||||
return msg.exec()
|
||||
|
||||
def add_new_files_callback(self):
|
||||
"""Run when user initiates adding new files to the Library."""
|
||||
@@ -1164,7 +1358,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
exists = True
|
||||
if not exists:
|
||||
self.lib.add_field_to_entry(id, field_id=field.type_key, value=field.value)
|
||||
self.lib.add_tags_to_entry(id, self.copy_buffer["tags"])
|
||||
self.lib.add_tags_to_entries(id, self.copy_buffer["tags"])
|
||||
if len(self.selected) > 1:
|
||||
if TAG_ARCHIVED in self.copy_buffer["tags"]:
|
||||
self.update_badges({BadgeType.ARCHIVED: True}, origin_id=0, add_tags=False)
|
||||
@@ -1244,7 +1438,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.set_clipboard_menu_viability()
|
||||
self.set_add_to_selected_visibility()
|
||||
self.set_select_actions_visibility()
|
||||
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
@@ -1261,14 +1455,23 @@ class QtDriver(DriverMixin, QObject):
|
||||
else:
|
||||
self.paste_fields_action.setEnabled(False)
|
||||
|
||||
def set_add_to_selected_visibility(self):
|
||||
def set_select_actions_visibility(self):
|
||||
if not self.add_tag_to_selected_action:
|
||||
return
|
||||
|
||||
if self.frame_content:
|
||||
self.select_all_action.setEnabled(True)
|
||||
else:
|
||||
self.select_all_action.setEnabled(False)
|
||||
|
||||
if self.selected:
|
||||
self.add_tag_to_selected_action.setEnabled(True)
|
||||
self.clear_select_action.setEnabled(True)
|
||||
self.delete_file_action.setEnabled(True)
|
||||
else:
|
||||
self.add_tag_to_selected_action.setEnabled(False)
|
||||
self.clear_select_action.setEnabled(False)
|
||||
self.delete_file_action.setEnabled(False)
|
||||
|
||||
def update_completions_list(self, text: str) -> None:
|
||||
matches = re.search(
|
||||
@@ -1302,7 +1505,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
elif query_type == "tag_id":
|
||||
completion_list = list(map(lambda x: prefix + "tag_id:" + str(x.id), self.lib.tags))
|
||||
elif query_type == "path":
|
||||
completion_list = list(map(lambda x: prefix + "path:" + x, self.lib.get_paths()))
|
||||
completion_list = list(
|
||||
map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100))
|
||||
)
|
||||
elif query_type == "mediatype":
|
||||
single_word_completions = map(
|
||||
lambda x: prefix + "mediatype:" + x.name,
|
||||
@@ -1376,6 +1581,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
with catch_warnings(record=True):
|
||||
item_thumb.delete_action.triggered.disconnect()
|
||||
|
||||
item_thumb.set_mode(ItemType.ENTRY)
|
||||
item_thumb.set_item_id(entry.id)
|
||||
item_thumb.show()
|
||||
@@ -1421,6 +1629,11 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
)
|
||||
)
|
||||
item_thumb.delete_action.triggered.connect(
|
||||
lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback(
|
||||
f, e_id
|
||||
)
|
||||
)
|
||||
|
||||
# Restore Selected Borders
|
||||
is_selected = item_thumb.item_id in self.selected
|
||||
@@ -1438,14 +1651,41 @@ class QtDriver(DriverMixin, QObject):
|
||||
the items. Defaults to True.
|
||||
"""
|
||||
item_ids = self.selected if (not origin_id or origin_id in self.selected) else [origin_id]
|
||||
pending_entries: dict[BadgeType, list[int]] = {}
|
||||
|
||||
logger.info(
|
||||
"[QtDriver][update_badges] Updating ItemThumb badges",
|
||||
badge_values=badge_values,
|
||||
origin_id=origin_id,
|
||||
add_tags=add_tags,
|
||||
)
|
||||
for it in self.item_thumbs:
|
||||
if it.item_id in item_ids:
|
||||
for badge_type, value in badge_values.items():
|
||||
if add_tags:
|
||||
if not pending_entries.get(badge_type):
|
||||
pending_entries[badge_type] = []
|
||||
pending_entries[badge_type].append(it.item_id)
|
||||
it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type])
|
||||
it.assign_badge(badge_type, value)
|
||||
|
||||
if not add_tags:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[QtDriver][update_badges] Adding tags to updated entries",
|
||||
pending_entries=pending_entries,
|
||||
)
|
||||
for badge_type, value in badge_values.items():
|
||||
if value:
|
||||
self.lib.add_tags_to_entries(
|
||||
pending_entries.get(badge_type, []), BADGE_TAGS[badge_type]
|
||||
)
|
||||
else:
|
||||
self.lib.remove_tags_from_entries(
|
||||
pending_entries.get(badge_type, []), BADGE_TAGS[badge_type]
|
||||
)
|
||||
|
||||
def filter_items(self, filter: FilterState | None = None) -> None:
|
||||
if not self.lib.library_dir:
|
||||
logger.info("Library not loaded")
|
||||
@@ -1632,8 +1872,21 @@ class QtDriver(DriverMixin, QObject):
|
||||
)
|
||||
self.main_window.setAcceptDrops(True)
|
||||
|
||||
self.init_file_extension_manager()
|
||||
|
||||
self.selected.clear()
|
||||
self.set_add_to_selected_visibility()
|
||||
self.set_select_actions_visibility()
|
||||
self.save_library_backup_action.setEnabled(True)
|
||||
self.close_library_action.setEnabled(True)
|
||||
self.refresh_dir_action.setEnabled(True)
|
||||
self.tag_manager_action.setEnabled(True)
|
||||
self.manage_file_ext_action.setEnabled(True)
|
||||
self.new_tag_action.setEnabled(True)
|
||||
self.fix_dupe_files_action.setEnabled(True)
|
||||
self.fix_unlinked_entries_action.setEnabled(True)
|
||||
self.clear_thumb_cache_action.setEnabled(True)
|
||||
self.folders_to_tags_action.setEnabled(True)
|
||||
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
# page (re)rendering, extract eventually
|
||||
|
||||
@@ -29,7 +29,7 @@ from src.core.library import ItemType, Library
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.flowlayout import FlowWidget
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.platform_strings import open_file_str, trash_term
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.thumb_button import ThumbButton
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -219,10 +219,17 @@ class ItemThumb(FlowWidget):
|
||||
open_file_action = QAction(self)
|
||||
Translations.translate_qobject(open_file_action, "file.open_file")
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
open_explorer_action = QAction(open_file_str(), self)
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
self.delete_action = QAction(self)
|
||||
Translations.translate_qobject(
|
||||
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
|
||||
)
|
||||
|
||||
self.thumb_button.addAction(open_file_action)
|
||||
self.thumb_button.addAction(open_explorer_action)
|
||||
self.thumb_button.addAction(self.delete_action)
|
||||
|
||||
# Static Badges ========================================================
|
||||
|
||||
@@ -492,15 +499,11 @@ class ItemThumb(FlowWidget):
|
||||
toggle_value: bool,
|
||||
tag_id: int,
|
||||
):
|
||||
logger.info("toggle_item_tag", entry_id=entry_id, toggle_value=toggle_value, tag_id=tag_id)
|
||||
|
||||
if toggle_value:
|
||||
self.lib.add_tags_to_entry(entry_id, tag_id)
|
||||
else:
|
||||
self.lib.remove_tags_from_entry(entry_id, tag_id)
|
||||
|
||||
if self.driver.preview_panel.is_open:
|
||||
self.driver.preview_panel.update_widgets(update_preview=False)
|
||||
if entry_id in self.driver.selected and self.driver.preview_panel.is_open:
|
||||
if len(self.driver.selected) == 1:
|
||||
self.driver.preview_panel.fields.update_toggled_tag(tag_id, toggle_value)
|
||||
else:
|
||||
pass
|
||||
|
||||
def mouseMoveEvent(self, event): # noqa: N802
|
||||
if event.buttons() is not Qt.MouseButton.LeftButton:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
@@ -110,3 +113,11 @@ class PagedPanel(QWidget):
|
||||
item.setHidden(False)
|
||||
elif isinstance(item, int):
|
||||
self.button_nav_layout.addStretch(item)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.close()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
from src.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PanelModal(QWidget):
|
||||
saved = Signal()
|
||||
@@ -96,7 +98,10 @@ class PanelModal(QWidget):
|
||||
widget.parent_post_init()
|
||||
|
||||
def closeEvent(self, event): # noqa: N802
|
||||
self.done_button.click()
|
||||
if self.cancel_button:
|
||||
self.cancel_button.click()
|
||||
elif self.done_button:
|
||||
self.done_button.click()
|
||||
event.accept()
|
||||
|
||||
def setTitle(self, title: str): # noqa: N802
|
||||
@@ -125,12 +130,19 @@ class PanelWidget(QWidget):
|
||||
pass
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
logging.warning(f"add_callback not implemented for {self.__class__.__name__}")
|
||||
logger.warning(f"[PanelModal] add_callback not implemented for {self.__class__.__name__}")
|
||||
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
if self.panel_cancel_button:
|
||||
self.panel_cancel_button.click()
|
||||
elif self.panel_done_button:
|
||||
self.panel_done_button.click()
|
||||
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||
if self.panel_save_button:
|
||||
self.panel_save_button.click()
|
||||
elif self.panel_done_button:
|
||||
self.panel_done_button.click()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -114,13 +114,18 @@ class FieldContainers(QWidget):
|
||||
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
|
||||
|
||||
self.cached_entries = [self.lib.get_entry_full(entry_id)]
|
||||
entry_ = self.cached_entries[0]
|
||||
container_len: int = len(entry_.fields)
|
||||
container_index = 0
|
||||
entry = self.cached_entries[0]
|
||||
self.update_granular(entry.tags, entry.fields, update_badges)
|
||||
|
||||
def update_granular(
|
||||
self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True
|
||||
):
|
||||
"""Individually update elements of the item preview."""
|
||||
container_len: int = len(entry_fields)
|
||||
container_index = 0
|
||||
# Write tag container(s)
|
||||
if entry_.tags:
|
||||
categories = self.get_tag_categories(entry_.tags)
|
||||
if entry_tags:
|
||||
categories = self.get_tag_categories(entry_tags)
|
||||
for cat, tags in sorted(categories.items(), key=lambda kv: (kv[0] is None, kv)):
|
||||
self.write_tag_container(
|
||||
container_index, tags=tags, category_tag=cat, is_mixed=False
|
||||
@@ -128,10 +133,10 @@ class FieldContainers(QWidget):
|
||||
container_index += 1
|
||||
container_len += 1
|
||||
if update_badges:
|
||||
self.emit_badge_signals({t.id for t in entry_.tags})
|
||||
self.emit_badge_signals({t.id for t in entry_tags})
|
||||
|
||||
# Write field container(s)
|
||||
for index, field in enumerate(entry_.fields, start=container_index):
|
||||
for index, field in enumerate(entry_fields, start=container_index):
|
||||
self.write_container(index, field, is_mixed=False)
|
||||
|
||||
# Hide leftover container(s)
|
||||
@@ -140,6 +145,17 @@ class FieldContainers(QWidget):
|
||||
if i > (container_len - 1):
|
||||
c.setHidden(True)
|
||||
|
||||
def update_toggled_tag(self, tag_id: int, toggle_value: bool):
|
||||
"""Visually add or remove a tag from the item preview without needing to query the db."""
|
||||
entry = self.cached_entries[0]
|
||||
tag = self.lib.get_tag(tag_id)
|
||||
if not tag:
|
||||
return
|
||||
new_tags = (
|
||||
entry.tags.union({tag}) if toggle_value else {t for t in entry.tags if t.id != tag_id}
|
||||
)
|
||||
self.update_granular(entry_tags=new_tags, entry_fields=entry.fields, update_badges=False)
|
||||
|
||||
def hide_containers(self):
|
||||
"""Hide all field and tag containers."""
|
||||
for c in self.containers:
|
||||
@@ -262,7 +278,7 @@ class FieldContainers(QWidget):
|
||||
tags=tags,
|
||||
)
|
||||
for entry_id in self.driver.selected:
|
||||
self.lib.add_tags_to_entry(
|
||||
self.lib.add_tags_to_entries(
|
||||
entry_id,
|
||||
tag_ids=tags,
|
||||
)
|
||||
@@ -500,10 +516,9 @@ class FieldContainers(QWidget):
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
)
|
||||
remove_mb.addButton("&Remove", QMessageBox.ButtonRole.RejectRole)
|
||||
remove_mb.setDefaultButton(cancel_button)
|
||||
remove_mb.setEscapeButton(cancel_button)
|
||||
result = remove_mb.exec_()
|
||||
if result == 3: # TODO - what is this magic number?
|
||||
if result == QMessageBox.ButtonRole.ActionRole.value:
|
||||
callback()
|
||||
|
||||
def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True):
|
||||
|
||||
@@ -6,6 +6,7 @@ import io
|
||||
import time
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from warnings import catch_warnings
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
@@ -24,7 +25,8 @@ from src.qt.helpers.file_opener import FileOpenerHelper, open_file
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.platform_strings import open_file_str, trash_term
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
from src.qt.translations import Translations
|
||||
from src.qt.widgets.media_player import MediaPlayer
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -54,7 +56,11 @@ class PreviewThumb(QWidget):
|
||||
|
||||
self.open_file_action = QAction(self)
|
||||
Translations.translate_qobject(self.open_file_action, "file.open_file")
|
||||
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
self.open_explorer_action = QAction(open_file_str(), self)
|
||||
self.delete_action = QAction(self)
|
||||
Translations.translate_qobject(
|
||||
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
|
||||
)
|
||||
|
||||
self.preview_img = QPushButtonWrapper()
|
||||
self.preview_img.setMinimumSize(*self.img_button_size)
|
||||
@@ -62,6 +68,7 @@ class PreviewThumb(QWidget):
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_img.addAction(self.open_file_action)
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
self.preview_img.addAction(self.delete_action)
|
||||
|
||||
self.preview_gif = QLabel()
|
||||
self.preview_gif.setMinimumSize(*self.img_button_size)
|
||||
@@ -69,10 +76,12 @@ class PreviewThumb(QWidget):
|
||||
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.preview_gif.addAction(self.open_file_action)
|
||||
self.preview_gif.addAction(self.open_explorer_action)
|
||||
self.preview_gif.addAction(self.delete_action)
|
||||
self.preview_gif.hide()
|
||||
self.gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.addAction(self.delete_action)
|
||||
self.preview_vid.hide()
|
||||
self.thumb_renderer = ThumbRenderer(self.lib)
|
||||
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
|
||||
@@ -355,7 +364,7 @@ class PreviewThumb(QWidget):
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
if self.preview_img.is_connected:
|
||||
with catch_warnings(record=True):
|
||||
self.preview_img.clicked.disconnect()
|
||||
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
|
||||
self.preview_img.is_connected = True
|
||||
@@ -367,12 +376,31 @@ class PreviewThumb(QWidget):
|
||||
self.open_file_action.triggered.connect(self.opener.open_file)
|
||||
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
with catch_warnings(record=True):
|
||||
self.delete_action.triggered.disconnect()
|
||||
|
||||
self.delete_action.setText(
|
||||
Translations.translate_formatted("trash.context.singular", trash_term=trash_term())
|
||||
)
|
||||
self.delete_action.triggered.connect(
|
||||
lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
|
||||
)
|
||||
self.delete_action.setEnabled(bool(filepath))
|
||||
|
||||
return stats
|
||||
|
||||
def hide_preview(self):
|
||||
"""Completely hide the file preview."""
|
||||
self.switch_preview("")
|
||||
|
||||
def stop_file_use(self):
|
||||
"""Stops the use of the currently previewed file. Used to release file permissions."""
|
||||
logger.info("[PreviewThumb] Stopping file use in video playback...")
|
||||
# This swaps the video out for a placeholder so the previous video's file
|
||||
# is no longer in use by this object.
|
||||
self.preview_vid.play(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8))
|
||||
self.preview_vid.hide()
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
|
||||
self.update_image_size((self.size().width(), self.size().height()))
|
||||
return super().resizeEvent(event)
|
||||
|
||||
@@ -211,9 +211,4 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
)
|
||||
|
||||
self.add_tag_button.clicked.connect(
|
||||
lambda: (
|
||||
self.tag_search_panel.update_tags(),
|
||||
self.add_tag_modal.show(),
|
||||
)
|
||||
)
|
||||
self.add_tag_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
@@ -105,7 +105,7 @@ class TagWidget(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag: Tag,
|
||||
tag: Tag | None,
|
||||
has_edit: bool,
|
||||
has_remove: bool,
|
||||
library: "Library | None" = None,
|
||||
@@ -127,10 +127,7 @@ class TagWidget(QWidget):
|
||||
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
if self.lib:
|
||||
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
|
||||
else:
|
||||
self.bg_button.setText(escape_text(tag.name))
|
||||
|
||||
if has_edit:
|
||||
edit_action = QAction(self)
|
||||
edit_action.setText(Translations.translate_formatted("generic.edit"))
|
||||
@@ -153,9 +150,38 @@ class TagWidget(QWidget):
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.remove_button = QPushButton(self)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setText("–")
|
||||
self.remove_button.setHidden(True)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
self.remove_button.setHidden(True)
|
||||
self.inner_layout.addWidget(self.remove_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(44, 22)
|
||||
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
|
||||
# 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.bg_button.clicked.connect(self.on_click.emit)
|
||||
|
||||
self.set_tag(tag)
|
||||
|
||||
def set_tag(self, tag: Tag | None) -> None:
|
||||
self.tag = tag
|
||||
|
||||
if not tag:
|
||||
return
|
||||
|
||||
primary_color = get_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
@@ -200,55 +226,42 @@ class TagWidget(QWidget):
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
|
||||
self.base_layout.addWidget(self.bg_button)
|
||||
self.remove_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
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"QPushButton{{"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 800;"
|
||||
f"border-radius: 5px;"
|
||||
f"border-width: 4;"
|
||||
f"border-color: rgba(0,0,0,0);"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 14px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"border-width: 2;"
|
||||
f"border-radius: 6px;"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
if self.lib:
|
||||
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
|
||||
else:
|
||||
self.bg_button.setText(escape_text(tag.name))
|
||||
|
||||
if has_remove:
|
||||
self.inner_layout.addWidget(self.remove_button)
|
||||
self.inner_layout.addStretch(1)
|
||||
|
||||
# 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.bg_button.clicked.connect(self.on_click.emit)
|
||||
def set_has_remove(self, has_remove: bool):
|
||||
self.has_remove = has_remove
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802
|
||||
if self.has_remove:
|
||||
|
||||
@@ -101,6 +101,6 @@ class TagBoxWidget(FieldWidget):
|
||||
)
|
||||
|
||||
for entry_id in self.driver.selected:
|
||||
self.driver.lib.remove_tags_from_entry(entry_id, tag_id)
|
||||
self.driver.lib.remove_tags_from_entries(entry_id, tag_id)
|
||||
|
||||
self.updated.emit()
|
||||
|
||||
@@ -30,7 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget
|
||||
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
|
||||
from src.core.enums import SettingItems
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
from src.qt.platform_strings import PlatformStrings
|
||||
from src.qt.platform_strings import open_file_str
|
||||
from src.qt.translations import Translations
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -130,7 +130,7 @@ class VideoPlayer(QGraphicsView):
|
||||
Translations.translate_qobject(open_file_action, "file.open_file")
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
|
||||
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
|
||||
open_explorer_action = QAction(open_file_str(), self)
|
||||
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.addAction(open_file_action)
|
||||
|
||||
@@ -95,7 +95,7 @@ def library(request):
|
||||
path=pathlib.Path("foo.txt"),
|
||||
fields=lib.default_fields,
|
||||
)
|
||||
assert lib.add_tags_to_entry(entry.id, tag.id)
|
||||
assert lib.add_tags_to_entries(entry.id, tag.id)
|
||||
|
||||
entry2 = Entry(
|
||||
id=2,
|
||||
@@ -103,7 +103,7 @@ def library(request):
|
||||
path=pathlib.Path("one/two/bar.md"),
|
||||
fields=lib.default_fields,
|
||||
)
|
||||
assert lib.add_tags_to_entry(entry2.id, tag2.id)
|
||||
assert lib.add_tags_to_entries(entry2.id, tag2.id)
|
||||
|
||||
assert lib.add_entries([entry, entry2])
|
||||
assert len(lib.tags) == 6
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_meta_tag_category(qt_driver, library, entry_full):
|
||||
panel = PreviewPanel(library, qt_driver)
|
||||
|
||||
# Ensure the Favorite tag is on entry_full
|
||||
library.add_tags_to_entry(1, entry_full.id)
|
||||
library.add_tags_to_entries(1, entry_full.id)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
@@ -151,7 +151,7 @@ def test_custom_tag_category(qt_driver, library, entry_full):
|
||||
)
|
||||
|
||||
# Ensure the Favorite tag is on entry_full
|
||||
library.add_tags_to_entry(1, entry_full.id)
|
||||
library.add_tags_to_entries(1, entry_full.id)
|
||||
|
||||
# Select the single entry
|
||||
qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
|
||||
|
||||
@@ -330,8 +330,8 @@ def test_merge_entries(library: Library):
|
||||
tag_0 = library.add_tag(Tag(id=1000, name="tag_0"))
|
||||
tag_1 = library.add_tag(Tag(id=1001, name="tag_1"))
|
||||
tag_2 = library.add_tag(Tag(id=1002, name="tag_2"))
|
||||
library.add_tags_to_entry(ids[0], [tag_0.id, tag_2.id])
|
||||
library.add_tags_to_entry(ids[1], [tag_1.id])
|
||||
library.add_tags_to_entries(ids[0], [tag_0.id, tag_2.id])
|
||||
library.add_tags_to_entries(ids[1], [tag_1.id])
|
||||
library.merge_entries(entry_a, entry_b)
|
||||
assert library.has_path_entry(Path("b"))
|
||||
assert not library.has_path_entry(Path("a"))
|
||||
@@ -344,11 +344,11 @@ def test_merge_entries(library: Library):
|
||||
AssertionError()
|
||||
|
||||
|
||||
def test_remove_tag_from_entry(library, entry_full):
|
||||
def test_remove_tags_from_entries(library, entry_full):
|
||||
removed_tag_id = -1
|
||||
for tag in entry_full.tags:
|
||||
removed_tag_id = tag.id
|
||||
library.remove_tags_from_entry(entry_full.id, tag.id)
|
||||
library.remove_tags_from_entries(entry_full.id, tag.id)
|
||||
|
||||
entry = next(library.get_entries(with_joins=True))
|
||||
assert removed_tag_id not in [t.id for t in entry.tags]
|
||||
@@ -414,6 +414,24 @@ def test_library_prefs_multiple_identical_vals():
|
||||
assert TestPrefs.BAR.value
|
||||
|
||||
|
||||
def test_path_search_ilike(library: Library):
|
||||
results = library.search_library(FilterState.from_path("bar.md"))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_like(library: Library):
|
||||
results = library.search_library(FilterState.from_path("BAR.MD"))
|
||||
assert results.total_count == 0
|
||||
assert len(results.items) == 0
|
||||
|
||||
|
||||
def test_path_search_default_with_sep(library: Library):
|
||||
results = library.search_library(FilterState.from_path("one/two"))
|
||||
assert results.total_count == 1
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_glob_after(library: Library):
|
||||
results = library.search_library(FilterState.from_path("foo*"))
|
||||
assert results.total_count == 1
|
||||
@@ -432,6 +450,50 @@ def test_path_search_glob_both_sides(library: Library):
|
||||
assert len(results.items) == 1
|
||||
|
||||
|
||||
def test_path_search_ilike_glob_equality(library: Library):
|
||||
results_ilike = library.search_library(FilterState.from_path("one/two"))
|
||||
results_glob = library.search_library(FilterState.from_path("*one/two*"))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar*"))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
|
||||
def test_path_search_like_glob_equality(library: Library):
|
||||
results_ilike = library.search_library(FilterState.from_path("ONE/two"))
|
||||
results_glob = library.search_library(FilterState.from_path("*ONE/two*"))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
|
||||
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
|
||||
assert [e.id for e in results_ilike.items] == [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("BAR.MD"))
|
||||
results_glob = library.search_library(FilterState.from_path("*bar.md*"))
|
||||
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
results_ilike = library.search_library(FilterState.from_path("bar.md"))
|
||||
results_glob = library.search_library(FilterState.from_path("*BAR.MD*"))
|
||||
assert [e.id for e in results_ilike.items] != [e.id for e in results_glob.items]
|
||||
results_ilike, results_glob = None, None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(["filetype", "num_of_filetype"], [("md", 1), ("txt", 1), ("png", 0)])
|
||||
def test_filetype_search(library, filetype, num_of_filetype):
|
||||
results = library.search_library(FilterState.from_filetype(filetype))
|
||||
|
||||
Reference in New Issue
Block a user