mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 14:20:48 +00:00
Compare commits
17 Commits
v9.5.0-pr1
...
v9.5.0-pr2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f38a79b06e | ||
|
|
dbf7353bdf | ||
|
|
480328b83b | ||
|
|
6a54323307 | ||
|
|
f48b363383 | ||
|
|
634e1c7fe9 | ||
|
|
90a826d128 | ||
|
|
93fc28e092 | ||
|
|
80c7e81e69 | ||
|
|
f212e2393a | ||
|
|
d7958892b7 | ||
|
|
5be7dfc314 | ||
|
|
6e402ac34d | ||
|
|
9bdbafa40c | ||
|
|
2215403201 | ||
|
|
16ebd89196 | ||
|
|
f5ff4d78c1 |
@@ -82,7 +82,7 @@ Translation hosting generously provided by [Weblate](https://weblate.org/en/). C
|
||||
|
||||
### Search
|
||||
|
||||
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
|
||||
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`). Path searches currently use [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax, so you may need to wrap your filename or filepath in asterisks while searching. This will not be strictly necessary in future versions of the program.
|
||||
- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries
|
||||
- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively
|
||||
|
||||
@@ -166,6 +166,10 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
|
||||
- The **color** option lets you select an optional color palette to use for your tag.
|
||||
- The **"Is Cagegory"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
|
||||
|
||||
#### Tag Manager
|
||||
|
||||
You can manage your library of tags from opening the "Tag Manager" panel from Edit -> "Tag Manager". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
|
||||
|
||||
### Editing Tags
|
||||
|
||||
To edit a tag, click on it inside the preview panel or right-click the tag and select “Edit Tag” from the context menu.
|
||||
|
||||
@@ -9,8 +9,11 @@ title: Home
|
||||
TagStudio is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
|
||||
|
||||
<figure width="60%" markdown="span">
|
||||
|
||||

|
||||
|
||||
<figcaption>TagStudio Alpha v9.5.0 running on macOS Sequoia.</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
## Feature Roadmap
|
||||
@@ -21,13 +24,13 @@ The [Feature Roadmap](updates/roadmap.md) lists all of the planned core features
|
||||
|
||||
### Libraries
|
||||
|
||||
- Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location.
|
||||
- Create [libraries](./library/index.md) centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location.
|
||||
- Address moved, deleted, or otherwise "unlinked" files by using the "Fix Unlinked Entries" option in the Tools menu.
|
||||
|
||||
### Tagging + Custom Metadata
|
||||
### Tagging + Metadata Fields
|
||||
|
||||
- Add custom powerful tags to your library entries
|
||||
- Add metadata to your library entries, including:
|
||||
- Add custom powerful [tags](./library/tag.md) to your library entries
|
||||
- Add [metadata fields](./library/field.md) to your library entries, including:
|
||||
- Name, Author, Artist (Single-Line Text Fields)
|
||||
- Description, Notes (Multiline Text Fields)
|
||||
- Create rich tags composed of a name, color, a list of aliases, and a list of “parent tags” - these being tags in which these tags inherit values from.
|
||||
@@ -37,13 +40,13 @@ The [Feature Roadmap](updates/roadmap.md) lists all of the planned core features
|
||||
|
||||
### Search
|
||||
|
||||
- Search for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
|
||||
- [Search](./library/library_search.md) for file entries based on tags, file path (`path:`), file types (`filetype:`), and even media types! (`mediatype:`)
|
||||
- Use and combine boolean operators (`AND`, `OR`, `NOT`) along with parentheses groups, quotation escaping, and underscore substitution to create detailed search queries
|
||||
- Use special search conditions (`special:untagged` and `special:empty`) to find file entries without tags or fields, respectively
|
||||
- Use special search conditions (`special:untagged`) to find file entries without tags or fields, respectively
|
||||
|
||||
### File Entries
|
||||
|
||||
- Nearly all file types are supported in TagStudio libraries - just not all have dedicated thumbnail support.
|
||||
- Nearly all [file](./library/entry.md) types are supported in TagStudio libraries - just not all have dedicated thumbnail support.
|
||||
- Preview most image file types, animated GIFs, videos, plain text documents, audio files, Blender projects, and more!
|
||||
- Open files or file locations by right-clicking on thumbnails and previews and selecting the respective context menu options. You can also click on the preview panel image to open the file, and click the file path label to open its location.
|
||||
- Delete files from both your library and drive by right-clicking the thumbnail(s) and selecting the "Move to Trash"/"Move to Recycle Bin" option.
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
# Field
|
||||
# Fields
|
||||
|
||||
Fields are the building blocks of metadata stored in [entries](entry.md). Fields have several base types for representing different kinds of information, including:
|
||||
Fields are additional types of metadata that you can attach to [file entries](entry.md). Like [tags](tag.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](index.md) save file.
|
||||
|
||||
#### `text_line`
|
||||
## Field Types
|
||||
|
||||
- A string of text, displayed as a single line.
|
||||
- e.g: Title, Author, Artist, URL, etc.
|
||||
### Text Line
|
||||
|
||||
#### `text_box`
|
||||
A string of text, displayed as a single line.
|
||||
|
||||
- A long string of text displayed as a box of text.
|
||||
- e.g: Description, Notes, etc.
|
||||
- e.g: Title, Author, Artist, URL, etc.
|
||||
|
||||
#### `tag_box`
|
||||
### Text Box
|
||||
|
||||
- A box of [tags](tag.md) defined and added by the user.
|
||||
- Multiple tag boxes can be used to separate classifications of tags.
|
||||
- e.g: Content Tags, Meta Tags, etc.
|
||||
A long string of text displayed as a box of text.
|
||||
|
||||
#### `datetime` [WIP]
|
||||
- e.g: Description, Notes, etc.
|
||||
|
||||
- A date and time value.
|
||||
- e.g: Date Created, Date Modified, Date Taken, etc.
|
||||
### Datetime [WIP]
|
||||
|
||||
#### `checkbox` [WIP]
|
||||
A date and time value.
|
||||
|
||||
- A simple two-state checkbox.
|
||||
- Can be associated with a tag for quick organization.
|
||||
- e.g: Archive, Favorite, etc.
|
||||
|
||||
#### `collation` [obsolete]
|
||||
|
||||
- Previously used for associating files to be used in a [collation](../utilities/macro.md#create-collage), will be removed in favor of a more flexible feature in future updates.
|
||||
- e.g: Date Published, Date Taken, etc.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Library
|
||||
|
||||
The library is how TagStudio represents your chosen directory, with every file inside of it being displayed as an [entry](entry.md). You can have as many or few libraries as you wish, since each libraries' data is stored within a `.TagStudio` folder at its root.
|
||||
The library is how TagStudio represents your chosen directory, with every file inside being represented by a [file entry](entry.md). You can have as many or few libraries as you wish, since each libraries' data is stored within a `.TagStudio` folder at its root. From there the library save file itself is stored as `ts_library.sqlite`, with TagStudio versions 9.4 and below using a the legacy `ts_library.json` format.
|
||||
|
||||
Note that this means [tags](tag.md) you create only exist _per-library_.
|
||||
|
||||
93
docs/library/library_search.md
Normal file
93
docs/library/library_search.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Library Search
|
||||
|
||||
## Boolean Operators
|
||||
|
||||
TagStudio allows you to use common [boolean search](https://en.wikipedia.org/wiki/Full-text_search#Boolean_queries) operators when searching your library, along with [grouping](#grouping-and-nesting), [nesting](#grouping-and-nesting), and [character escaping](#escaping-characters). Note that you may need to use grouping in order to get the desired results you're looking for.
|
||||
|
||||
### AND
|
||||
|
||||
The `AND` operator will only return results that match **both** sides of the operator. `AND` is used implicitly when no boolean operators are given. To use the `AND` operator explicitly, simply type "and" (case insensitive) in-between items of your search.
|
||||
|
||||
> For example, searching for "Tag1 Tag2" will be treated the same as "Tag1 `AND` Tag2" and will only return results that contain both Tag1 and Tag2.
|
||||
|
||||
### OR
|
||||
|
||||
The `OR` operator will return results that match **either** the left or right side of the operator. To use the `OR` operator simply type "or" (case insensitive) in-between items of your search.
|
||||
|
||||
> For example, searching for "Tag1 `OR` Tag2" will return results that contain either "Tag1", "Tag2", or both.
|
||||
|
||||
### NOT
|
||||
|
||||
The `NOT` operator will returns results where the condition on the right is **false.** To use the `NOT` operator simply type "not" (case insensitive) in-between items of your search. You can also begin your search with `NOT` to only view results that do not contain the next term that follows.
|
||||
|
||||
> For example, searching for "Tag1 `NOT` Tag2" will only return results that contain "Tag1" while also not containing "Tag2".
|
||||
|
||||
### Grouping and Nesting
|
||||
|
||||
Searches can be grouped and nested by using parentheses to surround parts of your search query.
|
||||
|
||||
> For example, searching for "(Tag1 `OR` Tag2) `AND` Tag3" will return results any results that contain Tag3, plus one or the other (or both) of Tag1 and Tag2.
|
||||
|
||||
### Escaping Characters
|
||||
|
||||
Sometimes search queries have ambiguous characters and need to be "escaped". This is most common with tag names which contain spaces, or overlap with existing search keywords such as "[path:](#filename--filepath) of exile". To escape most search terms, surround the section of your search in plain quotes. Alternatively, spaces in tag names can be replaced by underscores.
|
||||
|
||||
#### Valid Escaped Tag Searches
|
||||
|
||||
- "Tag Name With Spaces"
|
||||
- Tag_Name_With_Spaces
|
||||
|
||||
#### Invalid Escaped Tag Searches
|
||||
|
||||
- Tag Name With Spaces
|
||||
- Reason: Ambiguity between a tag named "Tag Name With Spaces" and four individual tags called "Tag", "Name", "With", "Spaces".
|
||||
|
||||
## Tags
|
||||
|
||||
[Tag](#tags) search is the default mode of file entry search in TagStudio. No keyword prefix is required, however using `tag:` will also work. The tag search attempts to match tag [names](tag.md#name), [shorthands](tag.md#shorthand), [aliases](tag.md#aliases), as well as allows for tags to [substitute](tag.md#intuition-via-substitution) in for any of their [parent tags](tag.md#parent-tags).
|
||||
|
||||
You may also see the `tag_id:` prefix keyword show up with using the right-click "Search for Tag" option on tags. This is meant for internal use, and eventually will not be displayed or accessible to the user.
|
||||
|
||||
## Fields
|
||||
|
||||
_[Field](field.md) search is currently not in the program, however is coming in a future version._
|
||||
|
||||
## File Entry Search
|
||||
|
||||
### Filename + Filepath
|
||||
|
||||
Currently (v9.5.0-PR1) the filepath search uses [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>) syntax, meaning you'll likely have to wrap your filename or partial filepath inside asterisks for results to appear. This search is also currently case sensitive. Use the `path:` keyword prefix followed by the filename or path, with asterisks surrounding partial names.
|
||||
|
||||
#### Examples
|
||||
|
||||
Given a file "artwork/piece.jpg", these searches will return results with it:
|
||||
|
||||
- `path: artwork/piece.jpg` _(Note how no asterisks are required if the full path is given)_
|
||||
- `path: *piece.jpg*`
|
||||
- `path: *artwork*`
|
||||
- `path: *rtwor*`
|
||||
- `path: *ece.jpg*`
|
||||
- `path: *iec*`
|
||||
|
||||
And these (currently) won't:
|
||||
|
||||
- `path: piece.jpg`
|
||||
- `path: piece.jpg`
|
||||
- `path: artwork`
|
||||
- `path: rtwor`
|
||||
- `path: ece.jpg`
|
||||
- `path: iec`
|
||||
|
||||
## Special Searches
|
||||
|
||||
"Special" searches use the `special:` keyword prefix and give quick results for certain special search queries.
|
||||
|
||||
### Untagged
|
||||
|
||||
To see all your file entries which don't contain any tags, use the `special:untagged` search.
|
||||
|
||||
### Empty
|
||||
|
||||
**_NOTE:_** _Currently unavailable in v9.5.0-PR1_
|
||||
|
||||
To see all your file entries which don't contain any tags _and_ any fields, use the `special:empty` search.
|
||||
@@ -37,7 +37,11 @@ Create a new tag by accessing the "New Tag" option from the Edit menu or by pres
|
||||
- Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique.
|
||||
- For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)".
|
||||
- The **color** option lets you select an optional color palette to use for your tag.
|
||||
- The **"Is Cagegory"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
|
||||
- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
|
||||
|
||||
### Tag Manager
|
||||
|
||||
You can manage your library of tags from opening the "Tag Manager" panel from Edit -> "Tag Manager". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
|
||||
|
||||
## Editing Tags
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.5.0-pre2"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
|
||||
[tool.ruff]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
|
||||
line-length = 100
|
||||
@@ -10,28 +17,8 @@ line-length = 100
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"B",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"FBT003",
|
||||
"I",
|
||||
"N",
|
||||
"SIM",
|
||||
"T20",
|
||||
"UP",
|
||||
]
|
||||
ignore = [
|
||||
"D100",
|
||||
"D101",
|
||||
"D102",
|
||||
"D103",
|
||||
"D104",
|
||||
"D105",
|
||||
"D106",
|
||||
"D107",
|
||||
]
|
||||
select = ["B", "D", "E", "F", "FBT003", "I", "N", "SIM", "T20", "UP"]
|
||||
ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
|
||||
|
||||
[tool.mypy]
|
||||
strict_optional = false
|
||||
|
||||
@@ -78,7 +78,7 @@ app = BUNDLE(
|
||||
name='TagStudio.app',
|
||||
icon=icon,
|
||||
bundle_identifier='com.cyanvoxel.tagstudio',
|
||||
version='9.5.0',
|
||||
version='9.5.0-pr2',
|
||||
info_plist={
|
||||
'NSAppleScriptEnabled': False,
|
||||
'NSPrincipalClass': 'NSApplication',
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"status.library_version_found": "Found:",
|
||||
"status.library_version_mismatch": "Library Version Mismatch!",
|
||||
"status.results_found": "{count} Results Found ({time_span})",
|
||||
"status.results.invalid_syntax": "Invalid Search Syntax:",
|
||||
"status.results": "Results",
|
||||
"tag_manager.title": "Library Tags",
|
||||
"tag.add_to_search": "Add to Search",
|
||||
|
||||
@@ -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 1" # Usually "" or "Pre-Release"
|
||||
VERSION_BRANCH: str = "Pre-Release 2" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
|
||||
@@ -71,4 +71,4 @@ class LibraryPrefs(DefaultEnum):
|
||||
IS_EXCLUDE_LIST = True
|
||||
EXTENSION_LIST: list[str] = [".json", ".xmp", ".aae"]
|
||||
PAGE_SIZE: int = 500
|
||||
DB_VERSION: int = 6
|
||||
DB_VERSION: int = 7
|
||||
|
||||
@@ -2,11 +2,14 @@ import enum
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from src.core.query_lang import AST as Query # noqa: N811
|
||||
from src.core.query_lang import Constraint, ConstraintType, Parser
|
||||
|
||||
MAX_SQL_VARIABLES = 32766 # 32766 is the max sql bind parameter count as defined here: https://github.com/sqlite/sqlite/blob/master/src/sqliteLimit.h#L140
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TagColorEnum(enum.IntEnum):
|
||||
DEFAULT = 1
|
||||
|
||||
@@ -113,7 +113,7 @@ class _FieldID(Enum):
|
||||
AUTHOR = DefaultField(id=1, name="Author", type=FieldTypeEnum.TEXT_LINE)
|
||||
ARTIST = DefaultField(id=2, name="Artist", type=FieldTypeEnum.TEXT_LINE)
|
||||
URL = DefaultField(id=3, name="URL", type=FieldTypeEnum.TEXT_LINE)
|
||||
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_LINE)
|
||||
DESCRIPTION = DefaultField(id=4, name="Description", type=FieldTypeEnum.TEXT_BOX)
|
||||
NOTES = DefaultField(id=5, name="Notes", type=FieldTypeEnum.TEXT_BOX)
|
||||
COLLATION = DefaultField(id=9, name="Collation", type=FieldTypeEnum.TEXT_LINE)
|
||||
DATE = DefaultField(id=10, name="Date", type=FieldTypeEnum.DATETIME)
|
||||
|
||||
@@ -31,6 +31,7 @@ from sqlalchemy import (
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -70,6 +71,18 @@ from .visitors import SQLBoolExpressionBuilder
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS child_id
|
||||
UNION ALL
|
||||
SELECT tp.parent_id AS child_id
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.child_id = c.child_id
|
||||
)
|
||||
SELECT * FROM ChildTags;
|
||||
""") # noqa: E501
|
||||
|
||||
|
||||
def slugify(input_string: str) -> str:
|
||||
# Convert to lowercase and normalize unicode characters
|
||||
@@ -318,6 +331,7 @@ class Library:
|
||||
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
|
||||
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
|
||||
poolclass = None if self.storage_path == ":memory:" else NullPool
|
||||
db_version: int = 0
|
||||
|
||||
logger.info(
|
||||
"[Library] Opening SQLite Library",
|
||||
@@ -328,11 +342,13 @@ class Library:
|
||||
with Session(self.engine) as session:
|
||||
# dont check db version when creating new library
|
||||
if not is_new:
|
||||
db_version = session.scalar(
|
||||
db_result = session.scalar(
|
||||
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
|
||||
)
|
||||
if db_result:
|
||||
db_version = db_result.value # type: ignore
|
||||
|
||||
if not db_version or db_version.value != LibraryPrefs.DB_VERSION.default:
|
||||
if db_version < 6: # NOTE: DB_VERSION 6 is the first supported SQL DB version.
|
||||
mismatch_text = Translations.translate_formatted(
|
||||
"status.library_version_mismatch"
|
||||
)
|
||||
@@ -344,15 +360,14 @@ class Library:
|
||||
success=False,
|
||||
message=(
|
||||
f"{mismatch_text}\n"
|
||||
f"{found_text} v{0 if not db_version else db_version.value}, "
|
||||
f"{found_text} v{db_version}, "
|
||||
f"{expected_text} v{LibraryPrefs.DB_VERSION.default}"
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(f"[Library] DB_VERSION: {db_version}")
|
||||
make_tables(self.engine)
|
||||
|
||||
# TODO: Determine a good way of updating built-in data after updates.
|
||||
|
||||
# Add default tag color namespaces.
|
||||
if is_new:
|
||||
namespaces = default_color_groups.namespaces()
|
||||
@@ -421,14 +436,52 @@ class Library:
|
||||
)
|
||||
session.add(folder)
|
||||
session.expunge(folder)
|
||||
|
||||
session.commit()
|
||||
self.folder = folder
|
||||
|
||||
# Apply any post-SQL migration patches.
|
||||
if not is_new:
|
||||
# NOTE: DB_VERSION 6 was first used in v9.5.0-pr1
|
||||
if db_version == 6:
|
||||
self.apply_db6_patches(session)
|
||||
else:
|
||||
pass
|
||||
|
||||
# Update DB_VERSION
|
||||
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
|
||||
|
||||
# everything is fine, set the library path
|
||||
self.library_dir = library_dir
|
||||
return LibraryStatus(success=True, library_path=library_dir)
|
||||
|
||||
def apply_db6_patches(self, session: Session):
|
||||
"""Apply migration patches to a library with DB_VERSION 6.
|
||||
|
||||
DB_VERSION 6 was first used in v9.5.0-pr1.
|
||||
"""
|
||||
logger.info("[Library] Applying patches to DB_VERSION: 6 library...")
|
||||
with session:
|
||||
# Repair "Description" fields with a TEXT_LINE key instead of a TEXT_BOX key.
|
||||
desc_stmd = (
|
||||
update(ValueType)
|
||||
.where(ValueType.key == _FieldID.DESCRIPTION.name)
|
||||
.values(type=FieldTypeEnum.TEXT_BOX.name)
|
||||
)
|
||||
session.execute(desc_stmd)
|
||||
session.flush()
|
||||
|
||||
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
all_tag_ids: set[int] = {tag.id for tag in self.tags}
|
||||
disam_stmt = (
|
||||
update(Tag)
|
||||
.where(Tag.disambiguation_id.not_in(all_tag_ids))
|
||||
.values(disambiguation_id=None)
|
||||
)
|
||||
session.execute(disam_stmt)
|
||||
session.flush()
|
||||
|
||||
session.commit()
|
||||
|
||||
@property
|
||||
def default_fields(self) -> list[BaseField]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -640,10 +693,11 @@ class Library:
|
||||
return session.query(exists().where(Entry.path == path)).scalar()
|
||||
|
||||
def get_paths(self, glob: str | None = None) -> list[str]:
|
||||
path_strings: list[str] = []
|
||||
with Session(self.engine) as session:
|
||||
paths = session.scalars(select(Entry.path)).unique()
|
||||
path_strings = list(map(lambda x: x.as_posix(), paths))
|
||||
|
||||
path_strings: list[str] = list(map(lambda x: x.as_posix(), paths))
|
||||
return path_strings
|
||||
|
||||
def search_library(
|
||||
@@ -711,10 +765,7 @@ class Library:
|
||||
|
||||
return res
|
||||
|
||||
def search_tags(
|
||||
self,
|
||||
name: str | None,
|
||||
) -> list[Tag]:
|
||||
def search_tags(self, name: str | None) -> list[set[Tag]]:
|
||||
"""Return a list of Tag records matching the query."""
|
||||
tag_limit = 100
|
||||
|
||||
@@ -734,8 +785,23 @@ class Library:
|
||||
)
|
||||
)
|
||||
|
||||
tags = session.scalars(query)
|
||||
res = list(set(tags))
|
||||
direct_tags = set(session.scalars(query))
|
||||
ancestor_tag_ids: list[Tag] = []
|
||||
for tag in direct_tags:
|
||||
ancestor_tag_ids.extend(
|
||||
list(session.scalars(TAG_CHILDREN_QUERY, {"tag_id": tag.id}))
|
||||
)
|
||||
|
||||
ancestor_tags = session.scalars(
|
||||
select(Tag)
|
||||
.where(Tag.id.in_(ancestor_tag_ids))
|
||||
.options(selectinload(Tag.parent_tags), selectinload(Tag.aliases))
|
||||
)
|
||||
|
||||
res = [
|
||||
direct_tags,
|
||||
{at for at in ancestor_tags if at not in direct_tags},
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"searching tags",
|
||||
@@ -792,6 +858,14 @@ class Library:
|
||||
session.delete(child_tag)
|
||||
session.expunge(child_tag)
|
||||
|
||||
disam_stmt = (
|
||||
update(Tag)
|
||||
.where(Tag.disambiguation_id == tag.id)
|
||||
.values(disambiguation_id=None)
|
||||
)
|
||||
session.execute(disam_stmt)
|
||||
session.flush()
|
||||
|
||||
session.delete(tag)
|
||||
session.commit()
|
||||
session.expunge(tag)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import datetime as dt
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
|
||||
@@ -185,9 +185,9 @@ class Entry(Base):
|
||||
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
date_created: Mapped[dt.datetime | None]
|
||||
date_modified: Mapped[dt.datetime | None]
|
||||
date_added: Mapped[dt.datetime | None]
|
||||
date_created: Mapped[dt | None]
|
||||
date_modified: Mapped[dt | None]
|
||||
date_added: Mapped[dt | None]
|
||||
|
||||
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
|
||||
|
||||
@@ -222,9 +222,9 @@ class Entry(Base):
|
||||
folder: Folder,
|
||||
fields: list[BaseField],
|
||||
id: int | None = None,
|
||||
date_created: dt.datetime | None = None,
|
||||
date_modified: dt.datetime | None = None,
|
||||
date_added: dt.datetime | None = None,
|
||||
date_created: dt | None = None,
|
||||
date_modified: dt | None = None,
|
||||
date_added: dt | None = None,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
|
||||
@@ -23,7 +23,7 @@ else:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# TODO: Reevaluate after subtags -> parent tags name change
|
||||
CHILDREN_QUERY = text("""
|
||||
TAG_CHILDREN_ID_QUERY = text("""
|
||||
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS child_id
|
||||
@@ -151,7 +151,7 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
|
||||
return tag_ids
|
||||
outp = []
|
||||
for tag_id in tag_ids:
|
||||
outp.extend(list(session.scalars(CHILDREN_QUERY, {"tag_id": tag_id})))
|
||||
outp.extend(list(session.scalars(TAG_CHILDREN_ID_QUERY, {"tag_id": tag_id})))
|
||||
return outp
|
||||
|
||||
def __entry_has_all_tags(self, tag_ids: list[int]) -> ColumnElement[bool]:
|
||||
|
||||
@@ -93,22 +93,23 @@ class Tokenizer:
|
||||
|
||||
start = self.pos
|
||||
|
||||
while self.current_char not in self.NOT_IN_ULITERAL and self.current_char is not None:
|
||||
while self.current_char is not None:
|
||||
if self.current_char in self.NOT_IN_ULITERAL:
|
||||
if self.current_char == ":":
|
||||
if len(out) == 0:
|
||||
raise ParsingError(self.pos, self.pos)
|
||||
constraint_type = ConstraintType.from_string(out)
|
||||
if constraint_type is not None:
|
||||
self.__advance()
|
||||
return Token(TokenType.CONSTRAINTTYPE, constraint_type, start, self.pos)
|
||||
else:
|
||||
break
|
||||
|
||||
out += self.current_char
|
||||
self.__advance()
|
||||
|
||||
end = self.pos - 1
|
||||
|
||||
if self.current_char == ":":
|
||||
if len(out) == 0:
|
||||
raise ParsingError(self.pos, self.pos)
|
||||
self.__advance()
|
||||
constraint_type = ConstraintType.from_string(out)
|
||||
if constraint_type is None:
|
||||
raise ParsingError(start, end, f'Invalid ContraintType "{out}"')
|
||||
return Token(TokenType.CONSTRAINTTYPE, constraint_type, start, end)
|
||||
else:
|
||||
return Token(TokenType.ULITERAL, out, start, end)
|
||||
return Token(TokenType.ULITERAL, out, start, end)
|
||||
|
||||
def __quoted_string(self) -> Token:
|
||||
start = self.pos
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime as dt
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
@@ -42,7 +42,7 @@ class RefreshDirTracker:
|
||||
path=entry_path,
|
||||
folder=self.library.folder,
|
||||
fields=[],
|
||||
date_added=dt.datetime.now(),
|
||||
date_added=dt.now(),
|
||||
)
|
||||
for entry_path in self.files_not_in_library
|
||||
]
|
||||
|
||||
8
tagstudio/src/qt/helpers/escape_text.py
Normal file
8
tagstudio/src/qt/helpers/escape_text.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
def escape_text(text: str):
|
||||
"""Escapes characters that are problematic in Qt widgets."""
|
||||
return text.replace("&", "&&")
|
||||
@@ -386,6 +386,16 @@ class BuildTagPanel(PanelWidget):
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
|
||||
# Add Tag Widget
|
||||
tag_widget = TagWidget(
|
||||
tag,
|
||||
library=self.lib,
|
||||
has_edit=False,
|
||||
has_remove=True,
|
||||
)
|
||||
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
|
||||
row.addWidget(tag_widget)
|
||||
|
||||
# Add Disambiguation Tag Button
|
||||
disam_button = QRadioButton()
|
||||
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
|
||||
@@ -412,6 +422,15 @@ class BuildTagPanel(PanelWidget):
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.disam_button_group.addButton(disam_button)
|
||||
@@ -421,18 +440,7 @@ class BuildTagPanel(PanelWidget):
|
||||
disam_button.clicked.connect(lambda checked=False: self.toggle_disam_id(parent_id))
|
||||
row.addWidget(disam_button)
|
||||
|
||||
# Add Tag Widget
|
||||
tag_widget = TagWidget(
|
||||
tag,
|
||||
library=self.lib,
|
||||
has_edit=False,
|
||||
has_remove=True,
|
||||
)
|
||||
|
||||
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
|
||||
row.addWidget(tag_widget)
|
||||
|
||||
return disam_button, tag_widget.bg_button, container
|
||||
return tag_widget.bg_button, disam_button, container
|
||||
|
||||
def toggle_disam_id(self, disambiguation_id: int | None):
|
||||
if self.disambiguation_id == disambiguation_id:
|
||||
|
||||
@@ -7,8 +7,9 @@ import typing
|
||||
|
||||
import src.qt.modals.build_tag as build_tag
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QShowEvent
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -26,10 +27,6 @@ from src.qt.translations import Translations
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import (
|
||||
TagWidget,
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_primary_color,
|
||||
get_text_color,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -85,12 +82,7 @@ class TagSearchPanel(PanelWidget):
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
|
||||
def __build_row_item_widget(self, tag: Tag):
|
||||
container = QWidget()
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
|
||||
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)
|
||||
@@ -115,53 +107,9 @@ class TagSearchPanel(PanelWidget):
|
||||
# )
|
||||
# )
|
||||
|
||||
row.addWidget(tag_widget)
|
||||
|
||||
primary_color = get_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
primary_color
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else QColor(tag.color.secondary)
|
||||
)
|
||||
text_color: QColor
|
||||
if tag.color and tag.color.secondary:
|
||||
text_color = QColor(tag.color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
|
||||
if self.is_tag_chooser:
|
||||
add_button = QPushButton()
|
||||
add_button.setMinimumSize(22, 22)
|
||||
add_button.setMaximumSize(22, 22)
|
||||
add_button.setText("+")
|
||||
add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-bottom: 4px;"
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
f"{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
tag_id = tag.id
|
||||
add_button.clicked.connect(lambda: self.tag_chosen.emit(tag_id))
|
||||
row.addWidget(add_button)
|
||||
return container
|
||||
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."""
|
||||
@@ -187,7 +135,7 @@ class TagSearchPanel(PanelWidget):
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-style:dashed;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
@@ -197,6 +145,15 @@ class TagSearchPanel(PanelWidget):
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
create_button.clicked.connect(lambda: self.create_and_add_tag(query))
|
||||
@@ -211,11 +168,17 @@ class TagSearchPanel(PanelWidget):
|
||||
def on_tag_modal_saved():
|
||||
"""Callback for actions to perform when a new tag is confirmed created."""
|
||||
tag: Tag = self.build_tag_modal.build_tag()
|
||||
self.lib.add_tag(tag)
|
||||
self.lib.add_tag(
|
||||
tag,
|
||||
set(self.build_tag_modal.parent_ids),
|
||||
set(self.build_tag_modal.alias_names),
|
||||
set(self.build_tag_modal.alias_ids),
|
||||
)
|
||||
self.add_tag_modal.hide()
|
||||
|
||||
self.tag_chosen.emit(tag.id)
|
||||
self.search_field.setText("")
|
||||
self.search_field.setFocus()
|
||||
self.update_tags()
|
||||
|
||||
self.build_tag_modal: BuildTagPanel = build_tag.BuildTagPanel(self.lib)
|
||||
@@ -230,32 +193,44 @@ class TagSearchPanel(PanelWidget):
|
||||
|
||||
def update_tags(self, query: str | None = None):
|
||||
logger.info("[Tag Search Super Class] Updating Tags")
|
||||
|
||||
# TODO: Look at recycling rather than deleting and re-initializing
|
||||
while self.scroll_layout.count():
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
tag_results = self.lib.search_tags(name=query)
|
||||
if len(tag_results) > 0:
|
||||
results_1 = []
|
||||
results_2 = []
|
||||
for tag in tag_results:
|
||||
if tag.id in self.exclude:
|
||||
continue
|
||||
elif query and tag.name.lower().startswith(query.lower()):
|
||||
results_1.append(tag)
|
||||
else:
|
||||
results_2.append(tag)
|
||||
results_1.sort(key=lambda tag: self.lib.tag_display_name(tag.id))
|
||||
results_1.sort(key=lambda tag: len(self.lib.tag_display_name(tag.id)))
|
||||
results_2.sort(key=lambda tag: self.lib.tag_display_name(tag.id))
|
||||
self.first_tag_id = results_1[0].id if len(results_1) > 0 else tag_results[0].id
|
||||
for tag in results_1 + results_2:
|
||||
self.scroll_layout.addWidget(self.__build_row_item_widget(tag))
|
||||
else:
|
||||
# If query doesnt exist add create button
|
||||
|
||||
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}
|
||||
|
||||
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]
|
||||
priority_results: set[Tag] = set()
|
||||
all_results: list[Tag] = []
|
||||
|
||||
if query and query.strip():
|
||||
for tag in raw_results:
|
||||
if tag.name.lower().startswith(query_lower):
|
||||
priority_results.add(tag)
|
||||
|
||||
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 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
|
||||
|
||||
if query and query.strip():
|
||||
c = self.build_create_tag_button(query)
|
||||
self.scroll_layout.addWidget(c)
|
||||
self.search_field.setFocus()
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text:
|
||||
@@ -271,11 +246,22 @@ class TagSearchPanel(PanelWidget):
|
||||
self.parentWidget().hide()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa N802
|
||||
if not self.is_initialized:
|
||||
self.update_tags()
|
||||
self.is_initialized = True
|
||||
self.update_tags()
|
||||
self.search_field.setText("")
|
||||
self.search_field.setFocus()
|
||||
return super().showEvent(event)
|
||||
|
||||
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()
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.search_field.selectAll()
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
def remove_tag(self, tag: Tag):
|
||||
pass
|
||||
|
||||
|
||||
@@ -66,6 +66,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.query_lang.util import ParsingError
|
||||
from src.core.ts_core import TagStudioCore
|
||||
from src.core.utils.refresh_dir import RefreshDirTracker
|
||||
from src.core.utils.web import strip_web_protocol
|
||||
@@ -293,7 +294,9 @@ class QtDriver(DriverMixin, QObject):
|
||||
# Initialize the main window's tag search panel
|
||||
self.tag_search_panel = TagSearchPanel(self.lib, is_tag_chooser=True)
|
||||
self.add_tag_modal = PanelModal(
|
||||
self.tag_search_panel, Translations.translate_formatted("tag.add.plural")
|
||||
widget=self.tag_search_panel,
|
||||
title=Translations.translate_formatted("tag.add.plural"),
|
||||
window_title=Translations.translate_formatted("tag.add.plural"),
|
||||
)
|
||||
self.tag_search_panel.tag_chosen.connect(
|
||||
lambda t: (
|
||||
@@ -484,6 +487,13 @@ class QtDriver(DriverMixin, QObject):
|
||||
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(
|
||||
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)
|
||||
|
||||
# View Menu ============================================================
|
||||
@@ -659,24 +669,26 @@ class QtDriver(DriverMixin, QObject):
|
||||
# in a global dict for methods to access for different DPIs.
|
||||
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
|
||||
|
||||
def _filter_items():
|
||||
try:
|
||||
self.filter_items(
|
||||
FilterState.from_search_query(self.main_window.searchField.text())
|
||||
.with_sorting_mode(self.sorting_mode)
|
||||
.with_sorting_direction(self.sorting_direction)
|
||||
)
|
||||
except ParsingError as e:
|
||||
self.main_window.statusbar.showMessage(
|
||||
f"{Translations["status.results.invalid_syntax"]} "
|
||||
f"\"{self.main_window.searchField.text()}\""
|
||||
)
|
||||
logger.error("[QtDriver] Could not filter items", error=e)
|
||||
|
||||
# Search Button
|
||||
search_button: QPushButton = self.main_window.searchButton
|
||||
search_button.clicked.connect(
|
||||
lambda: self.filter_items(
|
||||
FilterState.from_search_query(self.main_window.searchField.text())
|
||||
.with_sorting_mode(self.sorting_mode)
|
||||
.with_sorting_direction(self.sorting_direction)
|
||||
)
|
||||
)
|
||||
search_button.clicked.connect(_filter_items)
|
||||
# Search Field
|
||||
search_field: QLineEdit = self.main_window.searchField
|
||||
search_field.returnPressed.connect(
|
||||
lambda: self.filter_items(
|
||||
FilterState.from_search_query(self.main_window.searchField.text())
|
||||
.with_sorting_mode(self.sorting_mode)
|
||||
.with_sorting_direction(self.sorting_direction)
|
||||
)
|
||||
)
|
||||
search_field.returnPressed.connect(_filter_items)
|
||||
# Sorting Dropdowns
|
||||
sort_mode_dropdown: QComboBox = self.main_window.sorting_mode_combobox
|
||||
for sort_mode in SortingModeEnum:
|
||||
|
||||
@@ -70,7 +70,7 @@ class FieldContainer(QWidget):
|
||||
|
||||
self.title_container = QWidget()
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.title_layout.setObjectName("fieldLayout")
|
||||
self.title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.title_layout.setSpacing(0)
|
||||
@@ -78,6 +78,7 @@ class FieldContainer(QWidget):
|
||||
|
||||
self.title_widget = QLabel()
|
||||
self.title_widget.setMinimumHeight(button_size)
|
||||
self.title_widget.setMinimumWidth(200)
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setText(title)
|
||||
|
||||
@@ -367,29 +367,35 @@ class JsonMigrationModal(QObject):
|
||||
pb.setCancelButton(None)
|
||||
self.body_wrapper_01.layout().addWidget(pb)
|
||||
|
||||
iterator = FunctionIterator(self.migration_iterator)
|
||||
iterator.value.connect(
|
||||
lambda x: (
|
||||
pb.setLabelText(f"<h4>{x}</h4>"),
|
||||
self.update_sql_value_ui(show_msg_box=False)
|
||||
if x == Translations["json_migration.checking_for_parity"]
|
||||
else (),
|
||||
self.update_parity_ui()
|
||||
if x == Translations["json_migration.checking_for_parity"]
|
||||
else (),
|
||||
try:
|
||||
iterator = FunctionIterator(self.migration_iterator)
|
||||
iterator.value.connect(
|
||||
lambda x: (
|
||||
pb.setLabelText(f"<h4>{x}</h4>"),
|
||||
self.update_sql_value_ui(show_msg_box=False)
|
||||
if x == Translations["json_migration.checking_for_parity"]
|
||||
else (),
|
||||
self.update_parity_ui()
|
||||
if x == Translations["json_migration.checking_for_parity"]
|
||||
else (),
|
||||
)
|
||||
)
|
||||
)
|
||||
r = CustomRunnable(iterator.run)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
self.update_sql_value_ui(show_msg_box=not skip_ui),
|
||||
pb.setMinimum(1),
|
||||
pb.setValue(1),
|
||||
# Enable the finish button
|
||||
self.stack[1].buttons[4].setDisabled(False), # type: ignore
|
||||
r = CustomRunnable(iterator.run)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
self.update_sql_value_ui(show_msg_box=not skip_ui),
|
||||
pb.setMinimum(1),
|
||||
pb.setValue(1),
|
||||
# Enable the finish button
|
||||
self.stack[1].buttons[4].setDisabled(False), # type: ignore
|
||||
)
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
except Exception as e:
|
||||
logger.error("[MigrationModal][Iterator] Error:", error=e)
|
||||
pb.setLabelText(f"<h4>{type(e).__name__}</h4>")
|
||||
pb.setMinimum(1)
|
||||
pb.setValue(1)
|
||||
|
||||
def migration_iterator(self):
|
||||
"""Iterate over the library migration process."""
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
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
|
||||
@@ -125,3 +126,11 @@ class PanelWidget(QWidget):
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
logging.warning(f"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()
|
||||
else: # Other key presses
|
||||
pass
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
from src.core.library import Tag
|
||||
from src.core.library.alchemy.enums import TagColorEnum
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.helpers.escape_text import escape_text
|
||||
from src.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -127,9 +128,9 @@ class TagWidget(QWidget):
|
||||
self.bg_button = QPushButton(self)
|
||||
self.bg_button.setFlat(True)
|
||||
if self.lib:
|
||||
self.bg_button.setText(self.lib.tag_display_name(tag.id))
|
||||
self.bg_button.setText(escape_text(self.lib.tag_display_name(tag.id)))
|
||||
else:
|
||||
self.bg_button.setText(tag.name)
|
||||
self.bg_button.setText(escape_text(tag.name))
|
||||
if has_edit:
|
||||
edit_action = QAction(self)
|
||||
edit_action.setText(Translations.translate_formatted("generic.edit"))
|
||||
@@ -150,10 +151,10 @@ class TagWidget(QWidget):
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
self.inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self.inner_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.bg_button.setLayout(self.inner_layout)
|
||||
self.bg_button.setMinimumSize(22, 22)
|
||||
self.bg_button.setMinimumSize(44, 22)
|
||||
|
||||
primary_color = get_primary_color(tag)
|
||||
border_color = (
|
||||
@@ -189,6 +190,15 @@ class TagWidget(QWidget):
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.bg_button.setMinimumHeight(22)
|
||||
self.bg_button.setMaximumHeight(22)
|
||||
@@ -201,16 +211,34 @@ class TagWidget(QWidget):
|
||||
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: 3px;"
|
||||
f"border-width:0;"
|
||||
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(18, 18)
|
||||
self.remove_button.setMaximumSize(18, 18)
|
||||
self.remove_button.setMinimumSize(22, 22)
|
||||
self.remove_button.setMaximumSize(22, 22)
|
||||
self.remove_button.clicked.connect(self.on_remove.emit)
|
||||
|
||||
if has_remove:
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_tag_search(library):
|
||||
assert library.search_tags(tag.name.lower())
|
||||
assert library.search_tags(tag.name.upper())
|
||||
assert library.search_tags(tag.name[2:-2])
|
||||
assert not library.search_tags(tag.name * 2)
|
||||
assert library.search_tags(tag.name * 2) == [set(), set()]
|
||||
|
||||
|
||||
def test_get_entry(library: Library, entry_min):
|
||||
|
||||
Reference in New Issue
Block a user