Compare commits

...

19 Commits

Author SHA1 Message Date
Travis Abendshien 4576e69cba fix(tests): don't test for case sensitivity on windows 2026-09-15 22:08:47 -07:00
Travis Abendshien 9319944863 perf: skip NFD normalization for ASCII paths 2026-09-15 15:50:25 -07:00
Travis Abendshien 8e0b16a6d2 perf: increase sync batch sizes 2026-09-15 15:50:25 -07:00
Travis Abendshien 693248a2da perf: don't check is_dir() in ripgrep with --files 2026-09-15 15:50:25 -07:00
Travis Abendshien cba32801da fix: fix whitespace stripping inconsistencies in wcmatch 2026-09-15 15:50:25 -07:00
Travis Abendshien ce7af58562 fix: fix wcmatch matching '*' to '/' 2026-09-15 15:50:25 -07:00
Travis Abendshien 874117dd14 tests: add ignore matching tests 2026-09-15 15:50:25 -07:00
Travis Abendshien 751c431f24 fix: fix wcmatch not matching root directories, fix '**' to match 'zero or more' 2026-09-15 15:50:25 -07:00
Travis Abendshien ec094a8f19 fix: block cyclical symlink traversal in internal scanner 2026-09-15 15:50:25 -07:00
Travis Abendshien 5f9555a455 fix: have ripgrep ignore .gitignore files it finds 2026-09-15 15:50:25 -07:00
Travis Abendshien 860dd56cf0 fix: use correct rg path in actual ripgrep process call 2026-09-15 15:50:25 -07:00
Travis Abendshien 74134476f3 refactor(ui): make 'fix unlined entries' panel refresh button use sync banner 2026-09-15 15:50:25 -07:00
Travis Abendshien abb0f367d7 refactor: remove legacy 'search and relink' system, fold into auto-relink 2026-09-15 15:50:25 -07:00
Travis Abendshien 27281bfb9a fix: fix edge cases and rework tests based on docs case table 2026-09-15 15:50:25 -07:00
Travis Abendshien 9ae29cb027 fix(ui): fix banner not always updating label position 2026-09-15 15:50:25 -07:00
Travis Abendshien af0ceb68d9 docs: update library sync documentation 2026-09-15 15:50:25 -07:00
Travis Abendshien 4ebcfc70bb fix: don't lint st_birthtime 2026-09-15 15:50:25 -07:00
Travis Abendshien cc99c409de refactor!: rework and expand refresh (sync) system 2026-09-15 15:50:23 -07:00
Travis Abendshien 364409f073 feat: store and update file created and modified dates 2026-09-15 14:26:23 -07:00
66 changed files with 3159 additions and 822 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+7 -8
View File
@@ -8,12 +8,6 @@ icon: material/file-document-remove
# :material-file-document-remove: Ignoring Files & Directories
<!-- prettier-ignore -->
!!! warning "Legacy File Extension Ignoring"
TagStudio versions prior to v9.5.4 use a different, more limited method to exclude or include file extensions from your library and subsequent searches. Opening a pre-exiting library in v9.5.4 or later will non-destructively convert this to the newer, more extensive `.ts_ignore` format.
If you're still running an older version of TagStudio in the meantime, you can access the legacy system by going to "Edit -> Manage File Extensions" in the menubar.
TagStudio offers the ability to ignore specific files and directories via a `.ts_ignore` file located inside your [library's](libraries.md) `.TagStudio` folder. This file is designed to use very similar [glob](<https://en.wikipedia.org/wiki/Glob_(programming)>)-style pattern matching as the [`.gitignore`](https://git-scm.com/docs/gitignore) file used by Git™[^1]. It can be edited within TagStudio or opened to edit with an external program by going to the "Edit -> Ignore Files" option in the menubar.
This file is only referenced when scanning directories for new files to add to your library, and does not apply to files that have already been added to your library.
@@ -131,14 +125,15 @@ The forward slash "`/`" is used as the directory separator. Separators may occur
A `!` prefix before a pattern negates the pattern, allowing any files matched matched by previous patterns to be un-matched.
- Any matching file excluded by a previous pattern will become included again.
- **It is not possible to re-include a file if a parent directory of that file is excluded.**
- **An excluded parent directory can not have files within it reincluded!** (e.g. `Photos/`)
- Alternatively, if one or more files within a directory is excluded (e.g. `!Photos/*.jpg`) then files within can be reincluded from there (e.g. `Photos/a.jpg`).
<!-- prettier-ignore-start -->
=== "Example negation"
```toml
# All .jpg files will be ignored, except any located in the 'Photos' folder.
*.jpg
Photos/!*.jpg
!Photos/*.jpg
```
=== "Escape a ! Symbol"
```toml
@@ -147,6 +142,10 @@ A `!` prefix before a pattern negates the pattern, allowing any files matched ma
```
<!-- prettier-ignore-end -->
<!-- prettier-ignore -->
!!! bug "Directory Exclusion Negation"
TagStudio attempts to match the behavior of a `.gitignore` file 1:1, however if you don't have `ripgrep` installed on your system and TagStudio falls back to its internal pattern matcher, excluded directories can be overwritten by further negations, unlike `.gitignore` behavior. Be wary that this is **not officially supported**, and this behavior may be removed at any time.
---
### Wildcards
+59 -4
View File
@@ -33,13 +33,68 @@ To create or open a [library](libraries.md), go to **File -> Open/Create Library
!!! info "Legacy Library Migration"
If you open a library created with TagStudio **v9.4.2 or earlier** in **[v9.5.0](changelog.md#950-march-3rd-2025) or later**, you'll be walked through a migration process that converts the old `ts_library.json` save file to the new `ts_library.sqlite` format. The original JSON file is preserved and can be easily deleted from the **View -> Library Information** panel once you're satisfied with the migration.
## :material-database-refresh: Refreshing Directories
## :material-database-sync: Library Syncing
TagStudio automatically scans for new or updated files when opening a library by default. This behavior can be toggled in the settings if your library is very large and/or located on a slow drive.
A TagStudio library gets synced with the files found in your chosen content folders and certain metadata attributes (e.g. stats) found on those files. This is a **non-destructive, read-only** process and none of your content files are moved, modified, or deleted. Syncing is indicated by a temporary progress bar, and you can continue to use TagStudio normally while syncing occurs.
![Settings -> Automatically Load New Files](assets/settings_refresh_library_on_open.png)
Syncing automatically occurs when you open a library by default, and you can manually sync a library at any time by going to **File -> Sync Library** in the menubar to by pressing <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS). If you do not wish for your library to be synced when opened, you can disable this behavior in the settings.
To manually refresh your library at any time, use **File -> Refresh Directories** from the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
<figure markdown="span">
![Settings -> Sync Library on Open](assets/settings_refresh_library_on_open.png)
<figcaption>
Settings -> Sync Library on Open
</figcaption>
</figure>
### :material-link-variant: Automatic Relinking
Unlinked entries are file entries in your TagStudio library that have become "unlinked" from their original file on disk, likely as a result of the original file being renamed, moved, or deleted. TagStudio attempts to automatically relink any of these entries as a part of the syncing process, but there are some scenarios where automatic relinking is not possible or too ambiguous and requires a manual review. Below is a complete table of every scenario in which file entries can become unlinked, and whether or not TagStudio can auto-relink them:
| Case | File Moved? | File Renamed? | File Modified? | Unlinked Entries | Matched Files | Auto-Relink |
| -------: | :---------: | :-----------: | :------------: | :--------------: | :-----------: | :---------------------------------: |
| **\#0** | _No_ | _No_ | **Yes** | 0 | — | :material-minus-circle:{.lg .gray} |
| **\#1** | — | — | — | 1 | 0 | :material-close-circle:{.lg .red} |
| **\#2** | **Yes** | **Yes** | **Yes** | 1 | 0 | :material-close-circle:{.lg .red} |
| **\#3** | **Yes** | **Yes** | _No_ | 1 | 1 | :material-check-circle:{.lg .green} |
| **\#4** | **Yes** | **Yes** | _No_ | 1 | 2+ | :material-close-circle:{.lg .red} |
| **\#5** | **Yes** | **Yes** | _No_ | 2+ | Any | :material-close-circle:{.lg .red} |
| **\#6** | **Yes** | _No_ | **Yes** | 1 | 1 | :material-check-circle:{.lg .green} |
| **\#7** | **Yes** | _No_ | _No_ | 1 | 1 | :material-check-circle:{.lg .green} |
| **\#8** | **Yes** | _No_ | _No_ | 1 | 2+ | :material-close-circle:{.lg .red} |
| **\#9** | **Yes** | _No_ | _No_ | 2+ | Any | :material-close-circle:{.lg .red} |
| **\#10** | _No_ | **Yes** | **Yes** | 1 | 0 | :material-close-circle:{.lg .red} |
| **\#11** | _No_ | **Yes** | _No_ | 1 | 1 | :material-check-circle:{.lg .green} |
| **\#12** | _No_ | **Yes** | _No_ | 1 | 2+ | :material-close-circle:{.lg .red} |
| **\#13** | _No_ | **Yes** | _No_ | 2+ | Any | :material-close-circle:{.lg .red} |
#### Explanations
- **Case \#0**: _Modifying file content alone does not create unlinked entries._
- **Case \#1**: If the original file was deleted, no matches will be found. TagStudio leaves the decision to delete entries up to the user.
- **Case \#2**: If the original file bears no similarities to the unlinked entry anymore, it is indistinguishable from a deleted or new file.
- **Case \#3**: The file has been moved and renamed with a high degree of confidence.
- **Case \#4**: If more than one file is matched with the same stats, the case is too ambiguous.
- **Case \#5**: If two or more entries share the same stats, it's not clear which entry a matched file belongs to.
- **Case \#6**: The file has been moved and modified, but since no other file shares its filename, it is assumed to be the same file with a decent degree of confidence.
- **Case \#7**: The original file has been moved with a high degree of confidence.
- **Case \#8**: If multiple copies of the same moved file are matched in different locations, the case is ambiguous.
- **Case \#9**: _Similar to **\#5**._ If two or more entries share the same filename and stats, it's not clear which entry a matched file belongs to.
- **Case \#10**: _Same as **\#2**._
- **Case \#11**: The file has been renamed with a high degree of confidence.
- **Case \#12**: _Same as **\#4**._
- **Case \#13**: _Same as **\#5**._
Every numbered case above assumes the entry has saved file metadata attributes to help match against (added in **v9.7**), in which case the **file modification date** combined with the **file size** is used as a soft file signature to aid in scenarios such as renames or moves. If no file metadata is stored with the file entry, or if this soft file signature doesn't lead to a confident match, TagStudio falls back to matching by filename alone: a single file found with that name is automatically relinked, while zero or multiple filename matches leave the entry unlinked for manual review.
<!-- prettier-ignore -->
!!! warning
There's currently no way to manually specify which remaining unlinked entries should be linked with which files, only to delete the entries from the library. Manual relinking is a high priority feature for future releases.
<!-- prettier-ignore -->
!!! warning "Switching from a Case-Sensitive to Case-Insensitive Filesystem (i.e. Windows)"
If you switch from using TagStudio on a computer with a case-sensitive filesystem to one with a *case-insensitive* one, TagStudio will treat any entries added up to this point with the same filepath + name under case-insensitivity as duplicate entries and merge them. The automatic relinking process will also take case-insensitivity into account when relinking entries.
Currently, TagStudio only uses this case-insensitivity mode when running on Windows. Future versions will be more precise about this distinction, with the aim of determining the case sensitivity on a per-drive basis.
## :material-database-cog: Library Information Panel
+11 -11
View File
@@ -96,7 +96,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
- [x] Delete Old Backups **[[v9.5.4](changelog.md#954-september-1st-2025)]**
- [x] Delete Legacy JSON File **[[v9.5.4](changelog.md#954-september-1st-2025)]**
- [x] Translations
- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Improved Tag Autocomplete :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Tags appear as widgets in search bar _(similar to new tag search/create bar)_ :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [x] Unified Media Player
@@ -136,7 +136,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
- [x] Theme
- [x] Thumbnail Generation **[[v9.5.4](changelog.md#954-september-1st-2025)]**
- [x] Configurable Page Size
- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Stored in `.TagStudio` folder :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Toggle File Extension Label :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Toggle Duration Label :material-chevron-double-up:{ .priority-med title="Medium Priority" }
@@ -149,13 +149,13 @@ A detailed specification written for the TagStudio tag and/or library format. In
- [x] Per-Library Tags
- [ ] Global Tags :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.8.x]**
- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Ability to store TagStudio data folder separate from library content folder(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
- [ ] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Detect Deletions :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Ability to store TagStudio data folder separate from library content folder(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [x] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.0]**
- [x] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [x] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Some way to safely handle unlinked entries presumed to be from deleted files automatically (deleted after X days?) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [x] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Background File Scanning :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [x] Thumbnail Caching **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
- [ ] Audio Waveform Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
@@ -166,7 +166,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
File or file-like [entries](entries.md) stored in the library.
- [x] File Entries **[v1.0.0]**
- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.6.x]**
- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.8.x]**
- [x] Fields
- [x] Text Lines
- [x] Text Boxes
@@ -267,7 +267,7 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
Sharable TagStudio library data in the form of data packs (tags, colors, etc.) or other formats.
Packs are intended as an easy way to import and export specific data between libraries and users, while export-only formats are intended to be imported by other programs.
- [ ] Color Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [ ] Color Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Importable
- [ ] Exportable
- [x] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" }
+1
View File
@@ -80,6 +80,7 @@ img {
th,
td {
padding: 0.5em 1em 0.5em 1em !important;
min-width: 0 !important;
}
hr {
+6 -16
View File
@@ -12,9 +12,13 @@ icon: material/mouse
To create or open a [library](libraries.md), go to **File -> Open/Create Library** in the menu bar or use <kbd>Ctrl</kbd>+<kbd>O</kbd> (<kbd>⌘ Command </kbd>+<kbd>O</kbd> on macOS) and chose a folder with file contents you'd like to use as a TagStudio library. If a `.TagStudio` folder doesn't already exist inside the directory, TagStudio will create one and automatically scan the folder for files to include. Otherwise, the pre-existing library is opened.
### :material-database-refresh: Refreshing Directories
### :material-database-sync: Library Syncing
TagStudio automatically scans for new or updated files when opening a library by default. Manually refresh by going to **File -> Refresh Directories** in the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
A TagStudio library gets synced with the files found in your content folders. This is a **non-destructive, read-only** process and none of your files are moved, modified, or deleted. Syncing is indicated by a temporary progress bar, and you can continue to use TagStudio normally while syncing occurs.
### :material-link-variant: Automatic Relinking
Files that become moved, renamed, or modified will try to be automatically relinked during syncing. If a file cannot be found or a match cannot be safely made, it will stay unlinked until the entries are manually deleted under "Fix Unlinked Entries". _Manual relinking is a high priority feature for future versions._
<!-- prettier-ignore -->
!!! abstract "TagStudio Libraries"
@@ -115,20 +119,6 @@ Creating and adding fields to entries is extremely similar to how [tagging](#tag
---
## Relinking Moved Files
Inevitably some of the files inside your library will be renamed, moved, or deleted. If a file has been renamed or moved, TagStudio will display the thumbnail as a red broken chain link. To relink moved files or delete these entries, select the "Manage Unlinked Entries" option under the Tools menu. Click the "Refresh" button to scan your library for unlinked entries. Once complete, you can attempt to "Search & Relink" any unlinked file entries to their respective files, or "Delete Unlinked Entries" in the event the original files have been deleted and you no longer wish to keep their entries inside your library.
<!-- prettier-ignore -->
!!! warning
There is currently no method to relink entries to files that have been renamed - only moved or deleted. This is a high priority for future releases.
<!-- prettier-ignore -->
!!! warning
If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are high priorities for future versions.
---
## Launch Arguments
There are a handful of launch arguments you can pass to TagStudio via the command line or a desktop shortcut.
@@ -14,7 +14,7 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 400
DB_VERSION: int = 500
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
+3 -1
View File
@@ -9,6 +9,8 @@ import structlog
from sqlalchemy import Dialect, String, TypeDecorator
from sqlalchemy.orm import DeclarativeBase
from tagstudio.core.utils.normalization import norm_path
logger = structlog.getLogger(__name__)
@@ -19,7 +21,7 @@ class PathType(TypeDecorator):
@override
def process_bind_param(self, value: Path | None, dialect: Dialect):
if value is not None:
return Path(value).as_posix()
return norm_path(Path(value), case_sensitive=True).as_posix()
return None
@override
@@ -72,7 +72,10 @@ class ItemType(enum.Enum):
class SortingModeEnum(enum.Enum):
DATE_ADDED = "file.date_added"
DATE_CREATED = "file.date_created"
DATE_MODIFIED = "file.date_modified"
FILE_NAME = "generic.filename"
FILE_SIZE = "file.size"
PATH = "file.path"
RANDOM = "sorting.mode.random"
+179 -10
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, NamedTuple
import structlog
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
@@ -28,7 +28,6 @@ from sqlalchemy import (
create_engine,
delete,
desc,
exists,
func,
inspect,
or_,
@@ -94,6 +93,9 @@ from tagstudio.core.library.alchemy.models import (
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.filesystem import is_fs_case_sensitive
from tagstudio.core.utils.normalization import norm_path
from tagstudio.core.utils.stat import get_date_created, get_date_modified, get_file_size
from tagstudio.core.utils.types import unwrap
if TYPE_CHECKING:
@@ -203,25 +205,37 @@ class LibraryStatus:
json_migration_req: bool = False
class FileStat(NamedTuple):
"""Cached stat() fields. Matches the ones stored in file entries."""
date_created: float | None
date_modified: float | None
file_size: int | None
class Library:
"""Class for the Library object, and all CRUD operations made upon it."""
library_dir: Path | None = None
engine: Engine | None = None
included_files: set[Path] = set()
path_cache: dict[Path, int] | None = None
duplicate_path_entry_ids: list[int] | None = None
def __init__(self) -> None:
self.dupe_entries_count: int = -1 # NOTE: For internal management.
self.dupe_files_count: int = -1
self.ignored_entries_count: int = -1
self.unlinked_entries_count: int = -1
# TODO: Make this dependant on the filesystem(s) of the library root directories.
self.is_fs_case_sensitive = is_fs_case_sensitive()
def close(self):
if self.engine:
self.engine.dispose()
self.library_dir = None
self.folder = None
self.included_files = set()
self.path_cache = None
self.duplicate_path_entry_ids = None
self.dupe_entries_count = -1
self.dupe_files_count = -1
@@ -649,6 +663,84 @@ class Library:
make_transient(entry)
return entry
def refresh_entries_stats(self, entries: list[tuple[int, Path]]) -> int:
"""Check and update os.stat() metadata for multiple file entries in bulk.
Only file entries that have differing stat data will be updated.
Args:
entries (list[tuple[int, Path]]): A list of (ID, Path) tuples to check.
Returns:
int: The number of entries that were updated.
"""
if not entries:
return 0
library_dir = unwrap(self.library_dir)
entry_ids = [entry_id for entry_id, _ in entries]
stored: dict[int, FileStat] = {}
with Session(self.engine) as session:
for sub_list in [
entry_ids[i : i + MAX_SQL_VARIABLES]
for i in range(0, len(entry_ids), MAX_SQL_VARIABLES)
]:
stmt = select(
Entry.id, Entry.date_created, Entry.date_modified, Entry.file_size
).where(Entry.id.in_(sub_list))
for row in session.execute(stmt):
stored[row.id] = FileStat(row.date_created, row.date_modified, row.file_size)
updates: dict[int, FileStat] = {}
for entry_id, path in entries:
stored_stat = stored.get(entry_id, FileStat(None, None, None))
full_path = library_dir / path
try:
file_stat = full_path.stat()
except OSError as e:
logger.error(
"[Library] Could not stat file while refreshing entry metadata",
path=full_path,
error=e,
)
continue
current_stat = FileStat(
get_date_created(file_stat),
get_date_modified(file_stat),
get_file_size(file_stat),
)
if stored_stat == current_stat:
continue
logger.info(
"[Library] Entry stat data changed",
path=full_path,
date_created=(stored_stat.date_created, current_stat.date_created),
date_modified=(stored_stat.date_modified, current_stat.date_modified),
file_size=(stored_stat.file_size, current_stat.file_size),
)
updates[entry_id] = current_stat
if not updates:
return 0
with Session(self.engine) as session:
session.execute(
update(Entry),
[
{"id": entry_id, **entry_stat._asdict()}
for entry_id, entry_stat in updates.items()
],
)
session.commit()
logger.info(f"[Library] Refreshed stat data for {len(updates)} of {len(entries)} entries")
return len(updates)
def get_tag_entries(
self, tag_ids: Iterable[int], entry_ids: Iterable[int]
) -> dict[int, set[int]]:
@@ -727,8 +819,44 @@ class Library:
full_ts_path.mkdir(parents=True, exist_ok=True)
return False
def _path_cache_key(self, path: Path) -> Path:
return norm_path(path, case_sensitive=self.is_fs_case_sensitive)
def _cache_add_path(self, entry_id: int, path: Path) -> None:
"""Keep the path cache consistent with a newly-added or relinked entry."""
if self.path_cache is None:
return
key = self._path_cache_key(path)
if key in self.path_cache:
displaced_id = self.path_cache[key]
logger.warning(
"[Library] Duplicate path discovered in path cache while normalizing path, "
"marking displaced entry as unlinked.",
path=path,
displaced_entry_id=displaced_id,
entry_id=entry_id,
)
if self.duplicate_path_entry_ids is None:
self.duplicate_path_entry_ids = []
self.duplicate_path_entry_ids.append(displaced_id)
self.path_cache[key] = entry_id
def _cache_remove_entries(self, entry_ids: Iterable[int]) -> None:
"""Keep the path cache consistent with removed entry ids."""
removed = set(entry_ids)
if not removed:
return
if self.path_cache is not None:
stale_keys = [key for key, eid in self.path_cache.items() if eid in removed]
for key in stale_keys:
del self.path_cache[key]
if self.duplicate_path_entry_ids:
self.duplicate_path_entry_ids = [
eid for eid in self.duplicate_path_entry_ids if eid not in removed
]
def add_entries(self, items: list[Entry]) -> list[int]:
"""Add multiple Entry records to the Library."""
"""Add multiple entries to the Library."""
assert items
with Session(self.engine) as session:
@@ -745,6 +873,9 @@ class Library:
new_ids = [item.id for item in items]
session.expunge_all()
for entry_id, item in zip(new_ids, items, strict=True):
self._cache_add_path(entry_id, item.path)
return new_ids
def remove_entries(self, entry_ids: list[int]) -> None:
@@ -756,11 +887,39 @@ class Library:
]:
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
session.commit()
self._cache_remove_entries(entry_ids)
def has_entry_with_path(self, path: Path) -> bool:
"""Check if an entry with this path is in the library."""
def get_entry_id_from_path(self, path: Path) -> int:
"""Attempt to return an Entry ID given a filepath, else return -1."""
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()
return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1
def all_paths_with_ids(self) -> dict[int, Path]:
"""Bulk fetch every Entry's (id, path). Only used to init the path cache."""
with Session(self.engine) as session:
rows = session.execute(select(Entry.id, Entry.path)).all()
return {row.id: row.path for row in rows}
def get_or_build_path_cache(self) -> dict[Path, int]:
"""Return the dict cache of normalized paths -> entry IDs."""
if self.path_cache is None:
cache: dict[Path, int] = {}
duplicates: list[int] = []
for entry_id, path in self.all_paths_with_ids().items():
key = self._path_cache_key(path)
if key in cache:
logger.warning(
"[Library] Duplicate normalized path created while building cache, "
"marking the displaced entry as unlinked.",
path=path,
displaced_entry_id=cache[key],
entry_id=entry_id,
)
duplicates.append(cache[key])
cache[key] = entry_id
self.path_cache = cache
self.duplicate_path_entry_ids = duplicates
return self.path_cache
def get_paths(self, limit: int = -1) -> list[str]:
path_strings: list[str] = []
@@ -779,7 +938,8 @@ class Library:
) -> SearchResult:
"""Filter library by search query.
:return: number of entries matching the query and one page of results.
Returns:
SearchResult: number of entries matching the query and one page of results.
"""
assert isinstance(search, BrowsingState)
assert self.library_dir
@@ -817,8 +977,14 @@ class Library:
match search.sorting_mode:
case SortingModeEnum.DATE_ADDED:
sort_on = Entry.id
case SortingModeEnum.DATE_CREATED:
sort_on = Entry.date_created
case SortingModeEnum.DATE_MODIFIED:
sort_on = Entry.date_modified
case SortingModeEnum.FILE_NAME:
sort_on = func.lower(Entry.filename)
case SortingModeEnum.FILE_SIZE:
sort_on = Entry.file_size
case SortingModeEnum.PATH:
sort_on = func.lower(Entry.path)
case SortingModeEnum.RANDOM:
@@ -1078,7 +1244,7 @@ class Library:
Returns True if the action succeeded and False if the path already exists.
"""
if self.has_entry_with_path(path):
if self.get_entry_id_from_path(path) >= 0:
return False
if isinstance(entry_id, Entry):
entry_id = entry_id.id
@@ -1096,6 +1262,9 @@ class Library:
session.execute(update_stmt)
session.commit()
self._cache_remove_entries([entry_id])
self._cache_add_path(entry_id, path)
return True
def remove_tag(self, tag_id: int) -> bool:
@@ -20,6 +20,7 @@ from tagstudio.core.library.alchemy.constants import (
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP
from tagstudio.core.library.alchemy.utils import list_tables
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.utils.normalization import norm_path
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
@@ -109,6 +110,7 @@ class DBMigrations:
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
MigrationTo400, # changes: add category_exclusions
MigrationTo500, # changes: entries
]
for migration in migrations:
if self.loaded_db_version < migration.version and (
@@ -606,3 +608,52 @@ class MigrationTo400(DBMigration):
PRIMARY KEY (tag_id, category_id)
)
""")
class MigrationTo500(DBMigration):
version = 500
@override
@classmethod
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
"""Migrate DB to DB_VERSION 500."""
# Drop date columns that were string based to add new float ones, plus int file_size
logger.info(fmt_log("Dropping old entry columns..."))
conn.execute("ALTER TABLE entries DROP COLUMN date_created")
conn.execute("ALTER TABLE entries DROP COLUMN date_modified")
logger.info(fmt_log("Adding new entry columns..."))
conn.execute("ALTER TABLE entries ADD COLUMN date_created REAL")
conn.execute("ALTER TABLE entries ADD COLUMN date_modified REAL")
conn.execute("ALTER TABLE entries ADD COLUMN file_size INTEGER")
# Normalize entry paths to NFD
logger.info(fmt_log("Normalizing file entry paths..."))
rows = conn.execute("SELECT id, path FROM entries").fetchall()
entries_by_key: dict[Path, list[tuple[int, str]]] = {}
for entry_id, path in rows:
nfd_path = norm_path(Path(path), case_sensitive=True)
entries_by_key.setdefault(nfd_path, []).append((entry_id, path))
updates: list[dict[str, str | int]] = []
for nfd_path, group in entries_by_key.items():
if len(group) > 1:
continue
entry_id, path = group[0]
if path == nfd_path.as_posix():
continue # Already normalized
updates.append(
{
"path": nfd_path.as_posix(),
"filename": nfd_path.name,
"suffix": nfd_path.suffix.lstrip(".").lower(),
"id": entry_id,
}
)
conn.executemany(
"UPDATE entries SET path = :path, filename = :filename, "
"suffix = :suffix WHERE id = :id",
updates,
)
+12 -7
View File
@@ -17,6 +17,7 @@ from tagstudio.core.library.alchemy.fields import (
TextField,
)
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent
from tagstudio.core.utils.normalization import norm_path
class Namespace(Base):
@@ -202,8 +203,9 @@ class Entry(Base):
path: Mapped[Path] = mapped_column(PathType, unique=True)
filename: Mapped[str] = mapped_column()
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_created: Mapped[float | None]
date_modified: Mapped[float | None]
file_size: Mapped[int | None]
date_added: Mapped[dt | None]
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
@@ -237,21 +239,24 @@ class Entry(Base):
path: Path,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_created: float | None = None,
date_modified: float | None = None,
file_size: int | None = None,
date_added: dt | None = None,
) -> None:
super().__init__()
self.path = path
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.filename = path.name
self.suffix = path.suffix.lstrip(".").lower()
self.path = norm_path(path, case_sensitive=True) # NFD is enforced
self.filename = self.path.name
self.suffix = self.path.suffix.lstrip(".").lower()
# The date the file associated with this entry was created.
# st_birthtime on Windows and Mac, st_ctime on Linux.
self.date_created = date_created
# The date the file associated with this entry was last modified: st_mtime.
self.date_modified = date_modified
# The size of the file associated with this entry, in bytes: st_size.
self.file_size = file_size
# The date this entry was added to the library.
self.date_added = date_added
@@ -1,98 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
import structlog
from wcmatch import glob, pathlib
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger()
@dataclass
class UnlinkedRegistry:
"""State tracker for unlinked entries."""
lib: Library
files_fixed_count: int = 0
unlinked_entries: list[Entry] = field(default_factory=list)
@property
def unlinked_entries_count(self) -> int:
return len(self.unlinked_entries)
def reset(self):
self.unlinked_entries.clear()
def refresh_unlinked_files(self) -> Iterator[int]:
"""Track the number of entries that point to an invalid filepath."""
logger.info("[UnlinkedRegistry] Refreshing unlinked files...")
self.unlinked_entries = []
for i, entry in enumerate(self.lib.all_entries()):
yield i
full_path = unwrap(self.lib.library_dir) / entry.path
if not full_path.exists() or not full_path.is_file():
self.unlinked_entries.append(entry)
def match_unlinked_file_entry(self, match_entry: Entry) -> list[Path]:
"""Try and match unlinked file entries with matching results in the library directory.
Works if files were just moved to different subfolders and don't have duplicate names.
"""
library_dir = unwrap(self.lib.library_dir)
matches: list[Path] = []
# NOTE: ignore_to_glob() is needed for wcmatch, not ripgrep.
ignore_patterns = ignore_to_glob(Ignore.get_patterns(library_dir))
for path in pathlib.Path(str(library_dir)).glob(
patterns=f"***/{glob.escape(match_entry.path.name)}",
flags=PATH_GLOB_FLAGS,
exclude=ignore_patterns,
):
if path.is_dir():
continue
if path.name == match_entry.path.name:
new_path = Path(path).relative_to(library_dir)
matches.append(new_path)
logger.info("[UnlinkedRegistry] Matches", matches=matches)
return matches
def fix_unlinked_entries(self) -> Iterator[int]:
"""Attempt to fix unlinked file entries by finding a match in the library directory."""
self.files_fixed_count = 0
matched_entries: list[Entry] = []
for i, entry in enumerate(self.unlinked_entries):
yield i
item_matches = self.match_unlinked_file_entry(entry)
if len(item_matches) == 1:
logger.info(
"[UnlinkedRegistry]",
entry=entry.path.as_posix(),
item_matches=item_matches[0].as_posix(),
)
if not self.lib.update_entry_path(entry.id, item_matches[0]):
try:
match = unwrap(self.lib.get_entry_full_by_path(item_matches[0]))
entry_full = unwrap(self.lib.get_entry_full(entry.id))
self.lib.merge_entries(entry_full, match)
except AttributeError:
continue
self.files_fixed_count += 1
matched_entries.append(entry)
for entry in matched_entries:
self.unlinked_entries.remove(entry)
def remove_unlinked_entries(self) -> None:
self.lib.remove_entries(list(map(lambda unlinked: unlinked.id, self.unlinked_entries)))
self.unlinked_entries = []
+37 -20
View File
@@ -1,20 +1,18 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-License-Identifier: MIT
from copy import deepcopy
from pathlib import Path
import structlog
import wcmatch.fnmatch as fnmatch
from wcmatch import glob, pathlib
from wcmatch import glob
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.utils.singleton import Singleton
logger = structlog.get_logger()
PATH_GLOB_FLAGS = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE | pathlib.MATCHBASE
PATH_GLOB_FLAGS: int = glob.GLOBSTARLONG | glob.DOTGLOB | glob.NEGATE
GLOBAL_IGNORE = [
@@ -43,7 +41,7 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
Args:
ignore_patterns (list[str]): The .gitignore-like patterns to convert.
"""
glob_patterns: list[str] = deepcopy(ignore_patterns)
glob_patterns: list[str] = list(ignore_patterns)
glob_patterns_remove: list[str] = []
additional_patterns: list[str] = []
root_patterns: list[str] = []
@@ -67,25 +65,32 @@ def ignore_to_glob(ignore_patterns: list[str]) -> list[str]:
elif gp.startswith("/"):
# Matches "/file" case for .gitignore behavior where it should only match
# a file or folder int the root directory, and nowhere else.
glob_patterns_remove.append(gp)
# a file or folder in the root directory and nowhere else.
glob_patterns_remove.append(pattern)
gp = gp.lstrip("/")
root_patterns.append(exclusion_char + gp)
for gp in glob_patterns_remove:
glob_patterns.remove(gp)
glob_patterns = glob_patterns + additional_patterns
remove_set = set(glob_patterns_remove)
glob_patterns = [p for p in glob_patterns if p not in remove_set]
# root_patterns must be merged in before the "/**" suffix pass below, otherwise a rooted
# directory pattern (e.g. "/Downloads/") never gets a "/**" variant and matches nothing.
glob_patterns = glob_patterns + additional_patterns + root_patterns
# Add "/**" suffix to suffix-less patterns to match implicit .gitignore behavior.
for pattern in glob_patterns:
for pattern in list(glob_patterns):
if pattern.endswith("/**"):
continue
glob_patterns.append(pattern.removesuffix("/*").removesuffix("/") + "/**")
glob_patterns = glob_patterns + root_patterns
glob_patterns = list(set(glob_patterns))
# Fix wcmatch interpreting "**" as "one or more" to be a .gitignore style "zero or more".
# Otherwise "**/foo" won't match a root "foo" and "a/**/b" won't match match "a/b".
for pattern in list(glob_patterns):
collapsed = pattern.removeprefix("**/").replace("/**/", "/")
if collapsed != pattern:
glob_patterns.append(collapsed)
glob_patterns = list(dict.fromkeys(glob_patterns)) # Ordered deduplication
logger.info("[Ignore]", glob_patterns=glob_patterns)
return glob_patterns
@@ -108,12 +113,24 @@ def migrate_ext_list(exts: list[str], is_exclude_list: bool) -> str:
return out
def _strip(line: str) -> str:
"""Strip a line ending and unescaped trailing whitespace from an ignore file line.
Leading whitespace and a backslash-escaped trailing space are left intact, matching
.gitignore's rule that trailing spaces are ignored unless escaped.
"""
line = line.rstrip("\r\n")
while line and line[-1].isspace() and line[-2:-1] != "\\":
line = line[:-1]
return line
class Ignore(metaclass=Singleton):
"""Class for processing and managing glob-like file ignore file patterns."""
_last_loaded: tuple[Path, float] | None = None
_patterns: list[str] = []
compiled_patterns: fnmatch.WcMatcher | None = None
compiled_patterns: glob.WcMatcher | None = None
@staticmethod
def read_ignore_file(library_dir: Path) -> list[str]:
@@ -179,9 +196,9 @@ class Ignore(metaclass=Singleton):
new_mtime=loaded[1],
)
Ignore._patterns = patterns + Ignore._load_ignore_file(ts_ignore_path)
Ignore.compiled_patterns = fnmatch.compile(
ignore_to_glob(Ignore._patterns),
PATH_GLOB_FLAGS,
Ignore.compiled_patterns = glob.compile(
patterns=ignore_to_glob(Ignore._patterns),
flags=PATH_GLOB_FLAGS,
)
else:
logger.info(
@@ -205,7 +222,7 @@ class Ignore(metaclass=Singleton):
if path.exists():
with open(path, encoding="utf8") as f:
for line_raw in f.readlines():
line = line_raw.strip()
line = _strip(line_raw)
# Ignore blank lines and comments
if not line or line.startswith("#"):
continue
-210
View File
@@ -1,210 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import shutil
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import datetime as dt
from pathlib import Path
from time import time
import structlog
from wcmatch import pathlib
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore
logger = structlog.get_logger(__name__)
@dataclass
class RefreshTracker:
library: Library
files_not_in_library: list[Path] = field(default_factory=list)
@property
def files_count(self) -> int:
return len(self.files_not_in_library)
def save_new_files(self) -> Iterator[int]:
"""Save the list of files that are not in the library."""
batch_size = 200
index = 0
while index < len(self.files_not_in_library):
yield index
end = min(len(self.files_not_in_library), index + batch_size)
entries = [
Entry(
path=entry_path,
fields=[],
date_added=dt.now(),
)
for entry_path in self.files_not_in_library[index:end]
]
self.library.add_entries(entries)
index = end
self.files_not_in_library = []
def refresh_dir(self, library_dir: Path, force_internal_tools: bool = False) -> Iterator[int]:
"""Scan a directory for files, and add those relative filenames to internal variables.
Args:
library_dir (Path): The library directory.
force_internal_tools (bool): Option to force the use of internal tools for scanning
(i.e. wcmatch) instead of using tools found on the system (i.e. ripgrep).
"""
if self.library.library_dir is None:
raise ValueError("No library directory set.")
ignore_patterns = Ignore.get_patterns(library_dir)
if force_internal_tools:
return self.__wc_add(library_dir, ignore_to_glob(ignore_patterns))
dir_list: list[str] | None = self.__get_dir_list(library_dir, ignore_patterns)
# Use ripgrep if it was found and working, else fallback to wcmatch.
if dir_list is not None:
return self.__rg_add(library_dir, dir_list)
else:
return self.__wc_add(library_dir, ignore_to_glob(ignore_patterns))
def __get_dir_list(self, library_dir: Path, ignore_patterns: list[str]) -> list[str] | None:
"""Use ripgrep to return a list of matched directories and files.
Return `None` if ripgrep not found on system.
"""
rg_path = shutil.which("rg")
# Use ripgrep if found on system
if rg_path is not None:
logger.info("[Refresh: Using ripgrep for scanning]")
compiled_ignore_path = library_dir / ".TagStudio" / ".compiled_ignore"
# Write compiled ignore patterns (built-in + user) to a temp file to pass to ripgrep
with open(compiled_ignore_path, "w") as pattern_file:
pattern_file.write("\n".join(ignore_patterns))
result = silent_run(
" ".join(
[
"rg",
"--files",
"--follow",
"--hidden",
"--ignore-file",
f'"{str(compiled_ignore_path)}"',
]
),
cwd=library_dir,
capture_output=True,
shell=True,
encoding="UTF-8",
)
try:
compiled_ignore_path.unlink()
except Exception as e:
logger.error(
"[Refresh] Could not remove compiled ignore path",
path=compiled_ignore_path,
error=e,
)
if result.stderr:
logger.error(result.stderr)
return result.stdout.splitlines() # pyright: ignore [reportReturnType]
logger.warning("[Refresh: ripgrep not found on system]")
return None
def __rg_add(self, library_dir: Path, dir_list: list[str]) -> Iterator[int]:
start_time_total = time()
start_time_loop = time()
dir_file_count = 0
self.files_not_in_library = []
for r in dir_list:
f = pathlib.Path(r)
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
# Ignore if the file is a directory
if f.is_dir():
continue
dir_file_count += 1
self.library.included_files.add(f)
if not self.library.has_entry_with_path(f):
self.files_not_in_library.append(f)
end_time_total = time()
yield dir_file_count
logger.info(
"[Refresh]: Directory scan time",
path=library_dir,
duration=(end_time_total - start_time_total),
files_scanned=dir_file_count,
tool_used="ripgrep (system)",
)
def __wc_add(self, library_dir: Path, ignore_patterns: list[str]) -> Iterator[int]:
start_time_total = time()
start_time_loop = time()
dir_file_count = 0
self.files_not_in_library = []
logger.info("[Refresh]: Falling back to wcmatch for scanning")
try:
for f in pathlib.Path(str(library_dir)).glob(
"***/*", flags=PATH_GLOB_FLAGS, exclude=ignore_patterns
):
end_time_loop = time()
# Yield output every 1/30 of a second
if (end_time_loop - start_time_loop) > 0.034:
yield dir_file_count
start_time_loop = time()
# Skip if the file/path is already mapped in the Library
if f in self.library.included_files:
dir_file_count += 1
continue
# Ignore if the file is a directory
if f.is_dir():
continue
dir_file_count += 1
self.library.included_files.add(f)
relative_path = f.relative_to(library_dir)
if not self.library.has_entry_with_path(relative_path):
self.files_not_in_library.append(relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
end_time_total = time()
yield dir_file_count
logger.info(
"[Refresh]: Directory scan time",
path=library_dir,
duration=(end_time_total - start_time_total),
files_scanned=dir_file_count,
tool_used="wcmatch (internal)",
)
+128
View File
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import os
import stat
import subprocess
import tempfile
from collections.abc import Iterator
from pathlib import Path
import structlog
from wcmatch import glob
from tagstudio.core.constants import TS_FOLDER_NAME
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, ignore_to_glob
from tagstudio.core.utils.ripgrep_status import RipgrepStatus
from tagstudio.core.utils.silent_subprocess import silent_popen # pyright: ignore
logger = structlog.get_logger(__name__)
def scan_paths(
scan_dir: Path, ignore_patterns: list[str], force_internal_scanner: bool = False
) -> Iterator[Path]:
"""Scan `scan_dir` for files, yielding each match's path relative to `scan_dir`.
Uses ripgrep if present on the system, falling back to the internal (wcmatch) scanner
otherwise or if `force_internal_scanner` is set.
"""
if not force_internal_scanner and RipgrepStatus.which() is not None:
yield from _scan_with_ripgrep(scan_dir, ignore_patterns)
return
yield from _scan_with_internal_scanner(scan_dir, ignore_patterns)
def _scan_with_ripgrep(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[Path]:
"""Scan for files with ripgrep."""
logger.info("[Scanners] Using ripgrep for scanning", path=scan_dir)
compiled_ignore_path = scan_dir / TS_FOLDER_NAME / ".compiled_ignore"
compiled_ignore_path.parent.mkdir(parents=True, exist_ok=True)
compiled_ignore_path.write_text("\n".join(ignore_patterns), encoding="utf-8")
proc: subprocess.Popen[str] | None = None
# Writing to a temp file instead of a pipe so it doesn't get overloaded and lock up
with tempfile.TemporaryFile(mode="w+", encoding="UTF-8") as stderr_file:
try:
proc = silent_popen(
[
RipgrepStatus.which(),
"--files", # Skip folders
"--follow", # Follow symlinks
"--hidden", # Scan hidden folders and files
"--no-ignore", # Ignore *literal* .gitignore files in paths
"--ignore-file", # Pass the .ts_ignore file:
str(compiled_ignore_path),
],
cwd=scan_dir,
stdout=subprocess.PIPE,
stderr=stderr_file,
text=True,
encoding="UTF-8",
)
assert proc.stdout is not None
for line in proc.stdout:
line = line.rstrip("\n")
if not line:
continue
yield Path(line)
proc.wait()
if proc.returncode not in (0, 1): # 1 == "no matches", still successful
stderr_file.seek(0)
logger.error(
"[Scanners] ripgrep exited with an error",
returncode=proc.returncode,
stderr=stderr_file.read(),
)
finally:
if proc is not None:
# Loop finished
if proc.stdout is not None:
proc.stdout.close()
# Still running, but cancelled mid-loop
if proc.poll() is None:
proc.terminate()
proc.wait()
try:
compiled_ignore_path.unlink(missing_ok=True)
except OSError as e:
logger.error(
"[Scanners] Could not remove compiled ignore path",
path=compiled_ignore_path,
error=e,
)
def _scan_with_internal_scanner(scan_dir: Path, ignore_patterns: list[str]) -> Iterator[Path]:
"""Scan for files with the internal scanner."""
logger.info("[Scanners] Using internal scanner for scanning", path=scan_dir)
matcher = glob.compile(patterns=ignore_to_glob(ignore_patterns), flags=PATH_GLOB_FLAGS)
def walk(dir_path: Path, ancestors: frozenset[str]) -> Iterator[Path]:
try:
dir_items = list(os.scandir(dir_path))
except OSError as e:
logger.error("[Scanners] Could not scan directory", path=dir_path, error=e)
return
for item in dir_items:
rel = Path(item.path).relative_to(scan_dir)
if matcher.match(rel.as_posix()):
continue
try:
item_stat = item.stat(follow_symlinks=True)
except OSError:
continue
# Check for and handle cyclical symlinks
if stat.S_ISDIR(item_stat.st_mode):
key = os.path.realpath(item.path)
if key not in ancestors:
yield from walk(Path(item.path), ancestors | {key})
else:
yield rel
yield from walk(scan_dir, frozenset({os.path.realpath(scan_dir)}))
+370
View File
@@ -0,0 +1,370 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from collections.abc import Callable, Hashable, Iterator
from dataclasses import dataclass, field
from datetime import datetime as dt
from pathlib import Path
from time import sleep, time
import structlog
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.library.scanners import scan_paths
from tagstudio.core.utils.normalization import norm_path
from tagstudio.core.utils.stat import get_date_created, get_date_modified, get_file_size
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
# Yield progress this often during a loop to avoid overwhelming the UI.
# TODO: Look into whether or not this can be handled on the UI side.
YIELD_INTERVAL_SECONDS = 0.068
@dataclass
class LibrarySyncEngine:
"""Keeps a Library's entries in sync with its content directories on disk."""
library: Library
new_paths: list[Path] = field(default_factory=list)
paths_to_restat: list[tuple[int, Path]] = field(default_factory=list)
unlinked_entries: list[Entry] = field(default_factory=list)
relinked_entries: list[Entry] = field(default_factory=list)
cancelled: bool = False
@property
def new_file_count(self) -> int:
return len(self.new_paths)
@property
def restat_count(self) -> int:
return len(self.paths_to_restat)
@property
def unlinked_entries_count(self) -> int:
return len(self.unlinked_entries)
@property
def relinked_entries_count(self) -> int:
return len(self.relinked_entries)
def reset(self) -> None:
"""Clear this engine's scan results."""
self.new_paths = []
self.paths_to_restat = []
self.unlinked_entries = []
self.relinked_entries = []
def sync_dir(
self, library_dir: Path, force_internal_scanner: bool = False
) -> Iterator[tuple[int, int]]:
"""Scan library directory for files, then reconcile them against the Library's entries.
- Entries with no matching file on disk are marked as "unlinked"
- Automatically relink to appropriate new files on disk where possible
- Remaining new files on disk are added as new entires
- Remaining unlinked entries are tracked for manual review.
Yields (searched_count, found_count)
Args:
library_dir (Path): The library directory.
force_internal_scanner (bool): Option to force the use of the internal scanner
(i.e. wcmatch) instead of third-party tools found on the system (i.e. ripgrep).
"""
self.reset()
self.cancelled = False
case_sensitive = self.library.is_fs_case_sensitive
cache = self.library.get_or_build_path_cache()
unvisited = set(cache.keys())
ignore_patterns = Ignore.get_patterns(library_dir)
start_time = time()
start_time_loop = time()
count = 0
for raw_path in scan_paths(library_dir, ignore_patterns, force_internal_scanner):
if self.cancelled:
break
count += 1
key = norm_path(raw_path, case_sensitive=case_sensitive)
entry_id = cache.get(key)
if entry_id is not None:
unvisited.discard(key)
self.paths_to_restat.append((entry_id, raw_path))
else:
self.new_paths.append(raw_path)
if (time() - start_time_loop) > YIELD_INTERVAL_SECONDS:
yield count, len(self.new_paths)
# NOTE: sleep(0) will let the UI thread acquire the GIL before its required here.
sleep(0)
start_time_loop = time()
if self.cancelled:
yield count, len(self.new_paths)
logger.info("[Sync] Directory scan cancelled", path=library_dir, files_scanned=count)
return
unlinked_ids = {cache[key] for key in unvisited}
if self.library.duplicate_path_entry_ids:
unlinked_ids.update(self.library.duplicate_path_entry_ids)
if unlinked_ids:
self.unlinked_entries = self.library.get_entries(list(unlinked_ids))
if self.unlinked_entries:
yield -1, -1 # Signals the UI that repair work is starting
# Any (rare) duplicate entries are merged first, then the normal relinking process
self._merge_duplicate_path_entries(case_sensitive, cache)
self._auto_relink_matched_entries(case_sensitive, cache)
yield count, len(self.new_paths)
logger.info(
"[Sync] Directory scan complete",
path=library_dir,
duration=(time() - start_time),
files_scanned=count,
new_files=len(self.new_paths),
unlinked_entries=len(self.unlinked_entries),
relinked_entries=len(self.relinked_entries),
)
def save_new_entries(self) -> Iterator[int]:
"""Save the paths found on disk that don't have a Library entry yet."""
batch_size = 1000
library_dir = unwrap(self.library.library_dir)
index = 0
while index < len(self.new_paths):
if self.cancelled:
break
yield index
end = min(len(self.new_paths), index + batch_size)
batch = self.new_paths[index:end]
entries = []
for entry_path in batch:
file_stat = (library_dir / entry_path).stat()
entries.append(
Entry(
path=entry_path,
fields=[],
date_created=get_date_created(file_stat),
date_modified=get_date_modified(file_stat),
file_size=get_file_size(file_stat),
date_added=dt.now(),
)
)
self.library.add_entries(entries) # Path cache is updated in the library
index = end
self.new_paths = self.new_paths[index:] # Saved entries are removed from new_paths
def sync_entry_stats(self) -> Iterator[int]:
"""Refresh cached os.stat() metadata for entries already known to the Library."""
batch_size = 5000
index = 0
while index < len(self.paths_to_restat):
if self.cancelled:
break
yield index
end = min(len(self.paths_to_restat), index + batch_size)
self.library.refresh_entries_stats(self.paths_to_restat[index:end])
index = end
self.paths_to_restat = self.paths_to_restat[index:]
def _apply_relink(
self, entry: Entry, new_path: Path, cache: dict[Path, int], case_sensitive: bool
) -> bool:
"""Assign `new_path` to the `entry`, merging into a single entry if entries exist for both.
Returns:
bool: True if the relink was successful.
"""
new_key = norm_path(new_path, case_sensitive=case_sensitive)
existing_id = cache.get(new_key)
if existing_id is not None and existing_id != entry.id:
# Merge both entries into one with the single path
target = unwrap(self.library.get_entry_full(existing_id))
source = unwrap(self.library.get_entry_full(entry.id))
return self.library.merge_entries(source, target)
return self.library.update_entry_path(entry.id, new_path)
def _relink_unique_matches(
self,
paths: list[Path],
cache: dict[Path, int],
case_sensitive: bool,
key_of_entry: Callable[[Entry], Hashable],
key_of_path: Callable[[Path], Hashable | None],
log_message: str,
) -> list[Path]:
"""Relink entries to paths that share a unique key.
Args:
paths (list[Path]): Candidate filepaths to try matching against unlinked entries.
cache (dict[Path, int]): Path cache, forwarded to `_apply_relink()`.
case_sensitive (bool): Filesystem case sensitivity, forwarded to `_apply_relink()`.
key_of_entry (Callable[[Entry], Hashable]): Computes a grouping key from an entry.
key_of_path (Callable[[Path], Hashable | None]): Computes a grouping key from a path,
or None if that path can't be evaluated by this key at all (e.g. missing stats).
log_message (str): Logged on each successful relink in this pass.
Returns:
list[Path]: Remaining unmatched paths.
"""
if not paths or not self.unlinked_entries:
return paths # Nothing to match against
by_key: dict[Hashable, list[Entry]] = {} # Key -> entries sharing it
for entry in self.unlinked_entries:
by_key.setdefault(key_of_entry(entry), []).append(entry)
paths_by_key: dict[Hashable, list[Path]] = {} # Key -> candidate paths sharing it
unmatched: list[Path] = []
for path in paths:
key = key_of_path(path)
if key is None: # Not evaluable by this key (e.g. missing stats)
unmatched.append(path)
continue
paths_by_key.setdefault(key, []).append(path)
relinked: list[Entry] = []
for key, candidate_paths in paths_by_key.items(): # Check each key for a unique pairing
candidates = by_key.get(key, [])
if len(candidates) != 1 or len(candidate_paths) != 1: # Ambiguous case
unmatched.extend(candidate_paths)
continue
entry, new_path = candidates[0], candidate_paths[0] # The only pair sharing this key
if not self._apply_relink(entry, new_path, cache, case_sensitive): # DB write failed
unmatched.append(new_path)
continue
logger.info(log_message, old_path=entry.path.as_posix(), new_path=new_path.as_posix())
relinked.append(entry)
for entry in relinked:
self.unlinked_entries.remove(entry)
self.relinked_entries.extend(relinked)
return unmatched
def _merge_duplicate_path_entries(self, case_sensitive: bool, cache: dict[Path, int]) -> None:
"""Merge any entry whose stored path collides with another entry's onto that entry.
Entries here have already a full path + filename match.
"""
duplicate_ids = set(self.library.duplicate_path_entry_ids or [])
if not duplicate_ids:
return
merged: list[Entry] = []
for entry in self.unlinked_entries:
if self.cancelled:
break
if entry.id in duplicate_ids and self._apply_relink(
entry, entry.path, cache, case_sensitive
):
merged.append(entry)
for entry in merged:
self.unlinked_entries.remove(entry)
self.relinked_entries.extend(merged)
def _auto_relink_matched_entries(self, case_sensitive: bool, cache: dict[Path, int]) -> None:
"""Attempt to automatically relink unlinked entries under available conditions.
Auto-relink applies to:
- Files with the same filename but different paths
- Handles moves, moves + changes (Case #7)
- Files with different names and/or paths but the same date_modified and file_size
- Handles moves, renames + moves (Cases #3, #11)
- Files with only a matching filename, as a last resort when nothing else matches
- Handles moves + changes, and entries with no stored stats (Case #6)
Auto-relink DOES NOT apply to:
- Renames + Changes (Cases #2, #10)
- Deletions (Case #1)
- Ambiguous matches (Cases #4, #5, #8, #9, #12, #13)
*(Cases are listed in the Library documentation)*
"""
if not self.new_paths or not self.unlinked_entries:
return
library_dir = unwrap(self.library.library_dir)
self.relinked_entries = []
def name_key(path: Path) -> Path:
return norm_path(Path(path.name), case_sensitive=case_sensitive)
# Stat every new path once, every pass below reuses this
stats_by_path: dict[Path, tuple[float | None, int | None]] = {}
for new_path in self.new_paths:
try:
file_stat = (library_dir / new_path).stat()
except OSError as e:
logger.error(
"[Sync] Could not stat file during auto-relink check",
path=new_path,
error=e,
)
continue
stats_by_path[new_path] = (get_date_modified(file_stat), get_file_size(file_stat))
def name_and_stat_key(path: Path) -> tuple[Path, float | None, int | None] | None:
stat = stats_by_path.get(path)
return None if stat is None else (name_key(path), *stat)
# Pass 1: Filename + metadata (Case #7)
remaining = self._relink_unique_matches(
self.new_paths,
cache,
case_sensitive,
key_of_entry=lambda e: (name_key(e.path), e.date_modified, e.file_size),
key_of_path=name_and_stat_key,
log_message="[Sync] Automatically relinked moved file",
)
if self.cancelled:
self.new_paths = remaining
return
# Pass 2: Different filename checking for same metadata (Cases #3, #11)
remaining = self._relink_unique_matches(
remaining,
cache,
case_sensitive,
key_of_entry=lambda e: (e.date_modified, e.file_size),
key_of_path=stats_by_path.get,
log_message="[Sync] Automatically relinked renamed file (matched by size + mdate)",
)
if self.cancelled:
self.new_paths = remaining
return
# Pass 3: Filename only, for anything that wasn't matched before
# (Case #6, and entries with no stored stats)
remaining = self._relink_unique_matches(
remaining,
cache,
case_sensitive,
key_of_entry=lambda e: name_key(e.path),
key_of_path=name_key,
log_message="[Sync] Automatically relinked file by filename (no metadata found)",
)
self.new_paths = remaining
def remove_unlinked_entries(self) -> None:
"""Remove unlinked entries from the Library."""
# Path cache is updated in the library.
self.library.remove_entries([entry.id for entry in self.unlinked_entries])
self.unlinked_entries = []
+15
View File
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import platform
def is_fs_case_sensitive() -> bool:
"""Whether the filesystem is case sensitive.
NOTE: Not authoritative for OSes other than Windows.
"""
# TODO: Make this more robust instead of assuming Windows == NTFS/exFAT
# and other OS filesystems are automatically case sensitive.
return platform.system() != "Windows"
+19
View File
@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import unicodedata
from pathlib import Path
def norm_path(path: Path | str, case_sensitive: bool) -> Path:
"""Return `path` normalized to Unicode Normalization Form D (NFD)."""
if isinstance(path, str):
path = Path(path)
posix = path.as_posix()
# NOTE: ASCII paths will be unaffected by all unicode normalization and can be skipped.
# See: https://unicode.org/reports/tr15/
normalized = posix if posix.isascii() else unicodedata.normalize("NFD", posix)
if not case_sensitive:
normalized = normalized.casefold()
return Path(normalized)
+29
View File
@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import os
import platform
from pathlib import Path
def _resolve(path_or_stat: Path | os.stat_result) -> os.stat_result:
if isinstance(path_or_stat, os.stat_result):
return path_or_stat
return path_or_stat.stat()
def get_date_modified(path_or_stat: Path | os.stat_result) -> float:
return _resolve(path_or_stat).st_mtime
def get_date_created(path_or_stat: Path | os.stat_result) -> float:
stat = _resolve(path_or_stat)
if platform.system() in {"Windows", "Darwin"}:
# NOTE: Accessing stat().st_birthtime causes linter checks to fail on some systems.
return stat.st_birthtime # type: ignore[attr-defined, unused-ignore]
else:
return stat.st_ctime
def get_file_size(path_or_stat: Path | os.stat_result) -> int:
return _resolve(path_or_stat).st_size
+247
View File
@@ -0,0 +1,247 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import time
from collections.abc import Callable
from typing import Literal, override
from PySide6.QtCore import (
QEasingCurve,
QPropertyAnimation,
QRectF,
Qt,
QTimer,
QVariantAnimation,
Signal,
)
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPaintEvent
from PySide6.QtWidgets import QVBoxLayout, QWidget
from tagstudio.qt.views.banner_view import BannerView
from tagstudio.qt.views.styles.stylesheets import (
BANNER_CORNER_RADIUS,
banner_notice_bg_color,
banner_notice_style,
banner_progress_bg_color,
banner_progress_chunk_color,
banner_progress_style,
)
BannerMode = Literal["progress", "notice", "fleeting_notice"]
class _BannerBackground(QWidget):
"""The banner's background widget. Used for custom animations, like fading the color."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._bg_color = QColor(Qt.GlobalColor.transparent)
@property
def bg_color(self) -> QColor:
return self._bg_color
def set_bg_color(self, color: QColor) -> None:
self._bg_color = color
self.update()
@override
def paintEvent(self, event: QPaintEvent) -> None:
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
path.addRoundedRect(QRectF(self.rect()), BANNER_CORNER_RADIUS, BANNER_CORNER_RADIUS)
painter.fillPath(path, self._bg_color)
painter.end()
class Banner(QWidget):
"""A notification banner with an optional progress bar, action button, and close button."""
CONTENT_HEIGHT = 36
GAP = 6
HEIGHT = CONTENT_HEIGHT + GAP
ANIMATION_MS = 250
COLOR_ANIMATION_MS = 250
MIN_VISIBLE_MS = 3000
STARTUP_EXTRA_HOLD_MS = 500 # Starting up may eat into time shown, so add extra time.
notice_action_clicked = Signal()
cancel_requested = Signal()
dismissed = Signal()
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setMinimumHeight(0)
self.setMaximumHeight(0)
outer_layout = QVBoxLayout(self)
outer_layout.setContentsMargins(0, 0, 0, self.GAP)
outer_layout.setSpacing(0)
self._background = _BannerBackground(self)
self._background.setObjectName("banner")
self.view = BannerView()
self._background.setLayout(self.view)
outer_layout.addWidget(self._background)
self._mode: BannerMode = "progress"
self._progress_phase: object = None
self._notice_style = banner_notice_style()
self._progress_style = banner_progress_style()
self._notice_bg_color = banner_notice_bg_color()
self._progress_bg_color = banner_progress_bg_color()
self._background.setStyleSheet(self._notice_style)
self._background.set_bg_color(self._notice_bg_color)
self.view.progress_bar.set_corner_radius(BANNER_CORNER_RADIUS)
self.view.progress_bar.set_chunk_color(banner_progress_chunk_color())
self._card_color_anim = QVariantAnimation(self)
self._card_color_anim.setDuration(self.COLOR_ANIMATION_MS)
self._card_color_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._card_color_anim.valueChanged.connect(self._background.set_bg_color)
self._connect_callbacks()
self._set_mode("notice")
self._height_anim = QPropertyAnimation(self, b"maximumHeight", self)
self._height_anim.setDuration(self.ANIMATION_MS)
self._height_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._height_anim.valueChanged.connect(self.setMinimumHeight)
self._shown_at: float | None = None
self._extra_hold_ms = 0
self._hide_timer = QTimer(self)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(lambda: self._start_height_animation(0))
def request_extra_duration(self) -> None:
"""Add STARTUP_EXTRA_HOLD_MS to the next automatic hide's minimum-visible window."""
self._extra_hold_ms = self.STARTUP_EXTRA_HOLD_MS
def call_when_open(self, callback: Callable[[], None]) -> None:
"""Call `callback` if/when the banner is fully open."""
if self.maximumHeight() == self.HEIGHT and self._height_anim.state() != (
QPropertyAnimation.State.Running
):
callback()
return
def _on_finished() -> None:
self._height_anim.finished.disconnect(_on_finished)
callback()
self._height_anim.finished.connect(_on_finished)
def _connect_callbacks(self) -> None:
self.view.close_button.clicked.connect(self._on_dismiss)
self.view.action_button.clicked.connect(self._on_action_clicked)
def _on_action_clicked(self) -> None:
self.notice_action_clicked.emit()
def _start_height_animation(self, target_height: int) -> None:
if self.maximumHeight() == target_height and self._height_anim.state() != (
QPropertyAnimation.State.Running
):
return
self._height_anim.stop()
self._height_anim.setStartValue(self.maximumHeight())
self._height_anim.setEndValue(target_height)
self._height_anim.start()
def _animate_to(self, target_height: int, force: bool = False) -> None:
self._hide_timer.stop()
if target_height > 0:
self._shown_at = time.monotonic()
self._start_height_animation(target_height)
return
extra_hold_ms = self._extra_hold_ms
self._extra_hold_ms = 0
if not force and self._shown_at is not None:
elapsed_ms = (time.monotonic() - self._shown_at) * 1000
remaining_ms = (self.MIN_VISIBLE_MS + extra_hold_ms) - elapsed_ms
if remaining_ms > 0:
self._hide_timer.start(int(remaining_ms))
return
self._shown_at = None
self._start_height_animation(0)
def _set_mode(self, mode: BannerMode):
if mode == self._mode:
return
self._mode = mode
self._progress_phase = None
self.view.label.reset_width()
self.view.close_button.setVisible(mode != "fleeting_notice")
self.view.action_button.setVisible(mode == "notice")
self.view.progress_bar.setVisible(mode == "progress")
# The progress bar state gets a darkened background, while notices get the accent color.
is_progress = mode == "progress"
self._background.setStyleSheet(self._progress_style if is_progress else self._notice_style)
target_color = self._progress_bg_color if is_progress else self._notice_bg_color
self._card_color_anim.stop()
self._card_color_anim.setStartValue(self._background.bg_color)
self._card_color_anim.setEndValue(target_color)
self._card_color_anim.start()
def _present(self, mode: BannerMode, button_text: str, message: str) -> None:
"""Applies the banner mode and any label + button text, then animates the banner open."""
self._set_mode(mode)
self.view.label.reset_width()
self.view.action_button.setText(button_text)
self.view.label.setText(message)
self._animate_to(self.HEIGHT)
def _on_dismiss(self):
if self._mode == "progress":
self.cancel_requested.emit()
# Explicit dismiss, apply immediately
self._animate_to(0, force=True)
self.dismissed.emit()
def show_notice(self, message: str, action_text: str) -> None:
"""Show a dismissible notice with an action button, until dismissed or replaced."""
self._present("notice", action_text, message)
def show_fleeting_notice(self, message: str) -> None:
"""Show a brief notice with no action button that dismisses itself automatically."""
self._set_mode("fleeting_notice")
self.view.label.reset_width()
self.view.label.setText(message)
self._animate_to(self.HEIGHT)
# Deferred by the existing MIN_VISIBLE_MS guard, same as an unforced hide_banner().
self._animate_to(0)
def show_progress(self, text: str, value: int = 0, maximum: int = 0, phase: str | None = None):
"""Show the progress banner.
Args:
text (str): The status text shown in the banner body.
value (int): The current progress value.
maximum (int): The maximum progress value. If 0, shown as indeterminate.
phase (str | None): An identifier for the current sub-phase of progress.
Helps inform widgets that need to update between phases, like the StableLabel.
"""
self._set_mode("progress")
if phase != self._progress_phase:
self._progress_phase = phase
self.view.label.reset_width()
self.view.label.setText(text)
self.view.progress_bar.set_range(0, maximum)
self.view.progress_bar.set_value(value)
self._animate_to(self.HEIGHT)
def hide_banner(self, force: bool = False):
"""Hide the banner (if shown).
Args:
force (bool): Bypass the minimum visible duration and hide immediately.
"""
self._animate_to(0, force=force)
+4 -3
View File
@@ -158,7 +158,8 @@ class Inspector(QWidget):
if stats.duration is not None:
self._current_stats.duration = stats.duration
self.layout().file_attrs.update_stats(filepath, self._current_stats)
entry = unwrap(self._lib.get_entry(self._selected[0]))
self.layout().file_attrs.update_stats(filepath, self._current_stats, entry)
def _set_selection_callback(self) -> None:
with catch_warnings(record=True):
@@ -257,8 +258,8 @@ class Inspector(QWidget):
if update_preview:
stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath)
self._current_stats = stats
self.layout().file_attrs.update_stats(filepath, stats)
self.layout().file_attrs.update_date_label(filepath)
self.layout().file_attrs.update_stats(filepath, stats, entry)
self.layout().file_attrs.update_date_label(entry)
self.layout().containers.update_from_entry(entry_id)
self._set_selection_callback()
+13 -7
View File
@@ -40,6 +40,7 @@ from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.enums import SortingModeEnum
from tagstudio.i18n.platform_strings import trash_term
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.banner import Banner
from tagstudio.qt.controllers.inspector import Inspector
from tagstudio.qt.helpers.mnemonics import assign_mnemonics
from tagstudio.qt.mixed.landing import LandingWidget
@@ -64,7 +65,7 @@ class MainMenuBar(QMenuBar):
save_library_backup_action: QAction
settings_action: QAction
open_on_start_action: QAction
refresh_dir_action: QAction
sync_library_action: QAction
close_library_action: QAction
edit_menu: QMenu
@@ -152,17 +153,17 @@ class MainMenuBar(QMenuBar):
self.file_menu.addSeparator()
# Refresh Directories
self.refresh_dir_action = QAction(Translations["menu.file.refresh_directories"], self)
self.refresh_dir_action.setShortcut(
# Sync Library
self.sync_library_action = QAction(Translations["menu.file.sync_library"], self)
self.sync_library_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_R,
)
)
self.refresh_dir_action.setStatusTip("Ctrl+R")
self.refresh_dir_action.setEnabled(False)
self.file_menu.addAction(self.refresh_dir_action)
self.sync_library_action.setStatusTip("Ctrl+R")
self.sync_library_action.setEnabled(False)
self.file_menu.addAction(self.sync_library_action)
self.file_menu.addSeparator()
@@ -485,6 +486,7 @@ class MainWindow(QMainWindow):
# initialized in setup_entry_list
self.entry_list_container: QWidget
self.entry_list_layout: QVBoxLayout
self.banner: Banner
self.entry_scroll_area: QScrollArea
self.thumb_grid: QWidget
self.thumb_layout: ThumbGridLayout
@@ -691,6 +693,9 @@ class MainWindow(QMainWindow):
self.thumb_grid.setLayout(self.thumb_layout)
self.entry_scroll_area.setWidget(self.thumb_grid)
self.banner = Banner()
self.entry_list_layout.addWidget(self.banner)
self.entry_list_layout.addWidget(self.entry_scroll_area)
self.landing_widget = LandingWidget(driver, self.devicePixelRatio())
@@ -698,6 +703,7 @@ class MainWindow(QMainWindow):
self.pagination = Pagination()
self.entry_list_layout.addWidget(self.pagination)
self.content_splitter.addWidget(self.entry_list_container)
def setup_preview_panel(self, driver: QtDriver):
@@ -1,35 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtCore import QObject, Signal
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.progress_bar import ProgressWidget
class RelinkUnlinkedEntriesProgress(QObject):
done = Signal()
def __init__(self, tracker: UnlinkedRegistry):
super().__init__()
self.tracker = tracker
def repair_entries(self):
def displayed_text(x):
return Translations.format(
"entries.unlinked.relink.attempting",
index=x,
unlinked_count=self.tracker.unlinked_entries_count,
fixed_count=self.tracker.files_fixed_count,
)
pw = ProgressWidget(
label_text="",
cancel_button_text=None,
minimum=0,
maximum=self.tracker.unlinked_entries_count,
)
pw.setWindowTitle(Translations["entries.unlinked.relink.title"])
pw.from_iterable_function(self.tracker.fix_unlinked_entries, displayed_text, self.done.emit)
@@ -0,0 +1,236 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import time
from typing import override
from PySide6.QtCore import QEasingCurve, QRectF, Qt, QTimer, QVariantAnimation
from PySide6.QtGui import (
QColor,
QHideEvent,
QLinearGradient,
QPainter,
QPainterPath,
QPaintEvent,
QResizeEvent,
QShowEvent,
)
from PySide6.QtWidgets import QWidget
class RoundedProgressBar(QWidget):
"""A custom stylized progress bar that supports smooth animations and rounded corners."""
MARQUEE_FRACTION = 0.5
MARQUEE_INTERVAL_MS = 8
MARQUEE_CYCLE_MS = 2000
VALUE_ANIMATION_MS = 150
FADE_MS = 2000
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._minimum = 0
self._maximum = 0
self._value = 0
self._displayed_value = 0.0
self._corner_radius = 0.0
self._chunk_color = QColor(Qt.GlobalColor.transparent)
self._marquee_start: float | None = None
self._cached_path: QPainterPath | None = None
self._opacity = 1.0
self._pending_range_change = False
self._marquee_timer = QTimer(self)
self._marquee_timer.setTimerType(Qt.TimerType.PreciseTimer)
self._marquee_timer.setInterval(self.MARQUEE_INTERVAL_MS)
self._marquee_timer.timeout.connect(self.update)
self._value_anim = QVariantAnimation(self)
self._value_anim.setDuration(self.VALUE_ANIMATION_MS)
self._value_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._value_anim.valueChanged.connect(self._on_value_anim_changed)
self._value_anim.finished.connect(self._on_value_anim_finished)
self._fade_anim = QVariantAnimation(self)
self._fade_anim.setDuration(self.FADE_MS)
self._fade_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._fade_anim.valueChanged.connect(self._on_fade_anim_changed)
def _on_value_anim_changed(self, value: float) -> None:
self._displayed_value = value
self.update()
def _on_value_anim_finished(self) -> None:
"""Start the fade-out once the progress bar has reached its maximum."""
if not self._is_indeterminate() and self._value >= self._maximum:
self._fade_anim.stop()
self._fade_anim.setStartValue(self._opacity)
self._fade_anim.setEndValue(0.0)
self._fade_anim.start()
def _on_fade_anim_changed(self, value: float) -> None:
"""Update the fill opacity for the fade animation."""
self._opacity = value
self.update()
def _reset_fade(self) -> None:
"""Stop any fading and reset opacity back to full."""
self._fade_anim.stop()
if self._opacity != 1.0:
self._opacity = 1.0
self.update()
def set_range(self, minimum: int, maximum: int) -> None:
"""Set the value range, switching to indeterminate mode if maximum <= minimum."""
if (minimum, maximum) == (self._minimum, self._maximum):
return
self._pending_range_change = True
self._minimum = minimum
self._maximum = maximum
if self._is_indeterminate():
self._reset_fade()
self._sync_marquee_timer()
self.update()
def set_value(self, value: int) -> None:
"""Animate the fill towards `value`."""
self._value = value
if self._is_indeterminate():
self._pending_range_change = False
return
if self._pending_range_change:
self._pending_range_change = False
if value >= self._maximum:
self._value_anim.stop()
self._displayed_value = float(self._maximum)
self.update()
self._on_value_anim_finished()
return
self._displayed_value = float(self._minimum)
self._reset_fade()
elif value < self._maximum:
self._reset_fade()
self._value_anim.stop()
self._value_anim.setStartValue(self._displayed_value)
self._value_anim.setEndValue(float(value))
self._value_anim.start()
def set_corner_radius(self, radius: float) -> None:
"""Set the bottom corner radius and invalidate the cached QPainterPath."""
self._corner_radius = radius
self._cached_path = None
self.update()
def set_chunk_color(self, color: QColor) -> None:
"""Set the fill color."""
self._chunk_color = color
self.update()
def _is_indeterminate(self) -> bool:
"""Whether the bar is in indeterminate (marquee) mode."""
return self._maximum <= self._minimum
def _sync_marquee_timer(self) -> None:
"""Start or stop the marquee timer to match the current mode/visibility."""
if self._is_indeterminate() and self.isVisible():
if self._marquee_start is None:
self._marquee_start = time.monotonic()
if not self._marquee_timer.isActive():
self._marquee_timer.start()
else:
self._marquee_timer.stop()
self._marquee_start = None
def _marquee_fraction(self) -> float:
"""Return the marquee's current position as a fraction of its cycle."""
if self._marquee_start is None:
return 0.0
elapsed_ms = (time.monotonic() - self._marquee_start) * 1000
return (elapsed_ms % self.MARQUEE_CYCLE_MS) / self.MARQUEE_CYCLE_MS
@override
def showEvent(self, event: QShowEvent) -> None:
"""Resume the marquee timer when the bar becomes visible."""
super().showEvent(event)
self._sync_marquee_timer()
@override
def hideEvent(self, event: QHideEvent) -> None:
"""Stop all animations and reset state when the bar is hidden."""
super().hideEvent(event)
self._marquee_timer.stop()
self._marquee_start = None
self._value_anim.stop()
self._reset_fade()
self._pending_range_change = False
@override
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._cached_path = None # Invalidate the cached QPainterPath on resize
def _bottom_rounded_path(self, rect: QRectF) -> QPainterPath:
"""Return the bottom-rounded clip path for `rect`."""
if self._cached_path is not None:
return self._cached_path
# TODO: Currently these values are hardcoded for use with the Banner widget, but this
# could be made customizable to specify the exact rounding configuration.
# If you're reading this and want to make use of this progress bar, there you go.
radius = max(0.0, min(self._corner_radius, rect.width() / 2))
diam = radius * 2
path = QPainterPath()
path.moveTo(rect.left(), rect.top())
path.lineTo(rect.right(), rect.top())
path.lineTo(rect.right(), rect.bottom() - radius)
path.arcTo(QRectF(rect.right() - diam, rect.bottom() - diam, diam, diam), 0, -90)
path.lineTo(rect.left() + radius, rect.bottom())
path.arcTo(QRectF(rect.left(), rect.bottom() - diam, diam, diam), -90, -90)
path.closeSubpath()
self._cached_path = path
return path
def _marquee_gradient(self, full_chunk_rect: QRectF) -> QLinearGradient:
"""Gradient for the indeterminate marquee mode (Transparent -> Color -> Transparent)."""
gradient = QLinearGradient(full_chunk_rect.left(), 0, full_chunk_rect.right(), 0)
transparent = QColor(self._chunk_color)
transparent.setAlpha(0)
gradient.setColorAt(0.0, transparent)
gradient.setColorAt(0.5, self._chunk_color)
gradient.setColorAt(1.0, transparent)
return gradient
@override
def paintEvent(self, event: QPaintEvent) -> None:
del event
# Paint the marquee or normal chunk fill, clipped to the rounded bottom corners.
rect = QRectF(self.rect())
if self._is_indeterminate():
chunk_width = rect.width() * self.MARQUEE_FRACTION
travel = rect.width() * (1 + self.MARQUEE_FRACTION)
chunk_left = (self._marquee_fraction() * travel) - chunk_width
full_chunk_rect = QRectF(chunk_left, 0, chunk_width, rect.height())
chunk_rect = full_chunk_rect.intersected(rect)
if chunk_rect.isEmpty():
return
fill = self._marquee_gradient(full_chunk_rect)
else:
fraction = (self._displayed_value - self._minimum) / (self._maximum - self._minimum)
fraction = max(0.0, min(1.0, fraction))
chunk_rect = QRectF(0, 0, rect.width() * fraction, rect.height()).intersected(rect)
if chunk_rect.isEmpty():
return
fill = self._chunk_color
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setClipPath(self._bottom_rounded_path(rect))
painter.setOpacity(self._opacity)
painter.fillRect(chunk_rect, fill)
painter.end()
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from typing import override
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QLabel, QWidget
class StableLabel(QLabel):
"""A QLabel that resists "jiggling" from rapidly changing text.
Holds its `sizeHint()` width at the widest shown since the last reset_width() call,
and is always kept left aligned of that, so a centering layout's box stops
growing/shrinking on every update (aka the "jiggle" effect).
"""
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
super().__init__(text, parent)
self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self._min_width = 0
def reset_width(self) -> None:
"""Let the tracked width shrink again, for new unrelated text."""
self._min_width = 0
self.updateGeometry()
@override
def setText(self, text: str) -> None:
super().setText(text)
self._min_width = max(self._min_width, super().sizeHint().width())
self.updateGeometry()
@override
def sizeHint(self) -> QSize:
hint = super().sizeHint()
return QSize(max(hint.width(), self._min_width), hint.height())
+1 -1
View File
@@ -178,7 +178,7 @@ class DropImportModal(QWidget):
pw.from_iterable_function(
self.copy_files,
displayed_text,
self.driver.add_new_files_callback,
self.driver.sync_library_callback,
self.deleteLater,
)
+33 -40
View File
@@ -3,7 +3,6 @@
import os
import platform
import typing
from dataclasses import dataclass
from datetime import datetime as dt
@@ -17,6 +16,7 @@ from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
@@ -92,40 +92,35 @@ class FileAttributes(QWidget):
self.library = library
self.driver = driver
def update_date_label(self, filepath: Path | None = None) -> None:
def _format_date_or_na(self, timestamp: float | None) -> str:
if timestamp is None:
return "<i>N/A</i>"
return self.driver.settings.format_datetime(dt.fromtimestamp(timestamp))
def update_date_label(self, entry: Entry | None = None) -> None:
"""Update the "Date Created" and "Date Modified" file property labels."""
if filepath and filepath.is_file():
created: dt
if platform.system() == "Windows" or platform.system() == "Darwin":
# NOTE: Accessing stat().st_birthtime causes linter checks to fail on some systems.
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore]
else:
created = dt.fromtimestamp(filepath.stat().st_ctime)
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
self.date_created_label.setText(
f"<b>{Translations['file.date_created']}:</b>"
+ f" {self.driver.settings.format_datetime(created)}"
)
self.date_modified_label.setText(
f"<b>{Translations['file.date_modified']}:</b> "
f"{self.driver.settings.format_datetime(modified)}"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
elif filepath:
self.date_created_label.setText(
f"<b>{Translations['file.date_created']}:</b> <i>N/A</i>"
)
self.date_modified_label.setText(
f"<b>{Translations['file.date_modified']}:</b> <i>N/A</i>"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
else:
if entry is None:
self.date_created_label.setHidden(True)
self.date_modified_label.setHidden(True)
return
def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None):
created_text = self._format_date_or_na(entry.date_created)
modified_text = self._format_date_or_na(entry.date_modified)
self.date_created_label.setText(
f"<b>{Translations['file.date_created']}:</b> {created_text}"
)
self.date_modified_label.setText(
f"<b>{Translations['file.date_modified']}:</b> {modified_text}"
)
self.date_created_label.setHidden(False)
self.date_modified_label.setHidden(False)
def update_stats(
self,
filepath: Path | None = None,
stats: FileAttributeData | None = None,
entry: Entry | None = None,
):
"""Render the panel widgets with the newest data from the Library."""
if not stats:
stats = FileAttributeData()
@@ -170,18 +165,15 @@ class FileAttributes(QWidget):
# Initialize the possible stat variables
stats_label_text = ""
ext_display: str = ""
file_size: str = ""
file_size: str = format_size(entry.file_size) if entry and entry.file_size else ""
font_family: str = ""
# Attempt to populate the stat variables
ext_display = ext.upper()[1:] or filepath.stem.upper()
if filepath and filepath.is_file():
if filepath and filepath.is_file() and MediaTypes.contains("font", ext, SEARCH):
try:
file_size = format_size(filepath.stat().st_size)
if MediaTypes.contains("font", ext, SEARCH):
font = ImageFont.truetype(filepath)
font_family = f"{font.getname()[0]} ({font.getname()[1]}) "
font = ImageFont.truetype(filepath)
font_family = f"{font.getname()[0]} ({font.getname()[1]}) "
except (FileNotFoundError, OSError) as e:
logger.error(
"[FileAttributes] Could not process file stats", filepath=filepath, error=e
@@ -206,14 +198,15 @@ class FileAttributes(QWidget):
f" • <span style='color:{orange}'>"
f"{Translations['preview.ignored'].upper()}</span>"
)
if file_size:
stats_label_text += f"{file_size}"
if not filepath.exists():
stats_label_text = (
f"{stats_label_text}"
f" • <span style='color:{red}'>"
f"{Translations['preview.unlinked'].upper()}</span>"
)
if file_size:
stats_label_text += f"{file_size}"
elif file_size:
stats_label_text += file_size
+24 -58
View File
@@ -9,27 +9,22 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.merge_dupe_entries_progress import MergeDuplicateEntriesProgress
from tagstudio.qt.controllers.progress_bar import ProgressWidget
from tagstudio.qt.controllers.relink_entries_progress import RelinkUnlinkedEntriesProgress
from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal
from tagstudio.qt.views.styles.stylesheets import header
# Only import for type checking/autocompletion, will not be imported at runtime.
if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
# TODO: Split to use MVC guidelines.
# TODO: Split to use MVC guidelines, or completely redo.
class FixUnlinkedEntriesModal(QWidget):
def __init__(self, library: Library, driver: QtDriver):
super().__init__()
self.lib = library
self.driver = driver
self.tracker = UnlinkedRegistry(lib=self.lib)
self.sync_engine = driver.sync_engine
self.unlinked_count = -1
self.dupe_count = -1
@@ -39,7 +34,14 @@ class FixUnlinkedEntriesModal(QWidget):
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.unlinked_desc_widget = QLabel(Translations["entries.unlinked.description"])
self.unlinked_desc_widget = QLabel(
Translations["entries.unlinked.description"]
+ "<br><br>"
+ Translations["entries.unlinked.description.deleted"]
# TODO: Implement manual relinking
# + "<br><br>"
# + Translations["entries.unlinked.description.ambiguous"]
)
self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel")
self.unlinked_desc_widget.setWordWrap(True)
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
@@ -53,35 +55,24 @@ class FixUnlinkedEntriesModal(QWidget):
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.refresh_unlinked_button = QPushButton(Translations["entries.generic.refresh_alt"])
self.refresh_unlinked_button.clicked.connect(self.refresh_unlinked)
self.refresh_unlinked_button.clicked.connect(self.driver.sync_library_callback)
self.merge_class = MergeDuplicateEntriesProgress(self.lib, self.driver)
self.relink_class = RelinkUnlinkedEntriesProgress(self.tracker)
self.search_button = QPushButton(Translations["entries.unlinked.search_and_relink"])
self.relink_class.done.connect(
# refresh the grid
lambda: (
self.driver.update_browsing_state(),
self.refresh_unlinked(),
)
)
self.search_button.clicked.connect(self.relink_class.repair_entries)
self.manual_button = QPushButton(Translations["entries.unlinked.relink.manual"])
self.manual_button.setHidden(True)
self.remove_button = QPushButton(Translations["entries.unlinked.remove_alt"])
self.remove_modal = RemoveUnlinkedEntriesModal(self.driver, self.tracker)
self.remove_modal = RemoveUnlinkedEntriesModal(self.driver, self.sync_engine)
self.remove_modal.done.connect(
lambda: (
self.set_unlinked_count(),
# refresh the grid
self.driver.update_browsing_state(),
self.refresh_unlinked(),
self._sync_ui_from_tracker(),
)
)
self.remove_button.clicked.connect(self.remove_modal.show)
self.remove_button.clicked.connect(
lambda: (self.remove_modal.refresh_list(), self.remove_modal.show())
)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
@@ -96,7 +87,6 @@ class FixUnlinkedEntriesModal(QWidget):
self.root_layout.addWidget(self.unlinked_count_label)
self.root_layout.addWidget(self.unlinked_desc_widget)
self.root_layout.addWidget(self.refresh_unlinked_button)
self.root_layout.addWidget(self.search_button)
self.root_layout.addWidget(self.manual_button)
self.root_layout.addWidget(self.remove_button)
self.root_layout.addStretch(1)
@@ -105,46 +95,22 @@ class FixUnlinkedEntriesModal(QWidget):
self.update_unlinked_count()
def refresh_unlinked(self):
pw = ProgressWidget(
cancel_button_text=None,
minimum=0,
maximum=self.lib.entries_count,
)
pw.setWindowTitle(Translations["library.scan_library.title"])
pw.update_label(Translations["entries.unlinked.scanning"])
def update_driver_widgets():
if (
hasattr(self.driver, "library_info_window")
and self.driver.library_info_window.isVisible()
):
self.driver.library_info_window.update_cleanup()
pw.from_iterable_function(
self.tracker.refresh_unlinked_files,
None,
self.set_unlinked_count,
self.update_unlinked_count,
self.remove_modal.refresh_list,
update_driver_widgets,
)
def _sync_ui_from_tracker(self) -> None:
"""Refresh the UI from the tracker's current state, without rescanning the library."""
self.set_unlinked_count()
self.update_unlinked_count()
self.remove_modal.refresh_list()
def set_unlinked_count(self):
"""Sets the unlinked_entries_count in the Library to the tracker's value."""
self.lib.unlinked_entries_count = self.tracker.unlinked_entries_count
self.lib.unlinked_entries_count = self.sync_engine.unlinked_entries_count
def update_unlinked_count(self):
"""Updates the UI to reflect the Library's current unlinked_entries_count."""
# Indicates that the library is new compared to the last update.
# NOTE: Make sure set_unlinked_count() is called before this!
if self.tracker.unlinked_entries_count > 0 and self.lib.unlinked_entries_count < 0:
self.tracker.reset()
count: int = self.lib.unlinked_entries_count
syncing = self.driver.file_scan_lock # Disabled while a sync is running
self.search_button.setDisabled(count < 1)
self.remove_button.setDisabled(count < 1)
self.remove_button.setDisabled(count < 1 or syncing)
count_text: str = Translations.format(
"entries.unlinked.unlinked_count", count=count if count >= 0 else ""
+3 -3
View File
@@ -8,7 +8,6 @@ from typing import cast
from warnings import deprecated
import structlog
import wcmatch.fnmatch as fnmatch
from PySide6.QtCore import QObject, Qt, QThreadPool, Signal
from PySide6.QtWidgets import (
QApplication,
@@ -24,6 +23,7 @@ from PySide6.QtWidgets import (
)
from sqlalchemy import select
from sqlalchemy.orm import Session
from wcmatch import glob
from tagstudio.core.constants import (
IGNORE_NAME,
@@ -512,13 +512,13 @@ class JsonMigrationModal(QObject):
return str(f"<b><a style='color: {color}'>{new_value}</a></b>")
def assert_ignore_parity(self) -> None:
compiled_pats = fnmatch.compile(
compiled_pats = glob.compile(
ignore_to_glob(
Ignore._load_ignore_file( # pyright: ignore[reportPrivateUsage]
unwrap(self.json_lib.library_dir) / TS_FOLDER_NAME / IGNORE_NAME
)
),
PATH_GLOB_FLAGS,
flags=PATH_GLOB_FLAGS,
) # copied from Ignore.get_patterns since that method modifies singleton state
path = self.json_lib.library_dir / "filename"
for ext in self.json_lib.ext_list:
@@ -9,7 +9,7 @@ from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import QHBoxLayout, QLabel, QListView, QPushButton, QVBoxLayout, QWidget
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.core.library.sync import LibrarySyncEngine
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.progress_bar import ProgressWidget
from tagstudio.qt.utils.custom_runnable import CustomRunnable
@@ -18,11 +18,11 @@ if TYPE_CHECKING:
from tagstudio.qt.qt_driver import QtDriver
# TODO: Split to use MVC guidelines.
# TODO: Split to use MVC guidelines or completely redo.
class RemoveUnlinkedEntriesModal(QWidget):
done = Signal()
def __init__(self, driver: QtDriver, tracker: UnlinkedRegistry):
def __init__(self, driver: QtDriver, tracker: LibrarySyncEngine):
super().__init__()
self.driver = driver
self.tracker = tracker
+309 -89
View File
@@ -17,10 +17,11 @@ import sys
import time
from argparse import Namespace
from collections import OrderedDict
from collections.abc import Callable, Iterator
from functools import partial
from pathlib import Path
from queue import Queue
from typing import TypeVar
from typing import Literal, TypeVar
from warnings import catch_warnings
import structlog
@@ -47,7 +48,7 @@ from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.library import Library, LibraryStatus
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.library.sync import LibrarySyncEngine
from tagstudio.core.media_types import MediaTypes
from tagstudio.core.query_lang.file_groups import SEARCH
from tagstudio.core.query_lang.util import ParsingError
@@ -67,7 +68,6 @@ from tagstudio.qt.controllers.ignore_modal import IgnoreModal
from tagstudio.qt.controllers.library_info_window import LibraryInfoWindow
from tagstudio.qt.controllers.main_window import MainWindow
from tagstudio.qt.controllers.modal import Modal
from tagstudio.qt.controllers.progress_bar import ProgressWidget
from tagstudio.qt.controllers.splash import SplashScreen
from tagstudio.qt.controllers.tag_search_panel import TagSearchPanel
from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox
@@ -104,6 +104,9 @@ else:
from signal import SIGINT, SIGQUIT, SIGTERM, signal # pyright: ignore
logger = structlog.get_logger(__name__)
T = TypeVar("T")
# Used to track the context state of the banner widget.
_BannerContext = Literal["new_files", "unlinked", "relinked", "sync_disabled", "sync_finished"]
def clamp(value, lower_bound, upper_bound):
@@ -128,9 +131,6 @@ class Consumer(QThread):
pass
T = TypeVar("T")
# Ex. User visits | A ->[B] |
# | A B ->[C]|
# | A [B]<- C |
@@ -192,13 +192,17 @@ class QtDriver(DriverMixin, QObject):
def __init__(self, args: Namespace):
super().__init__()
# prevent recursive badges update when multiple items selected
self.badge_update_lock = False
self.lib = Library()
self.sync_engine = LibrarySyncEngine(self.lib)
self.rm: ResourceManager = ResourceManager()
self.args = args
self.frame_content: list[int] = [] # List of Entry IDs for the current query
self.badge_update_lock = False
self.file_scan_lock: bool = False # Prevent multiple file scanning operations at once
self._selected: OrderedDict[int, None] = OrderedDict()
self._sync_session_id: int = 0 # Prevent current sync from affecting subsequent libraries.
self._sync_disabled_notice_shown: bool = False
self._banner_context: _BannerContext | None = None
self.pages_count = 0
self.scrollbar_pos = 0
@@ -454,9 +458,9 @@ class QtDriver(DriverMixin, QObject):
set_open_last_loaded_on_startup
)
# Refresh Directories
self.main_window.menu_bar.refresh_dir_action.triggered.connect(
lambda: self.call_if_library_open(self.add_new_files_callback)
# Sync Library
self.main_window.menu_bar.sync_library_action.triggered.connect(
lambda: self.call_if_library_open(self.sync_library_callback)
)
# Close Library
@@ -552,13 +556,8 @@ class QtDriver(DriverMixin, QObject):
# region Tools Menu ===========================================================
def create_fix_unlinked_entries_modal():
if not hasattr(self, "unlinked_modal"):
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
self.unlinked_modal.show()
self.main_window.menu_bar.fix_unlinked_entries_action.triggered.connect(
create_fix_unlinked_entries_modal
self.open_fix_unlinked_entries_modal
)
def create_ignored_entries_modal():
@@ -577,7 +576,7 @@ class QtDriver(DriverMixin, QObject):
self.main_window.menu_bar.fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
# TODO: Move this to a settings screen.
# TODO: Make this accessible somewhere more sensible too, like "Library Information"
self.main_window.menu_bar.clear_thumb_cache_action.triggered.connect(
lambda: unwrap(self.cache_manager).clear_cache()
)
@@ -638,6 +637,7 @@ class QtDriver(DriverMixin, QObject):
self.init_library_window()
self.migration_modal: JsonMigrationModal | None = None
self.main_window.banner.request_extra_duration()
path_result = self.evaluate_path(str(self.args.open).lstrip().rstrip())
if path_result.success and path_result.library_path:
self.open_library(path_result.library_path)
@@ -678,6 +678,9 @@ class QtDriver(DriverMixin, QObject):
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
def _update_browsing_state():
# Clear any banner asking for a manual refresh of the view
if self._banner_context == "new_files":
self._clear_notice()
try:
self.update_browsing_state(
BrowsingState.from_search_query(self.main_window.search_field.text())
@@ -725,9 +728,11 @@ class QtDriver(DriverMixin, QObject):
self.main_window.back_button.clicked.connect(lambda: self.navigation_callback(-1))
self.main_window.forward_button.clicked.connect(lambda: self.navigation_callback(1))
# NOTE: Putting this early will result in a white non-responsive
# window until everything is loaded. Consider adding a splash screen
# or implementing some clever loading tricks.
# Banner
self.main_window.banner.notice_action_clicked.connect(self._on_notice_action_clicked)
self.main_window.banner.cancel_requested.connect(self._on_sync_cancel_requested)
# NOTE: Putting this too early will result in a non-responsive white window on start.
self.main_window.show()
self.main_window.activateWindow()
self.main_window.toggle_landing_page(enabled=True)
@@ -784,8 +789,14 @@ class QtDriver(DriverMixin, QObject):
if not self.lib.library_dir:
logger.info("No Library to Close")
return
logger.info("Closing Library...")
self.sync_engine.cancelled = True
self.file_scan_lock = False
self._new_sync_session() # Invalidate any sync still active for the old library
self._banner_context = None
self.main_window.banner.hide_banner(force=True)
self.main_window.status_bar.showMessage(Translations["status.library_closing"])
start_time = time.time()
@@ -800,6 +811,7 @@ class QtDriver(DriverMixin, QObject):
self.__reset_navigation()
self.lib.close()
self.sync_engine.reset()
self.cache_manager = None
self.thumb_job_queue.queue.clear()
@@ -827,7 +839,7 @@ class QtDriver(DriverMixin, QObject):
try:
self.main_window.menu_bar.save_library_backup_action.setEnabled(False)
self.main_window.menu_bar.close_library_action.setEnabled(False)
self.main_window.menu_bar.refresh_dir_action.setEnabled(False)
self.main_window.menu_bar.sync_library_action.setEnabled(False)
self.main_window.menu_bar.tag_manager_action.setEnabled(False)
self.main_window.menu_bar.color_manager_action.setEnabled(False)
self.main_window.menu_bar.field_template_manager_action.setEnabled(False)
@@ -1067,82 +1079,278 @@ class QtDriver(DriverMixin, QObject):
return msg.exec()
def add_new_files_callback(self):
"""Run when user initiates adding new files to the Library."""
tracker = RefreshTracker(self.lib)
def _run_sync_step(
self,
generator: Callable[[], Iterator[T]],
on_progress: Callable[[T], None],
on_done: Callable[[], None],
) -> None:
"""Run a generator function on a background thread with signals for progress and completion.
pw = ProgressWidget(
cancel_button_text=None,
minimum=0,
maximum=0,
Args:
generator (Callable[[], Iterator[T]]): Zero-argument callable returning the
generator to iterate.
on_progress (Callable[[T], None]): Called on the main thread with each yielded value.
on_done (Callable[[], None]): Called on the main thread once `generator` is finished.
"""
iterator = FunctionIterator(generator)
iterator.value.connect(on_progress)
runnable = CustomRunnable(iterator.run)
runnable.done.connect(on_done)
QThreadPool.globalInstance().start(runnable)
def sync_library_callback(self):
"""Run when syncing a Library is initiated."""
if self.file_scan_lock:
logger.info("[QtDriver] Sync already in progress, ignoring request")
return
self.file_scan_lock = True
session_id = self._new_sync_session()
# Disable the "Fix Unlinked Entries" modal's relink/remove actions during the sync
if hasattr(self, "unlinked_modal") and self.unlinked_modal.isVisible():
self.unlinked_modal.update_unlinked_count()
engine = self.sync_engine
library_dir = unwrap(self.lib.library_dir)
self.main_window.banner.show_progress(
Translations["library.sync.preparing"], phase="preparing"
)
pw.setWindowTitle(Translations["library.refresh.title"])
pw.update_label(Translations["library.refresh.scanning_preparing"])
pw.show()
iterator = FunctionIterator(lambda lib=self.lib.library_dir: tracker.refresh_dir(lib))
iterator.value.connect(
lambda x: (
pw.update_progress(x + 1),
pw.update_label(
Translations.format(
"library.refresh.scanning.plural"
if x + 1 != 1
else "library.refresh.scanning.singular",
searched_count=f"{x + 1:n}",
found_count=f"{tracker.files_count:n}",
)
def on_progress(progress: tuple[int, int]) -> None:
if engine.cancelled:
return
searched_count, found_count = progress
if searched_count < 0:
# Scan finished, duplicate entry merging/relinking is running before the next yield
self.main_window.banner.show_progress(
Translations["library.sync.repairing"], phase="repairing"
)
return
self.main_window.banner.show_progress(
Translations.format(
"library.sync.scanning",
searched_count=f"{searched_count + 1:n}",
found_count=f"{found_count:n}",
),
phase="scanning",
)
)
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.add_new_files_runnable(tracker),
)
)
QThreadPool.globalInstance().start(r)
def add_new_files_runnable(self, tracker: RefreshTracker):
def _start_scan() -> None:
self._run_sync_step(
lambda lib=library_dir: engine.sync_dir(lib),
on_progress,
lambda: self.save_new_entries_runnable(engine, session_id=session_id),
)
self.main_window.banner.call_when_open(_start_scan)
def _finish_sync(
self,
new_count: int = 0,
unlinked_count: int = 0,
relinked_count: int = 0,
session_id: int = 0,
):
"""Reset the banner once the sync is completed.
Args:
new_count (int): New files count.
unlinked_count (int): Unlinked entries count.
relinked_count (int): Automatically relinked files count.
session_id (int): The sync_session_id this sync started with.
"""
if self._is_sync_stale(session_id):
return
self.file_scan_lock = False
self.lib.unlinked_entries_count = unlinked_count
if hasattr(self, "unlinked_modal") and self.unlinked_modal.isVisible():
self.unlinked_modal.update_unlinked_count()
self.unlinked_modal.remove_modal.refresh_list()
if hasattr(self, "library_info_window") and self.library_info_window.isVisible():
self.library_info_window.update_cleanup()
if self.sync_engine.cancelled:
return
# Show fleeting count of any new files added with button to refresh view
if new_count:
text = Translations.format(
"library.sync.new_files_banner.plural"
if new_count != 1
else "library.sync.new_files_banner.singular",
count=f"{new_count:n}",
)
text += self._count_suffix(relinked_count, "library.sync.relinked_suffix")
if relinked_count:
text += self._count_suffix(unlinked_count, "library.sync.remaining_unlinked_suffix")
self._show_notice("new_files", text, Translations["entries.generic.refresh_alt"])
# Show persistent count of any remaining unlinked files and button to manually review
elif unlinked_count:
text = Translations.format(
"library.sync.unlinked_banner.plural"
if unlinked_count != 1
else "library.sync.unlinked_banner.singular",
count=f"{unlinked_count:n}",
)
text += self._count_suffix(relinked_count, "library.sync.relinked_suffix")
self._show_notice("unlinked", text, Translations["entries.unlinked.review"])
# Show fleeting notice number of entries automatically relinked
elif relinked_count:
text = Translations.format(
"library.sync.relinked_banner.plural"
if relinked_count != 1
else "library.sync.relinked_banner.singular",
count=f"{relinked_count:n}",
)
text += self._count_suffix(unlinked_count, "library.sync.remaining_unlinked_suffix")
self._show_notice("relinked", text, Translations["entries.generic.refresh_alt"])
# Show a fleeting "Library Synced" message
else:
self._show_notice("sync_finished", Translations["library.sync.complete"])
def _count_suffix(self, count: int, key: str) -> str:
"""Build a count suffix suffix, or "" if count is 0."""
if not count:
return ""
return " " + Translations.format(key, count=f"{count:n}")
def _new_sync_session(self) -> int:
"""Increment the sync session, invalidating any active sync's callbacks."""
self._sync_session_id += 1
return self._sync_session_id
def _is_sync_stale(self, session_id: int) -> bool:
"""Whether `session_id` belongs to an older sync session and should be invalidated."""
return session_id != self._sync_session_id
def _show_notice(
self, context: _BannerContext, message: str, button_text: str | None = None
) -> None:
"""Show a "notice" banner and keep track of its context type.
Args:
context (_BannerContext): The subtype of banner notice.
Used to keep track of the context state currently used for the banner.
This could be for a startup message, sync progress, an entry relink prompt, etc.
message (str): The notice message text.
button_text (str): The action button text.
"""
self._banner_context = context
if button_text is not None:
self.main_window.banner.show_notice(message, button_text)
else:
self.main_window.banner.show_fleeting_notice(message)
def _clear_notice(self, force: bool = False) -> None:
self._banner_context = None
self.main_window.banner.hide_banner(force=force)
def _on_notice_action_clicked(self) -> None:
if self._banner_context == "unlinked":
self._on_unlinked_banner_review()
elif self._banner_context == "sync_disabled":
self._on_sync_disabled_open_settings()
else: # "new_files" or "relinked"
self._on_new_files_banner_refresh()
def _on_new_files_banner_refresh(self):
self.update_browsing_state()
# If there are still unlinked entries after the automatic relinking step, show a notice.
if self.lib.unlinked_entries_count > 0:
count = self.lib.unlinked_entries_count
text = Translations.format(
"library.sync.unlinked_banner.plural"
if count != 1
else "library.sync.unlinked_banner.singular",
count=f"{count:n}",
)
self._show_notice("unlinked", text, Translations["entries.unlinked.review"])
else:
self._clear_notice(force=True)
def _on_unlinked_banner_review(self):
self._clear_notice(force=True)
self.open_fix_unlinked_entries_modal()
def _on_sync_disabled_open_settings(self):
self._clear_notice(force=True)
self.open_settings_modal()
def _on_sync_cancel_requested(self):
"""Stop the in-progress sync at its next opportunity."""
self.sync_engine.cancelled = True
logger.info("[QtDriver] Sync cancelled")
def open_fix_unlinked_entries_modal(self):
if not hasattr(self, "unlinked_modal"):
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
self.unlinked_modal.show()
def sync_entry_stats_runnable(
self,
engine: LibrarySyncEngine,
new_count: int = 0,
unlinked_count: int = 0,
relinked_count: int = 0,
session_id: int = 0,
):
"""Refresh cached stat() data for files already known to the library.
Threaded method.
"""
if self._is_sync_stale(session_id):
return
restat_count = engine.restat_count
def on_progress(idx: int) -> None:
if engine.cancelled:
return
self.main_window.banner.show_progress(
Translations.format(
"library.sync.updating.label", idx=f"{idx:n}", total=f"{restat_count:n}"
),
idx,
restat_count,
phase="updating",
)
on_progress(0)
self._run_sync_step(
engine.sync_entry_stats,
on_progress,
lambda: self._finish_sync(new_count, unlinked_count, relinked_count, session_id),
)
def save_new_entries_runnable(self, engine: LibrarySyncEngine, session_id: int = 0):
"""Adds any known new files to the library and run default macros on them.
Threaded method.
"""
files_count = tracker.files_count
if self._is_sync_stale(session_id):
return
new_count = engine.new_file_count
unlinked_count = engine.unlinked_entries_count
relinked_count = engine.relinked_entries_count
iterator = FunctionIterator(tracker.save_new_files)
pw = ProgressWidget(
cancel_button_text=None,
minimum=0,
maximum=0,
)
pw.setWindowTitle(Translations["entries.running.dialog.title"])
pw.update_label(
Translations.format("entries.running.dialog.new_entries", total=f"{files_count:n}")
)
pw.show()
def on_progress(idx: int) -> None:
if engine.cancelled:
return
self.main_window.banner.show_progress(
Translations.format("entries.running.dialog.new_entries", total=f"{new_count:n}"),
idx,
new_count,
phase="new_entries",
)
iterator.value.connect(
lambda _count: (
pw.update_label(
Translations.format(
"entries.running.dialog.new_entries", total=f"{files_count:n}"
)
),
)
on_progress(0)
self._run_sync_step(
engine.save_new_entries,
on_progress,
lambda: self.sync_entry_stats_runnable(
engine, new_count, unlinked_count, relinked_count, session_id
),
)
r = CustomRunnable(iterator.run)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
# refresh the library only when new items are added
files_count and self.update_browsing_state(),
)
)
QThreadPool.globalInstance().start(r)
def new_file_macros_runnable(self, new_ids):
"""Threaded method that runs macros on a set of Entry IDs."""
@@ -1640,7 +1848,7 @@ class QtDriver(DriverMixin, QObject):
f"[Config] Thumbnail Cache Size: {format_size(cache_size)}",
)
# Migration is required
# JSON Migration is required
if open_status.json_migration_req:
self.migration_modal = JsonMigrationModal(path)
self.migration_modal.migration_finished.connect(
@@ -1666,7 +1874,19 @@ class QtDriver(DriverMixin, QObject):
self.__reset_navigation()
if self.settings.scan_files_on_open:
self.add_new_files_callback()
self.sync_library_callback()
elif not self._sync_disabled_notice_shown:
self._sync_disabled_notice_shown = True
# Show that the setting for opening a library on start is turned off,
# with a prompt to open the settings to change that (encouraged but not required).
self._show_notice(
"sync_disabled",
Translations.format(
"library.sync.disabled_notice",
sync_setting=Translations["settings.scan_files_on_open"],
),
Translations["library.sync.open_settings"],
)
if self.settings.show_filepath == ShowFilepathOption.SHOW_FULL_PATHS:
library_dir_display = self.lib.library_dir
@@ -1688,7 +1908,7 @@ class QtDriver(DriverMixin, QObject):
self.set_select_actions_visibility()
self.main_window.menu_bar.save_library_backup_action.setEnabled(True)
self.main_window.menu_bar.close_library_action.setEnabled(True)
self.main_window.menu_bar.refresh_dir_action.setEnabled(True)
self.main_window.menu_bar.sync_library_action.setEnabled(True)
self.main_window.menu_bar.tag_manager_action.setEnabled(True)
self.main_window.menu_bar.color_manager_action.setEnabled(True)
self.main_window.menu_bar.field_template_manager_action.setEnabled(True)
+47
View File
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout
from tagstudio.i18n.translations import Translations
from tagstudio.qt.controllers.rounded_progress_bar import RoundedProgressBar
from tagstudio.qt.controllers.stable_label import StableLabel
class BannerView(QVBoxLayout):
PROGRESS_BAR_HEIGHT = 4
def __init__(self) -> None:
super().__init__()
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(0)
content_row = QHBoxLayout()
content_row.setContentsMargins(6, 6, 6, 2)
content_row.setSpacing(8)
self.close_button = QPushButton("×")
self.close_button.setObjectName("bannerCloseButton")
self.close_button.setFixedSize(24, 24)
content_row.addWidget(self.close_button)
content_row.addStretch(1)
self.label = StableLabel()
content_row.addWidget(self.label)
self.action_button = QPushButton(Translations["entries.generic.refresh_alt"])
self.action_button.setObjectName("bannerActionButton")
content_row.addWidget(self.action_button)
content_row.addStretch(1)
self.addLayout(content_row, 1)
self.progress_bar = RoundedProgressBar()
self.progress_bar.setFixedHeight(self.PROGRESS_BAR_HEIGHT)
policy = self.progress_bar.sizePolicy()
policy.setRetainSizeWhenHidden(True)
self.progress_bar.setSizePolicy(policy)
self.addWidget(self.progress_bar)
@@ -57,8 +57,6 @@ class ThumbGridLayout(QLayout):
self._scroll_to = entry_id
def set_entries(self, entry_ids: list[int]):
self.scroll_area.verticalScrollBar().setValue(0)
self._entry_ids = entry_ids
self._entries.clear()
self._tag_entries.clear()
@@ -211,9 +209,10 @@ class ThumbGridLayout(QLayout):
pass
self._scroll_to = None
visible_rows = math.ceil((view_height + (offset % height_offset)) / height_offset)
offset = int(offset / height_offset)
start = offset * per_row
row_offset = offset
visible_rows = math.ceil((view_height + (row_offset % height_offset)) / height_offset)
row_offset = int(row_offset / height_offset)
start = row_offset * per_row
end = start + (visible_rows * per_row)
first_visible = self._entry_ids[start] if 0 <= start < len(self._entry_ids) else None
@@ -19,6 +19,9 @@ from tagstudio.qt.views.styles.palette import (
# TODO: There's plenty of good opportunities here to consolidate similar styles.
# Work should be done to more closely use Qt's theming systems rather than override them.
# Shared with RoundedProgressBar.set_corner_radius() so both use the exact same corner arc.
BANNER_CORNER_RADIUS = 6
def add_button_style() -> str:
"""Style used for tag-like "Add" buttons [+]."""
@@ -557,6 +560,142 @@ def preview_warning_style() -> str:
"""
def _is_dark_theme() -> bool:
return QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
def get_contrast_text_color(background_color: QColor) -> QColor:
"""Return plain black or white, whichever reads better against `background_color`."""
return QColor(0, 0, 0) if background_color.lightness() > 120 else QColor(255, 255, 255)
def _banner_button_hover_style(object_name: str) -> str:
"""Shared hover/pressed/focus feedback for the banner's accent-tinted buttons."""
hover = Palette.accent().darker(140)
pressed = Palette.accent().lighter(120)
return f"""
QPushButton#{object_name}::hover {{
background-color: rgba{hover.toTuple()};
}}
QPushButton#{object_name}::pressed {{
background-color: rgba{pressed.toTuple()};
}}
QPushButton#{object_name}::focus {{
outline: none;
}}
"""
def banner_close_button_style() -> str:
"""Style for the banner's close ("X") button in accent-colored notice modes."""
accent = Palette.accent().darker(180)
text_color = get_contrast_text_color(accent)
return f"""
QPushButton#bannerCloseButton {{
font-size: 24pt;
padding-bottom: 4px;
background: transparent;
color: rgba{text_color.toTuple()};
border: none;
border-radius: 3px;
}}
{_banner_button_hover_style("bannerCloseButton")}
"""
def banner_action_button_style() -> str:
"""Style for the banner's action button (Refresh / Review / Open Settings)."""
accent = Palette.accent().darker(160)
text_color = get_contrast_text_color(accent)
return f"""
QPushButton#bannerActionButton {{
background-color: rgba{accent.toTuple()};
color: rgba{text_color.toTuple()};
border: none;
border-radius: 3px;
padding: 4px 8px;
outline: none;
}}
{_banner_button_hover_style("bannerActionButton")}
"""
def banner_notice_bg_color() -> QColor:
"""Fill color for the banner card in "notice" mode."""
color = QColor(Palette.accent())
color.setAlpha(235)
return color
def banner_notice_style() -> str:
"""Label/button rules for the banner's accent-colored "notice" mode."""
accent = Palette.accent()
text_color = get_contrast_text_color(accent)
return f"""
#banner QLabel {{ color: rgba{text_color.toTuple()}; background: transparent; }}
{banner_close_button_style()}
{banner_action_button_style()}
"""
def banner_close_button_progress_style() -> str:
"""Close button style for the progress banner mode."""
is_dark = _is_dark_theme()
text_color = QColor(255, 255, 255) if is_dark else QColor(0, 0, 0)
hover = "rgba(255, 255, 255, 40)" if is_dark else "rgba(0, 0, 0, 40)"
pressed = "rgba(255, 255, 255, 70)" if is_dark else "rgba(0, 0, 0, 70)"
return f"""
QPushButton#bannerCloseButton {{
font-size: 24pt;
padding-bottom: 4px;
background: transparent;
color: rgba{text_color.toTuple()};
border: none;
border-radius: 3px;
}}
QPushButton#bannerCloseButton::hover {{
background-color: {hover};
}}
QPushButton#bannerCloseButton::pressed {{
background-color: {pressed};
}}
QPushButton#bannerCloseButton::focus {{
outline: none;
}}
"""
def banner_progress_bg_color() -> QColor:
"""Fill color for the banner card in "progress"/"fleeting_notice" mode."""
is_dark = _is_dark_theme()
return QColor(
ThemePalette.COLOR_BG_DARK.value if is_dark else ThemePalette.COLOR_BG_LIGHT.value
)
def banner_progress_style() -> str:
"""Label/button rules for the banner's neutral "progress" mode."""
is_dark = _is_dark_theme()
text_str = "white" if is_dark else "black"
return f"""
#banner QLabel {{ color: {text_str}; background: transparent; }}
{banner_close_button_progress_style()}
"""
def banner_progress_chunk_color() -> QColor:
"""Fill color for the banner's custom-painted progress bar chunk."""
is_dark = _is_dark_theme()
chunk = QColor(Palette.accent().lighter(130) if is_dark else Palette.accent().darker(115))
chunk.setAlpha(235)
return chunk
def header(string: str, level: int, color: str | None = None) -> str:
"""Wrap a string in HTML header tags.
@@ -40,7 +40,6 @@
"entries.duplicates.description": "Duplicitní položky jsou definovány jako více položek, které ukazují na stejný soubor na disku. Jejich sloučením se spojí značky a metadata ze všech duplikátů do jediné konsolidované položky. Nesmí se zaměňovat s „duplicitními soubory“, což jsou duplikáty samotných vašich souborů mimo TagStudio.",
"entries.mirror.confirmation": "Opravdu chcete zrcadlit následujících {count} položek?",
"entries.unlinked.relink.manual": "Znovu propojit ručně",
"entries.unlinked.relink.title": "Propojuji záznamy",
"entries.unlinked.scanning": "Skenuji knihovnu pro nepropojené záznamy...",
"entries.unlinked.title": "Opravit nepropojené záznamy",
"field.copy": "Zkopírovat políčko",
+1 -4
View File
@@ -59,14 +59,11 @@
"entries.running.dialog.new_entries": "Füge {total} neue Dateieinträge hinzu...",
"entries.running.dialog.title": "Füge neue Dateieinträge hinzu",
"entries.tags": "Tags",
"entries.unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, außerhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft.<br><br>Nicht verknüpfte Einträge können durch das Durchsuchen Ihrer Verzeichnisse automatisch neu verknüpft, vom Benutzer manuell neu verknüpft oder auf Wunsch gelöscht werden.",
"entries.unlinked.relink.attempting": "Versuche {index}/{unlinked_count} Einträge neu zu verknüpfen, {fixed_count} bereits erfolgreich neu verknüpft",
"entries.unlinked.description": "Jeder Bibliothekseintrag ist mit einer Datei in einem Ihrer Verzeichnisse verknüpft. Wenn eine Datei, die mit einem Eintrag verknüpft ist, außerhalb von TagStudio verschoben oder gelöscht wird, gilt sie als nicht verknüpft.",
"entries.unlinked.relink.manual": "&Manuell Neuverknüpfen",
"entries.unlinked.relink.title": "Einträge werden neu verknüpft",
"entries.unlinked.remove": "Entferne nicht verknüpfte Einträge",
"entries.unlinked.remove_alt": "Entfer&ne nicht verknüpfte Einträge",
"entries.unlinked.scanning": "Bibliothek wird nach nicht verknüpften Einträgen durchsucht...",
"entries.unlinked.search_and_relink": "&Suchen && Neuverknüpfen",
"entries.unlinked.title": "Unverknüpfte Einträge reparieren",
"entries.unlinked.unlinked_count": "Unverknüpfte Einträge: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -58,14 +58,11 @@
"entries.running.dialog.new_entries": "Προσθήκη {total} νέων εγγραφών αρχείων...",
"entries.running.dialog.title": "Προσθήκη νέων εγγραφών αρχείων",
"entries.tags": "Tags",
"entries.unlinked.description": "Κάθε εγγραφή της βιβλιοθήκης είναι συνδεδεμένη με ένα αρχείο σε έναν από τους καταλόγους σας. Εάν ένα αρχείο που είναι συνδεδεμένο με μια εγγραφή μετακινηθεί ή διαγραφεί εκτός του TagStudio, τότε θεωρείται αποσυνδεδεμένο.<br><br>Οι αποσυνδεδεμένες εγγραφές μπορούν να επανασυνδεθούν αυτόματα μέσω αναζήτησης στους καταλόγους σας ή να διαγραφούν, εάν το επιθυμείτε.",
"entries.unlinked.relink.attempting": "Προσπάθεια επανασύνδεσης {index}/{unlinked_count} εγγραφών, {fixed_count} επανασυνδέθηκαν επιτυχώς",
"entries.unlinked.description": "Κάθε εγγραφή της βιβλιοθήκης είναι συνδεδεμένη με ένα αρχείο σε έναν από τους καταλόγους σας. Εάν ένα αρχείο που είναι συνδεδεμένο με μια εγγραφή μετακινηθεί ή διαγραφεί εκτός του TagStudio, τότε θεωρείται αποσυνδεδεμένο.",
"entries.unlinked.relink.manual": "&Χειροκίνητη επανασύνδεση",
"entries.unlinked.relink.title": "Επανασύνδεση εγγραφών",
"entries.unlinked.remove": "Αφαίρεση αποσυνδεδεμένων εγγραφών",
"entries.unlinked.remove_alt": "Α&φαίρεση αποσυνδεδεμένων εγγραφών",
"entries.unlinked.scanning": "Σάρωση βιβλιοθήκης για αποσυνδεδεμένες εγγραφές...",
"entries.unlinked.search_and_relink": "&Αναζήτηση && Επανασύνδεση",
"entries.unlinked.title": "Διόρθωση αποσυνδεδεμένων εγγραφών",
"entries.unlinked.unlinked_count": "Αποσυνδεδεμένες εγγραφές: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+26 -16
View File
@@ -3,13 +3,13 @@
"about.config_path": "Config Path",
"about.description": "TagStudio is a photo and 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.",
"about.documentation": "Documentation",
"about.library_version": "Library Format",
"about.module.found": "Found",
"about.modules.title": "Optional Modules",
"about.title": "About TagStudio",
"about.version": "Version",
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
"about.website": "Website",
"about.library_version": "Library Format",
"app.git": "Git Commit",
"app.nightly": "Nightly",
"app.pre_release": "Pre-Release",
@@ -62,16 +62,14 @@
"entries.remove.plural.confirm": "Are you sure you want to remove these <b>{count}</b> entries from your library? No files on disk will be deleted.",
"entries.remove.singular.confirm": "Are you sure you want to remove this entry from your library? No files on disk will be deleted.",
"entries.running.dialog.new_entries": "Adding {total} New File Entries…",
"entries.running.dialog.title": "Adding New File Entries",
"entries.tags": "Tags",
"entries.unlinked.description": "Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.<br><br>Unlinked entries may be automatically relinked via searching your directories or deleted if desired.",
"entries.unlinked.relink.attempting": "Attempting to Relink {index}/{unlinked_count} Entries, {fixed_count} Successfully Relinked",
"entries.unlinked.description": "Unlinked entries are file entries that can no longer find their original file on disk. Most entries are automatically relinked during a library sync, however some cases require manual review.",
"entries.unlinked.description.ambiguous": "For unlinked entries that have ambiguous matches to multiple files in your library, you may manually choose how they get relinked.",
"entries.unlinked.description.deleted": "When you delete files outside of TagStudio, their associated entries become unlinked. You may manually delete any unlinked entries at your own discretion.",
"entries.unlinked.relink.manual": "&Manual Relink",
"entries.unlinked.relink.title": "Relinking Entries",
"entries.unlinked.remove": "Remove Unlinked Entries",
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
"entries.unlinked.scanning": "Scanning Library for Unlinked Entries…",
"entries.unlinked.search_and_relink": "&Search && Relink",
"entries.unlinked.remove": "Delete Unlinked Entries",
"entries.unlinked.remove_alt": "&Delete Unlinked Entries",
"entries.unlinked.review": "Manual &Review",
"entries.unlinked.title": "Fix Unlinked Entries",
"entries.unlinked.unlinked_count": "Unlinked Entries: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -121,6 +119,7 @@
"file.open_location.mac": "Reveal in Finder",
"file.open_location.windows": "Show in File Explorer",
"file.path": "File Path",
"file.size": "File Size",
"folders_to_tags.close_all": "Close All",
"folders_to_tags.converting": "Converting folders to Tags",
"folders_to_tags.description": "Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to.",
@@ -253,11 +252,22 @@
"library_object.slug_required": "ID Slug (Required)",
"library.missing": "Library Location is Missing",
"library.name": "Library",
"library.refresh.scanning_preparing": "Scanning Directories for New Files…\nPreparing…",
"library.refresh.scanning.plural": "Scanning Directories for New Files…\n{searched_count} Files Searched, {found_count} New Files Found",
"library.refresh.scanning.singular": "Scanning Directories for New Files…\n{searched_count} File Searched, {found_count} New Files Found",
"library.refresh.title": "Refreshing Directories",
"library.scan_library.title": "Scanning Library",
"library.sync.complete": "Library Synced",
"library.sync.disabled_notice": "\"{sync_setting}\" Is Currently Turned Off",
"library.sync.new_files_banner.plural": "{count} New Files Found",
"library.sync.new_files_banner.singular": "{count} New File Found",
"library.sync.open_settings": "Open &Settings",
"library.sync.preparing": "Preparing to Sync…",
"library.sync.relinked_banner.plural": "{count} Files Automatically Relinked",
"library.sync.relinked_banner.singular": "{count} File Automatically Relinked",
"library.sync.relinked_suffix": "({count} Automatically Relinked)",
"library.sync.remaining_unlinked_suffix": "({count} Still Unlinked)",
"library.sync.repairing": "Repairing Entries…",
"library.sync.scanning": "Discovering Files… ({found_count} New Out of {searched_count})",
"library.sync.unlinked_banner.plural": "{count} Unlinked Entries Found",
"library.sync.unlinked_banner.singular": "{count} Unlinked Entry Found",
"library.sync.updating.label": "Syncing {idx}/{total} Entries…",
"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",
@@ -280,9 +290,9 @@
"menu.file.open_create_library": "&Open/Create Library",
"menu.file.open_library": "Open Library",
"menu.file.open_recent_library": "Open Recent",
"menu.file.refresh_directories": "&Refresh Directories",
"menu.file.save_backup": "&Save Library Backup",
"menu.file.save_library": "Save Library",
"menu.file.sync_library": "&Sync Library",
"menu.help": "&Help",
"menu.help.about": "About",
"menu.macros": "&Macros",
@@ -335,10 +345,10 @@
"settings.library": "Library Settings",
"settings.localization": "Localization",
"settings.media": "Media",
"settings.open_library_on_start": "Open Library on Start",
"settings.open_library_on_start": "Open Last Library on Start",
"settings.page_size": "Page Size",
"settings.restart_required": "Please restart TagStudio for changes to take effect.",
"settings.scan_files_on_open": "Automatically Load New Files",
"settings.scan_files_on_open": "Sync Library on Open",
"settings.show_filenames_in_grid": "Show Filenames in Grid",
"settings.show_recent_libraries": "Show Recent Libraries",
"settings.splash.label": "Splash Screen",
+1 -4
View File
@@ -62,14 +62,11 @@
"entries.running.dialog.new_entries": "Añadiendo {total} nuevas entradas de archivos...",
"entries.running.dialog.title": "Añadiendo las nuevas entradas de archivos",
"entries.tags": "Etiquetas",
"entries.unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de tus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado. <br><br>Las entradas no vinculadas se pueden volver a vincular automáticamente mediante una búsqueda en tus directorios, el usuario puede eliminarlas si así lo desea.",
"entries.unlinked.relink.attempting": "Intentando volver a vincular {index}/{unlinked_count} Entradas, {fixed_count} Reenlazado correctamente",
"entries.unlinked.description": "Cada entrada de la biblioteca está vinculada a un archivo en uno de tus directorios. Si un archivo vinculado a una entrada se mueve o se elimina fuera de TagStudio, se considerará desvinculado.",
"entries.unlinked.relink.manual": "&Reenlace manual",
"entries.unlinked.relink.title": "Volver a vincular las entradas",
"entries.unlinked.remove": "Eliminar Entradas No Vinculadas",
"entries.unlinked.remove_alt": "Quit&ar entradas desvinculadas",
"entries.unlinked.scanning": "Buscando entradas no enlazadas en la biblioteca...",
"entries.unlinked.search_and_relink": "&Buscar && Revincular",
"entries.unlinked.title": "Corregir entradas no vinculadas",
"entries.unlinked.unlinked_count": "Entradas no vinculadas: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -58,14 +58,11 @@
"entries.running.dialog.new_entries": "Lisätään {total} uutta tiedosto merkintää...",
"entries.running.dialog.title": "Lisätään uudet tiedosto merkinnät",
"entries.tags": "Tunnisteet",
"entries.unlinked.description": "Jokainen kirjastomerkintä on linkitetty tiedostoon jossakin hakemistoistasi. Jos merkintään linkitetty tiedosto siirretään tai poistetaan TagStudion ulkopuolelle, sitä pidetään linkittämättömänä.<br><br>Linkittämättömät merkinnät voidaan linkittää automaattisesti uudelleen hakemistojen haun avulla tai poistaa haluttaessa.",
"entries.unlinked.relink.attempting": "Yritetään linkittää uudelleen {index}/{unlinked_count} merkintää, {fixed_count} uudelleenlinkitys onnistui",
"entries.unlinked.description": "Jokainen kirjastomerkintä on linkitetty tiedostoon jossakin hakemistoistasi. Jos merkintään linkitetty tiedosto siirretään tai poistetaan TagStudion ulkopuolelle, sitä pidetään linkittämättömänä.",
"entries.unlinked.relink.manual": "&Manual Relink",
"entries.unlinked.relink.title": "Uudelleen yhdistetään merkintöjä",
"entries.unlinked.remove": "Poista linkittämättömät merkinnät",
"entries.unlinked.remove_alt": "Remo&ve Unlinked Entries",
"entries.unlinked.scanning": "Skannataan kirjastosta linkittämättömiä merkintöjä...",
"entries.unlinked.search_and_relink": "&Search && Relink",
"entries.unlinked.title": "Korjaa linkittämättömät merkinnät",
"entries.unlinked.unlinked_count": "Linkittämättömät merkinnät: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -49,12 +49,9 @@
"entries.running.dialog.new_entries": "Dinadagdag ang {total} Mga Bagong Entry ng File…",
"entries.running.dialog.title": "Dinadagdag ang Mga Bagong Entry ng File",
"entries.tags": "Mga Tag",
"entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.<br><br>Ang mga naka-unlink na entry ay maaring i-link muli sa pamamagitan ng paghahanap sa iyong mga direktoryo o buburahin kung ninanais.",
"entries.unlinked.relink.attempting": "Sinusubukang i-link muli ang {index}/{unlinked_count} Mga Entry, {fixed_count} Matagumpay na na-link muli",
"entries.unlinked.description": "Ang bawat entry sa library ay naka-link sa isang file sa isa sa iyong mga direktoryo. Kung ang isang file na naka-link sa isang entry ay inilipat o binura sa labas ng TagStudio, ito ay isinasaalang-alang na naka-unlink.",
"entries.unlinked.relink.manual": "&Manwal na Pag-link Muli",
"entries.unlinked.relink.title": "Nili-link muli ang Mga Entry",
"entries.unlinked.scanning": "Sina-scan ang Library para sa Mga Naka-unlink na Entry…",
"entries.unlinked.search_and_relink": "&Maghanap at Mag-link muli",
"entries.unlinked.title": "Ayusin ang Mga Naka-unlink na Entry",
"entries.unlinked.unlinked_count": "Mga Naka-unlink na Entry: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -3
View File
@@ -63,10 +63,8 @@
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier…",
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
"entries.tags": "Tags",
"entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. <br><br>Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.",
"entries.unlinked.relink.attempting": "Tentative de Reliage de {index}/{unlinked_count} Entrées, {fixed_count} ont été Reliées avec Succès",
"entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié.",
"entries.unlinked.relink.manual": "&Reliage Manuel",
"entries.unlinked.relink.title": "Reliage des Entrées",
"entries.unlinked.remove": "Supprimer les entrées non liées",
"entries.unlinked.remove_alt": "Supprim&er les entrées non liées",
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées…",
+1 -4
View File
@@ -64,14 +64,11 @@
"entries.running.dialog.new_entries": "{total} új elem felvétele folyamatban…",
"entries.running.dialog.title": "Új elemek felvétele",
"entries.tags": "Címkék",
"entries.unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül áthelyezésre vagy törésre kerül, akkor ez a kapcsolat megszakad.<br><br>Ezeket a kapcsolat nélküli elemeket a program megpróbálhatja automatikusan megkeresni, de Ön is kézileg újra összekapcsolhatja vagy törölheti őket.",
"entries.unlinked.relink.attempting": "{unlinked_count}/{index} elem újra összekapcsolásának megkísérlése; {fixed_count} elem sikeresen újra összekapcsolva",
"entries.unlinked.description": "A könyvtár minden eleme egy fájllal van összekapcsolva a számítógépen. Ha egy kapcsolt fájl a TagSudión kívül áthelyezésre vagy törésre kerül, akkor ez a kapcsolat megszakad.",
"entries.unlinked.relink.manual": "Új&ra összekapcsolás kézileg",
"entries.unlinked.relink.title": "Elemek újra összekapcsolása",
"entries.unlinked.remove": "Kapcsolat nélküli elemek eltávolítása",
"entries.unlinked.remove_alt": "&Kapcsolat nélküli elemek eltávolítása",
"entries.unlinked.scanning": "Kapcsolat nélküli elemek keresése a könyvtárban…",
"entries.unlinked.search_and_relink": "&Keresés és újra összekapcsolás",
"entries.unlinked.title": "Kapcsolat nélküli elemek javítása",
"entries.unlinked.unlinked_count": "Kapcsolat nélküli elemek: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -58,14 +58,11 @@
"entries.running.dialog.new_entries": "Aggiundendo {total} Nuove Voci di File...",
"entries.running.dialog.title": "Aggiungendo Nuove Voci di File",
"entries.tags": "Etichette",
"entries.unlinked.description": "Ogni voce della biblioteca è collegata ad un file in una delle tue cartelle. Se un file collegato ad una voce viene spostato o eliminito al di fuori di TagStudio, la voce corrispondente viene considerata scollegata.<br><br>Le voci scollegate possono essere ricollegate automaticamente attraverso la ricerca nelle tue cartelle oppure cancellate se lo si desidera.",
"entries.unlinked.relink.attempting": "Tentativo di Ricollegare {index}/{unlinked_count} Voci, {fixed_count} Ricollegate con Successo",
"entries.unlinked.description": "Ogni voce della biblioteca è collegata ad un file in una delle tue cartelle. Se un file collegato ad una voce viene spostato o eliminito al di fuori di TagStudio, la voce corrispondente viene considerata scollegata.",
"entries.unlinked.relink.manual": "Ricollegamento &Manuale",
"entries.unlinked.relink.title": "Ricollegamento Voci",
"entries.unlinked.remove": "Rimuovi Voci non Collegate",
"entries.unlinked.remove_alt": "Rimuo&vi Voci non Collegate",
"entries.unlinked.scanning": "Scansionando la Biblioteca in cerca di Voci non Collegate...",
"entries.unlinked.search_and_relink": "&Ricerca && Ricollega",
"entries.unlinked.title": "Correggi Voci non Collegate",
"entries.unlinked.unlinked_count": "Voci non Collegate: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -63,14 +63,11 @@
"entries.running.dialog.new_entries": "{total} 件の新しいファイル エントリを追加しています…",
"entries.running.dialog.title": "新しいファイルエントリを追加",
"entries.tags": "タグ",
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。<br><br>リンク切れのエントリは、ディレクトリを検索して自動的に再リンクすることも、必要に応じて削除することもできます。",
"entries.unlinked.relink.attempting": "{unlinked_count} 件中 {index} 件のエントリを再リンク中、{fixed_count} 件を正常に再リンクしました",
"entries.unlinked.description": "ライブラリの各エントリは、ディレクトリ内のファイルにリンクされています。エントリにリンクされたファイルが TagStudio 以外で移動または削除された場合、そのエントリはリンク切れとして扱われます。",
"entries.unlinked.relink.manual": "手動で再リンク(&M)",
"entries.unlinked.relink.title": "エントリの再リンク",
"entries.unlinked.remove": "リンク切れのエントリを削除",
"entries.unlinked.remove_alt": "リンク切れのエントリを削除(&V)",
"entries.unlinked.scanning": "リンク切れのエントリをライブラリ内でスキャンしています…",
"entries.unlinked.search_and_relink": "検索して再リンク(&S)",
"entries.unlinked.title": "リンク切れのエントリを修正",
"entries.unlinked.unlinked_count": "リンク切れのエントリ数: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -57,12 +57,9 @@
"entries.running.dialog.new_entries": "Legger til {total} Nye Filoppføringer...",
"entries.running.dialog.title": "Legger til Nye Filoppføringer",
"entries.tags": "Etiketter",
"entries.unlinked.description": "Hver biblioteksoppføring er koblet til en fil i en av dine mapper. Hvis en fil koblet til en oppføring er flyttet eller slettet utenfor TagStudio, så er den sett på som frakoblet.<br><br>Frakoblede oppføringer kan bli automatisk gjenkoblet ved å søke i mappene dine eller slettet om det er ønsket.",
"entries.unlinked.relink.attempting": "Forsøker å Gjenkoble {index}/{unlinked_count} Oppføringer, {fixed_count} Klart Gjenkoblet",
"entries.unlinked.description": "Hver biblioteksoppføring er koblet til en fil i en av dine mapper. Hvis en fil koblet til en oppføring er flyttet eller slettet utenfor TagStudio, så er den sett på som frakoblet.",
"entries.unlinked.relink.manual": "&Manuell Gjenkobling",
"entries.unlinked.relink.title": "Gjenkobler Oppføringer",
"entries.unlinked.scanning": "Skanner bibliotek for ulenkede oppføringer …",
"entries.unlinked.search_and_relink": "&Søk && Gjenkobl",
"entries.unlinked.title": "Fiks ulenkede oppføringer",
"entries.unlinked.unlinked_count": "Frakoblede Oppføringer: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -49,12 +49,9 @@
"entries.running.dialog.new_entries": "Dodawanie {total} nowych wpisów plików...",
"entries.running.dialog.title": "Dodawanie nowych wpisów plików",
"entries.tags": "Tagi",
"entries.unlinked.description": "Każdy wpis w bibliotece jest połączony z plikiem w jednym z twoich katalogów. Jeśli połączony plik jest przeniesiony poza TagStudio albo usunięty to jest uważany za odłączony.<br><br>Odłączone wpisy mogą być automatycznie połączone ponownie przez szukanie twoich katalogów, ręczne ponowne łączenie przez użytkownika lub usunięte jeśli zajdzie taka potrzeba.",
"entries.unlinked.relink.attempting": "Próbowanie ponownego łączenia {index}/{unlinked_count} wpisów, {fixed_count} poprawnie połączono ponownie",
"entries.unlinked.description": "Każdy wpis w bibliotece jest połączony z plikiem w jednym z twoich katalogów. Jeśli połączony plik jest przeniesiony poza TagStudio albo usunięty to jest uważany za odłączony.",
"entries.unlinked.relink.manual": "&Ręczne ponowne łączenie",
"entries.unlinked.relink.title": "Ponowne łączenie wpisów",
"entries.unlinked.scanning": "Skanowanie biblioteki dla odłączonych wpisów...",
"entries.unlinked.search_and_relink": "&Wyszukaj && Zalinkuj ponownie",
"entries.unlinked.title": "Napraw odłączone wpisy",
"entries.unlinked.unlinked_count": "Odłączone wpisy: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -54,13 +54,10 @@
"entries.running.dialog.new_entries": "A Adicionar {total} Novos Registos de Ficheiros...",
"entries.running.dialog.title": "A Adicionar Novos Registos de Ficheiros",
"entries.tags": "Tags",
"entries.unlinked.description": "Cada registo na biblioteca faz referência à um ficheiro numa das suas pastas. Se um ficheiro referenciado à uma entrada for movido ou apagado fora do TagStudio, ele é depois considerado não-referenciado.<br><br>Registos não-referenciados podem ser automaticamente referenciados por pesquisas nos seus diretórios, manualmente pelo utilizador, ou apagado se for desejado.",
"entries.unlinked.relink.attempting": "A tentar referenciar {index}/{unlinked_count} Registos, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.description": "Cada registo na biblioteca faz referência à um ficheiro numa das suas pastas. Se um ficheiro referenciado à uma entrada for movido ou apagado fora do TagStudio, ele é depois considerado não-referenciado.",
"entries.unlinked.relink.manual": "&Referência Manual",
"entries.unlinked.relink.title": "A Referenciar Registos",
"entries.unlinked.remove_alt": "Remover Entradas sem Conexões",
"entries.unlinked.scanning": "A escanear biblioteca por registos não referenciados...",
"entries.unlinked.search_and_relink": "&Pesquisar && Referenciar",
"entries.unlinked.title": "Corrigir Registos Não Referenciados",
"entries.unlinked.unlinked_count": "Registos Não Referenciados: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -58,14 +58,11 @@
"entries.running.dialog.new_entries": "Adicionando {total} Novos Registros de Arquivos...",
"entries.running.dialog.title": "Adicionando Novos Registros de Arquivos",
"entries.tags": "Tags",
"entries.unlinked.description": "Cada registro na biblioteca faz referência à um arquivo em uma de suas pastas. Se um arquivo referenciado à uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não-referenciado.<br><br>Registros não-referenciados podem ser automaticamente referenciados por buscas nos seus diretórios, manualmente pelo usuário, ou deletado se for desejado.",
"entries.unlinked.relink.attempting": "Tentando referenciar {index}/{unlinked_count} Registros, {fixed_count} Referenciados com Sucesso",
"entries.unlinked.description": "Cada registro na biblioteca faz referência à um arquivo em uma de suas pastas. Se um arquivo referenciado à uma entrada for movido ou deletado fora do TagStudio, ele é então considerado não-referenciado.",
"entries.unlinked.relink.manual": "&Referência Manual",
"entries.unlinked.relink.title": "Referenciando Registros",
"entries.unlinked.remove": "Remover Registros Não Vinculados",
"entries.unlinked.remove_alt": "Remover Entradas sem Conexões",
"entries.unlinked.scanning": "Escaneando bibliotecada em busca de registros não referenciados...",
"entries.unlinked.search_and_relink": "&Buscar && Referenciar",
"entries.unlinked.title": "Corrigir Registros Não Referenciados",
"entries.unlinked.unlinked_count": "Registros Não Referenciados: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -58,14 +58,11 @@
"entries.running.dialog.new_entries": "Nasii {total} neo shiruzmakaban fu mlafu ima...",
"entries.running.dialog.title": "Nasii neo shiruzmakaban fu mlafu ima",
"entries.tags": "Festaretol",
"entries.unlinked.description": "Tont shiruzmakaban fu mlafuhuomi tsunagajena na mlafu ine joku mlafukaban fu du. Li mlafu tsunagajena na shiruzmakaban ugokijena os kestejena ekso TagStudio, sit sore kntsunagajena.<br><br>Tsunaganaijena shiruzmakaban deki tsunaga gen na suha per mlafukaban fu du os keste li du vil.",
"entries.unlinked.relink.attempting": "Iskat ima na tsunaga gen {index}/{unlinked_count} shiruzmakaban, {fixed_count} tsunagajena gen",
"entries.unlinked.description": "Tont shiruzmakaban fu mlafuhuomi tsunagajena na mlafu ine joku mlafukaban fu du. Li mlafu tsunagajena na shiruzmakaban ugokijena os kestejena ekso TagStudio, sit sore kntsunagajena.",
"entries.unlinked.relink.manual": "&Tsunaga gen mit hant",
"entries.unlinked.relink.title": "Tsunaga shiruzmakaban gen",
"entries.unlinked.remove": "Keste kntsunagajena shiruzmakaban",
"entries.unlinked.remove_alt": "Keste kntsunagajena shiruzmakaban (&v)",
"entries.unlinked.scanning": "Taskame mlafuhuomi ima grun kntsunagajena shiruzmakaban...",
"entries.unlinked.search_and_relink": "&Suha &&Tsunaga gen",
"entries.unlinked.title": "Fiks kntsunagajena shiruzmakaban",
"entries.unlinked.unlinked_count": "Kntsunagajena shiruzmakaban: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -60,13 +60,10 @@
"entries.running.dialog.new_entries": "Добавление {total} новых записей...",
"entries.running.dialog.title": "Добавление новых записей",
"entries.tags": "Теги",
"entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".<br><br>Откреплённые записи могут быть прикреплены обратно автоматически, либо же удалены если в них нет надобности.",
"entries.unlinked.relink.attempting": "Попытка перепривязать {index}/{unlinked_count} записей, {fixed_count} привязано успешно",
"entries.unlinked.description": "Каждая запись в библиотеке привязана к файлу, находящегося внутри той или иной папки. Если файл, к которому была привязана запись, был удалён или перемещён без использования TagStudio, то запись становиться \"откреплённой\".",
"entries.unlinked.relink.manual": "&Ручная привязка",
"entries.unlinked.relink.title": "Привязка записей",
"entries.unlinked.remove": "Удалить откреплённые записи",
"entries.unlinked.scanning": "Сканирование библиотеки на наличие откреплённых записей...",
"entries.unlinked.search_and_relink": "&Поиск и привязка",
"entries.unlinked.title": "Исправить откреплённые записи",
"entries.unlinked.unlinked_count": "Откреплённых записей: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -59,13 +59,10 @@
"entries.running.dialog.title": "Lägger Till Nya Filposter",
"entries.tags": "Etiketter",
"entries.unlinked.description": "Varje post i biblioteket är länkad till en fil i en av dina kataloger. Om en fil länkad till en post är flyttad eller borttagen utanför TagStudio blir den olänkad. Olänkade poster kan automatiskt bli omlänkade genom att söka genom dina kataloger, manuellt omlänkade av användaren eller tas bort om så önskas.",
"entries.unlinked.relink.attempting": "Försöker att länka om {index}/{unlinked_count} Poster, {fixed_count} Lyckades Länkas Om",
"entries.unlinked.relink.manual": "Länka om manuellt",
"entries.unlinked.relink.title": "Länkar om poster",
"entries.unlinked.remove": "Ta Bort Olänkade Poster",
"entries.unlinked.remove_alt": "&Ta Bort Olänkade Poster",
"entries.unlinked.scanning": "Skannar bibliotek efter olänkade poster...",
"entries.unlinked.search_and_relink": "Sök && Länka om",
"entries.unlinked.title": "Fixa olänkade poster",
"entries.unlinked.unlinked_count": "Olänkade Poster: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -59,13 +59,10 @@
"entries.running.dialog.title": "புதிய கோப்பு உள்ளீடுகளைச் சேர்ப்பது",
"entries.tags": "குறிச்சொற்கள்",
"entries.unlinked.description": "ஒவ்வொரு நூலக நுழைவும் உங்கள் கோப்பகங்களில் ஒன்றில் ஒரு கோப்போடு இணைக்கப்பட்டுள்ளது. ஒரு நுழைவுடன் இணைக்கப்பட்ட ஒரு கோப்பு முகவரிச்சீட்டுஅறைக்கு வெளியே நகர்த்தப்பட்டால் அல்லது நீக்கப்பட்டால், அது பின்னர் இணைக்கப்படாததாகக் கருதப்படுகிறது.",
"entries.unlinked.relink.attempting": "{index}/{unlinked_count} உள்ளீடுகளை மீண்டும் இணைக்க முயற்சிக்கிறது, {fixed_count} மீண்டும் இணைக்கப்பட்டது",
"entries.unlinked.relink.manual": "& கையேடு மறுபரிசீலனை",
"entries.unlinked.relink.title": "உள்ளீடுகள் மீண்டும் இணைக்கப்படுகின்றது",
"entries.unlinked.remove": "இணைக்கப்படாத உள்ளீடுகளை அகற்று",
"entries.unlinked.remove_alt": "இணைக்கப்படாத உள்ளீடுகளை அகற்று&விடு",
"entries.unlinked.scanning": "இணைக்கப்படாத நுழைவுகளை புத்தககல்லரியில் சோதனை செய்யப்படுகிறது...",
"entries.unlinked.search_and_relink": "& தேடல் && relink",
"entries.unlinked.title": "இணைக்கப்படாத உள்ளீடுகளைச் சரிசெய்யவும்",
"entries.unlinked.unlinked_count": "இணைக்கப்படாத உள்ளீடுகள்: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status} <br> {ffprobe}: {ffprobe_status}",
@@ -57,14 +57,11 @@
"entries.running.dialog.new_entries": "mi pana e lipu sin {total}...",
"entries.running.dialog.title": "mi pana e lipu sin",
"entries.tags": "poki",
"entries.unlinked.description": "ijo ale li jo e ijo lon tomo sina. ona li tawa anu weka lon ilo TagStudio ala la, ona li jo ala e ijo lon.<br><br>ijo pi ijo lon li ken alasa lon tomo li ken kama jo e ijo lon. ante la sina ken weka e ona.",
"entries.unlinked.relink.attempting": "mi o pana e ijo lon tawa ijo {index}/{unlinked_count}. mi pana e ijo lon tawa ijo {fixed_count}",
"entries.unlinked.description": "ijo ale li jo e ijo lon tomo sina. ona li tawa anu weka lon ilo TagStudio ala la, ona li jo ala e ijo lon.",
"entries.unlinked.relink.manual": "sina o pana e ijo lon tawa ijo (&M)",
"entries.unlinked.relink.title": "mi pana e ijo lon tawa ijo",
"entries.unlinked.remove": "o weka e ijo pi ijo lon ala",
"entries.unlinked.remove_alt": "o weka e ijo pi ijo lon ala (&V)",
"entries.unlinked.scanning": "mi o alasa e ijo pi ijo lon ala...",
"entries.unlinked.search_and_relink": "o ala&sa o pana e ijo lon tawa ijo",
"entries.unlinked.title": "o pona e ijo pi ijo lon ala",
"entries.unlinked.unlinked_count": "ijo pi ijo lon ala: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+1 -4
View File
@@ -48,12 +48,9 @@
"entries.running.dialog.new_entries": "{total} Yeni Dosya Kaydı Ekleniyor...",
"entries.running.dialog.title": "Yeni Dosya Kayıtları Ekleniyor",
"entries.tags": "Etiketler",
"entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopmuş olarak sayılır.<br><br>Kopmuş kayıtlar dizinlerinizde arama yapılırken otomatik olarak tekrar eşleştirilebilir, manuel olarak sizin tarafınızdan eşleştirilebilir veya isteğiniz üzere silinebilir.",
"entries.unlinked.relink.attempting": "{index}/{unlinked_count} Kayıt Yeniden Eşleştirilmeye Çalışılıyor, {fixed_count} Başarıyla Yeniden Eşleştirildi",
"entries.unlinked.description": "Kütüphanenizdeki her bir kayıt, dizinlerinizden bir tane dosya ile eşleştirilmektedir. Eğer bir kayıta bağlı dosya TagStudio dışında taşınır veya silinirse, o dosya artık kopmuş olarak sayılır.",
"entries.unlinked.relink.manual": "&Manuel Yeniden Eşleştirme",
"entries.unlinked.relink.title": "Kayıtlar Yeniden Eşleştiriliyor",
"entries.unlinked.scanning": "Kütüphane, Kopmuş Kayıtlar için Taranıyor...",
"entries.unlinked.search_and_relink": "&Ara && Yeniden Eşleştir",
"entries.unlinked.title": "Kopmuş Kayıtları Düzelt",
"entries.unlinked.unlinked_count": "Kopmuş Kayıtlar: {count}",
"field.add": "Ek Bilgi Ekle",
@@ -57,14 +57,11 @@
"entries.running.dialog.new_entries": "正在加入 {total} 个新文件项目...",
"entries.running.dialog.title": "正在加入新文件项目",
"entries.tags": "标签",
"entries.unlinked.description": "每个仓库条目都链接到一个目录中的文件。如果链接到某个条目的文件在TagStudio之外被移动或删除,则会被视为未链接。<br><br>未链接的条目可能会通过搜索目录自动重新链接,或者根据需要删除。",
"entries.unlinked.relink.attempting": "正在尝试重新链接 {index}/{unlinked_count} 个项目, {fixed_count} 个项目成功重链",
"entries.unlinked.description": "每个仓库条目都链接到一个目录中的文件。如果链接到某个条目的文件在TagStudio之外被移动或删除,则会被视为未链接。",
"entries.unlinked.relink.manual": "手动重新链接(&m)",
"entries.unlinked.relink.title": "正在重新链接项目",
"entries.unlinked.remove": "删除未链接项目",
"entries.unlinked.remove_alt": "删除未链接项目(&v)",
"entries.unlinked.scanning": "正在扫描仓库以寻找未链接的项目...",
"entries.unlinked.search_and_relink": "搜索并重新链接(&s)",
"entries.unlinked.title": "修复未链接的项目",
"entries.unlinked.unlinked_count": "未链接的项目: {count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
@@ -58,14 +58,11 @@
"entries.running.dialog.new_entries": "正在加入 {total} 個新檔案項目...",
"entries.running.dialog.title": "正在加入新檔案項目",
"entries.tags": "標籤",
"entries.unlinked.description": "每個文件庫的項目都連接到您的其中一個檔案,如果一個已連接的檔案被刪除或移出 TagStudio,那麼這個項目會被歸類為「未連接」。<br><br>您可以透過搜尋您的檔案來讓未連接的項目自動重新連接,或者自動刪除這些未連接項目。",
"entries.unlinked.relink.attempting": "正在嘗試重新連接 {index}/{unlinked_count} 個項目,已成功重新連接 {fixed_count} 個",
"entries.unlinked.description": "每個文件庫的項目都連接到您的其中一個檔案,如果一個已連接的檔案被刪除或移出 TagStudio,那麼這個項目會被歸類為「未連接」。",
"entries.unlinked.relink.manual": "手動重新連接 (&M)",
"entries.unlinked.relink.title": "正在重新連接",
"entries.unlinked.remove": "刪除未連接項目",
"entries.unlinked.remove_alt": "刪除未連接項目 (&V)",
"entries.unlinked.scanning": "正在掃描文件庫中的未連接項目...",
"entries.unlinked.search_and_relink": "搜尋並重新連接 (&S)",
"entries.unlinked.title": "修復未連接項目",
"entries.unlinked.unlinked_count": "未連接項目:{count}",
"ffmpeg.missing.status": "{ffmpeg}: {ffmpeg_status}<br>{ffprobe}: {ffprobe_status}",
+129
View File
@@ -0,0 +1,129 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
from pathlib import Path
from wcmatch import glob
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
def matches(patterns: list[str], path: str) -> bool:
return glob.compile(ignore_to_glob(patterns), flags=PATH_GLOB_FLAGS).match(path)
def test_ignore_to_glob_does_not_crash_on_negated_root_anchored_pattern():
"""A pattern like "!/keep.txt" (negated + root-anchored) must not raise an error."""
patterns = ["*.txt", "!/keep_this.txt"]
glob_patterns = ignore_to_glob(patterns)
assert matches(patterns, "keep_this.txt") is False
assert matches(patterns, "sub/keep_this.txt") is True
assert matches(patterns, "other.txt") is True
assert glob_patterns
def test_ignore_to_glob_root_anchored_directory_matches_contents():
"""A root-anchored pattern for a directory (e.g. "/Downloads/") must exclude its contents."""
patterns = ["/Downloads/"]
assert matches(patterns, "Downloads/file.txt") is True
assert matches(patterns, "sub/Downloads/file.txt") is False
def test_ignore_to_glob_root_anchored_directory_without_trailing_slash():
"""A root-anchored pattern without a trailing slash still excludes contents."""
patterns = ["/Downloads"]
assert matches(patterns, "Downloads") is True
assert matches(patterns, "Downloads/file.txt") is True
assert matches(patterns, "sub/Downloads/file.txt") is False
def test_ignore_to_glob_non_rooted_directory_pattern():
"""A bare/non-rooted directory pattern matches at every depth."""
patterns = ["Dev/"]
assert matches(patterns, "Dev/file.txt") is True
assert matches(patterns, "sub/Dev/file.txt") is True
assert matches(patterns, "Dev") is False
assert matches(patterns, "SomeDevFile.txt") is False
def test_ignore_to_glob_leading_globstar_matches_zero_directories():
""" "**/foo" must also match a root-level "foo", not just nested ones.
NOTE: wcmatch's "**" only matches "one or more" directories while
gitignore/ripgrep matches "zero or more", which is the target behavior.
"""
patterns = ["**/foo"]
assert matches(patterns, "foo") is True
assert matches(patterns, "a/foo") is True
assert matches(patterns, "a/b/foo") is True
assert matches(patterns, "foobar") is False
def test_ignore_to_glob_middle_globstar_matches_zero_directories():
""" "a/**/b" must also match "a/b"."""
patterns = ["a/**/b"]
assert matches(patterns, "a/b") is True
assert matches(patterns, "a/x/b") is True
assert matches(patterns, "a/x/y/b") is True
assert matches(patterns, "a/bx") is False
assert matches(patterns, "a/b/file.txt") is True
assert matches(patterns, "a/x/b/file.txt") is True
assert matches(patterns, "a/x/y/b/file.txt") is True
assert matches(patterns, "a/bx/file.txt") is False
def test_ignore_to_glob_escaped_special_characters():
"""A backslash before "#" or "!" must escape these characters."""
assert matches(["\\#hashtag.jpg"], "#hashtag.jpg") is True
assert matches(["\\#hashtag.jpg"], "hashtag.jpg") is False
assert matches(["\\!wowee.jpg"], "!wowee.jpg") is True
assert matches(["\\!wowee.jpg"], "wowee.jpg") is False
def test_ignore_to_glob_output_has_no_duplicates():
"""Output must be deduplicated."""
glob_patterns = ignore_to_glob(["*.jpg", "Photos/", "**/foo"])
assert len(glob_patterns) == len(set(glob_patterns))
def test_single_asterisk_does_not_match_slash():
"""A single "*" must not match a single "/".
fnmatch will still match "*" to a "/", when gitignore and wcmatch.glob will not.
"""
patterns = ["Images/*.png"]
assert matches(patterns, "Images/mario.png") is True
assert matches(patterns, "Images/Mario/cat.png") is False
def test_negation_does_not_extend_to_deeper_subfolder():
"""A negation must not extend into a deeper subfolder its pattern doesn't match."""
patterns = ["*.jpg", "!Photos/*.jpg", "Photos/Private/*.jpg"]
assert matches(patterns, "a.jpg") is True
assert matches(patterns, "Photos/a.jpg") is False
assert matches(patterns, "Photos/Private/a.jpg") is True
def test_ignore_file_preserves_escaped_trailing_space(tmp_path: Path):
"""An escaped trailing space must not be stripped."""
ts_ignore = tmp_path / ".ts_ignore"
ts_ignore.write_bytes(b"foo\\ \nbar \n")
assert Ignore._load_ignore_file(ts_ignore) == ["foo\\ ", "bar"]
def test_ignore_file_preserves_leading_whitespace(tmp_path: Path):
"""Leading whitespace must not be stripped."""
ts_ignore = tmp_path / ".ts_ignore"
ts_ignore.write_bytes(b" baz\n")
assert Ignore._load_ignore_file(ts_ignore) == [" baz"]
def test_ignore_file_strips_crlf_line_ending(tmp_path: Path):
"""A Windows CRLF line ending must not become part of the pattern."""
ts_ignore = tmp_path / ".ts_ignore"
ts_ignore.write_bytes(b"qux\r\n")
assert Ignore._load_ignore_file(ts_ignore) == ["qux"]
+75 -5
View File
@@ -9,7 +9,7 @@ from tempfile import TemporaryDirectory
import pytest
import structlog
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
from tagstudio.core.library.alchemy.fields import (
DatetimeField,
TextField,
@@ -80,9 +80,50 @@ def test_library_add_file(library: Library):
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert not library.has_entry_with_path(entry.path)
assert library.get_entry_id_from_path(entry.path) == -1
assert library.add_entries([entry])
assert library.has_entry_with_path(entry.path)
assert library.get_entry_id_from_path(entry.path)
def test_path_cache_untouched_when_not_yet_built(library: Library):
"""Only `get_or_build_path_cache()` may build the path cache."""
assert library.path_cache is None
entry = Entry(path=Path("before_any_cache.txt"), fields=[])
library.add_entries([entry])
assert library.path_cache is None
def test_path_cache_self_maintained_by_add_entries(library: Library):
"""`add_entries()` must keep an already-built path cache up to date on its own."""
library.is_fs_case_sensitive = True
cache = library.get_or_build_path_cache()
assert Path("added_directly.txt") not in cache
entry = Entry(path=Path("added_directly.txt"), fields=[])
new_ids = library.add_entries([entry])
assert cache.get(Path("added_directly.txt")) == new_ids[0]
def test_path_cache_self_maintained_by_remove_entries(library: Library):
library.is_fs_case_sensitive = True
cache = library.get_or_build_path_cache()
entry = Entry(path=Path("to_remove.txt"), fields=[])
entry_id = library.add_entries([entry])[0]
assert cache.get(Path("to_remove.txt")) == entry_id
library.remove_entries([entry_id])
assert Path("to_remove.txt") not in cache
def test_path_cache_self_maintained_by_update_entry_path(library: Library):
library.is_fs_case_sensitive = True
cache = library.get_or_build_path_cache()
entry = Entry(path=Path("old_location.txt"), fields=[])
entry_id = library.add_entries([entry])[0]
assert library.update_entry_path(entry_id, Path("new_location.txt"))
assert Path("old_location.txt") not in cache
assert cache.get(Path("new_location.txt")) == entry_id
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
@@ -148,6 +189,35 @@ def test_entries_count(library: Library):
assert len(results) == 5
@pytest.mark.parametrize(
"sorting_mode",
[SortingModeEnum.DATE_CREATED, SortingModeEnum.DATE_MODIFIED, SortingModeEnum.FILE_SIZE],
)
def test_search_library_sorting(library: Library, sorting_mode: SortingModeEnum):
entries = [
Entry(
path=Path(f"sort_{i}.txt"),
fields=[],
date_created=float(i),
date_modified=float(i),
file_size=i,
)
for i in range(3)
]
new_ids = library.add_entries(entries)
assert len(new_ids) == 3
results = library.search_library(
BrowsingState.show_all()
.with_sorting_mode(sorting_mode)
.with_sorting_direction(ascending=True),
page_size=None,
)
sorted_new_ids = [entry_id for entry_id in results if entry_id in new_ids]
assert sorted_new_ids == new_ids
def test_parents_add(library: Library, generate_tag: Callable[..., Tag]):
# Given
tag: Tag = library.tags[0]
@@ -338,8 +408,8 @@ def test_merge_entries(library: Library):
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
assert library.merge_entries(entry_a_, entry_b_)
assert not library.has_entry_with_path(Path("a"))
assert library.has_entry_with_path(Path("b"))
assert library.get_entry_id_from_path(Path("a")) == -1
assert library.get_entry_id_from_path(Path("b"))
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))
-50
View File
@@ -1,50 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from tagstudio.core.constants import IGNORE_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.refresh import RefreshTracker
from tagstudio.core.utils.types import unwrap
CWD = Path(__file__).parent
@pytest.mark.parametrize("exclude_mode", [True, False])
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_new_files(library: Library, exclude_mode: bool):
library_dir = unwrap(library.library_dir)
# Given
registry = RefreshTracker(library=library)
library.included_files.clear()
(library_dir / "FOO.MD").touch()
(library_dir / IGNORE_NAME).write_text("*.md" if exclude_mode else "*\n!*.md")
# Test if the single file was added
list(registry.refresh_dir(library_dir, force_internal_tools=True))
assert set(registry.files_not_in_library) == set([Path(IGNORE_NAME), Path("FOO.MD")])
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_multi_byte_filenames(library: Library):
library_dir = unwrap(library.library_dir)
# Given
registry = RefreshTracker(library=library)
library.included_files.clear()
(library_dir / ".TagStudio").mkdir()
(library_dir / "こんにちは.txt").touch()
(library_dir / "emdash.txt").touch()
(library_dir / "apostrophe.txt").touch()
(library_dir / "umlaute äöü.txt").touch()
# Test if all files were added with their correct names and without exceptions
list(registry.refresh_dir(library_dir))
assert Path("こんにちは.txt") in registry.files_not_in_library
assert Path("emdash.txt") in registry.files_not_in_library
assert Path("apostrophe.txt") in registry.files_not_in_library
assert Path("umlaute äöü.txt") in registry.files_not_in_library
+81
View File
@@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
import os
from pathlib import Path
from tagstudio.core.library.scanners import _scan_with_internal_scanner
def test_scan_finds_normal_files(tmp_path: Path):
"""Normal files are found."""
(tmp_path / "a.txt").touch()
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.txt").touch()
results = set(_scan_with_internal_scanner(tmp_path, []))
assert results == {Path("a.txt"), Path("sub/b.txt")}
def test_scan_ignore_directory(tmp_path: Path):
"""An ignored directory ignores the files within it."""
(tmp_path / "keep.txt").touch()
(tmp_path / "ignore_dir").mkdir()
(tmp_path / "ignore_dir" / "skip.txt").touch()
results = set(_scan_with_internal_scanner(tmp_path, ["ignore_dir"]))
assert results == {Path("keep.txt")}
def test_scan_follows_symlinked_directory(tmp_path: Path):
"""A non-cyclic symlink directory must be followed."""
real_target = tmp_path / "original_dir"
real_target.mkdir()
(real_target / "photo.jpg").touch()
os.symlink(real_target, tmp_path / "symlink_dir", target_is_directory=True)
results = set(_scan_with_internal_scanner(tmp_path, []))
assert results == {Path("original_dir/photo.jpg"), Path("symlink_dir/photo.jpg")}
def test_scan_stop_infinite_symlink_cycle(tmp_path: Path):
"""A cyclic symlink shouldn't cause an infinite loop, and be truncated."""
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "file.txt").touch()
os.symlink(tmp_path, tmp_path / "sub" / "loop", target_is_directory=True)
results = set(_scan_with_internal_scanner(tmp_path, []))
assert results == {Path("sub/file.txt")}
def test_scan_handles_mutual_symlink_cycle(tmp_path: Path):
"""Two directories symlinking into each other must terminate."""
# NOTE: Example names are from pnpn packages that triggered this weird case while testing.
store = tmp_path / "node_modules" / ".pnpm"
a_real = store / "pkgA@1.0.0" / "node_modules" / "pkgA"
b_real = store / "pkgB@1.0.0" / "node_modules" / "pkgB"
a_real.mkdir(parents=True)
b_real.mkdir(parents=True)
(a_real / "index.js").touch()
(b_real / "index.js").touch()
(a_real / "node_modules").mkdir()
(b_real / "node_modules").mkdir()
os.symlink(a_real, tmp_path / "node_modules" / "pkgA", target_is_directory=True)
os.symlink(b_real, tmp_path / "node_modules" / "pkgB", target_is_directory=True)
os.symlink(b_real, a_real / "node_modules" / "pkgB", target_is_directory=True)
os.symlink(a_real, b_real / "node_modules" / "pkgA", target_is_directory=True)
results = list(_scan_with_internal_scanner(tmp_path, []))
assert len(results) == 8 # Bounded - matches ripgrep's own count on this fixture
assert set(results) == {
Path("node_modules/pkgA/index.js"),
Path("node_modules/pkgA/node_modules/pkgB/index.js"),
Path("node_modules/pkgB/index.js"),
Path("node_modules/pkgB/node_modules/pkgA/index.js"),
Path("node_modules/.pnpm/pkgA@1.0.0/node_modules/pkgA/index.js"),
Path("node_modules/.pnpm/pkgA@1.0.0/node_modules/pkgA/node_modules/pkgB/index.js"),
Path("node_modules/.pnpm/pkgB@1.0.0/node_modules/pkgB/index.js"),
Path("node_modules/.pnpm/pkgB@1.0.0/node_modules/pkgB/node_modules/pkgA/index.js"),
}
+796
View File
@@ -0,0 +1,796 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# pyright: reportPrivateUsage=false
import os
import platform
import unicodedata
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from tagstudio.core.constants import IGNORE_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.sync import LibrarySyncEngine
from tagstudio.core.utils.types import unwrap
# NOTE: Case numbers are described in the Library documentation.
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_new_files(library: Library):
"""New files that aren't excluded by an ignore pattern must be picked up as new."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "foo.md").touch()
(library_dir / "bar.txt").touch()
ts_ignore_path = library_dir / TS_FOLDER_NAME / IGNORE_NAME
ts_ignore_path.parent.mkdir(parents=True, exist_ok=True)
ts_ignore_path.write_text("*.md")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert set(engine.new_paths) == {Path("bar.txt")}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_multi_byte_filenames(library: Library):
"""Multi-byte and accented Unicode filenames must be scanned and added without errors."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / ".TagStudio").mkdir()
(library_dir / "こんにちは.txt").touch()
(library_dir / "emdash.txt").touch()
(library_dir / "apostrophe.txt").touch()
(library_dir / "umlaute äöü.txt").touch()
list(engine.sync_dir(library_dir))
assert Path("こんにちは.txt") in engine.new_paths
assert Path("emdash.txt") in engine.new_paths
assert Path("apostrophe.txt") in engine.new_paths
assert Path("umlaute äöü.txt") in engine.new_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_nfd_nfc_false_positive(library: Library):
"""A file reappearing in a different Unicode form must not look like a new/duplicate entry."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
nfc_name = unicodedata.normalize("NFC", "SKÅL.txt")
nfd_name = unicodedata.normalize("NFD", "SKÅL.txt")
assert nfc_name != nfd_name
(library_dir / nfc_name).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
# Simulate the file later showing up in NFD form, as if the filesystem was changed
(library_dir / nfc_name).unlink()
(library_dir / nfd_name).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert len(engine.paths_to_restat) == 1
assert engine.unlinked_entries_count == 2
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cache_pruned_on_remove(library: Library):
"""Removing an unlinked entry must prune the path cache."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
tracked_path = Path("tracked.txt")
(library_dir / tracked_path).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / tracked_path).unlink()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
tracked_entry = next(e for e in engine.unlinked_entries if e.path == tracked_path)
cache = library.get_or_build_path_cache()
assert tracked_path in cache
# Only an explicit removal should prune the cache
engine.unlinked_entries = [tracked_entry]
engine.remove_unlinked_entries()
assert tracked_path not in cache
# A different file at the same path afterwards should be treated as new
(library_dir / tracked_path).touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == [tracked_path]
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_signals_repair_phase_when_entries_are_unlinked(library: Library):
"""`sync_dir()` must yield a (-1, -1) signal before repair work when entries are unlinked."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
# The baseline entries never existed on disk, so they're always unlinked here
progress = list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert (-1, -1) in progress
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_duplicate_case_collision_merged(library: Library):
"""A duplicate entry displaced by a case-insensitive collision must be merged automatically."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
library.is_fs_case_sensitive = False # Force a case-insensitive collision
(library_dir / "Dupe").mkdir()
(library_dir / "Dupe" / "photo.jpg").touch()
entry_a_id, entry_b_id = library.add_entries(
[
Entry(path=Path("Dupe/photo.jpg"), fields=[]),
Entry(path=Path("Dupe/PHOTO.JPG"), fields=[]),
]
)
cache = library.get_or_build_path_cache()
path_key = Path("dupe/photo.jpg") # Case-insensitive + NFD
assert library.duplicate_path_entry_ids is not None
assert len(library.duplicate_path_entry_ids) == 1
dupe_id = library.duplicate_path_entry_ids[0]
original_id = entry_b_id if dupe_id == entry_a_id else entry_a_id
assert cache.get(path_key) == original_id
entries_before = library.entries_count
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == dupe_id
assert dupe_id not in {e.id for e in engine.unlinked_entries}
# A merge deletes the duplicate outright, rather than just reassigning its path
assert library.entries_count == entries_before - 1
assert cache.get(path_key) == original_id
assert library.duplicate_path_entry_ids == []
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_merges_nfc_nfd_duplicate(library: Library):
"""A duplicate entry differing only by Unicode form must be merged automatically.
PathType always normalizes to NFD, so this test uses raw SQL to simulate legacy data
from before that rule existed.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
nfc_name = unicodedata.normalize("NFC", "SKÅL.txt")
nfd_name = unicodedata.normalize("NFD", "SKÅL.txt")
assert nfc_name != nfd_name
# The real file lands in NFD form, as most filesystems store it regardless of input form
(library_dir / nfd_name).touch()
entry_a_id, entry_b_id = library.add_entries(
[
Entry(path=Path(nfd_name), fields=[]),
Entry(path=Path("placeholder.txt"), fields=[]),
]
)
with Session(library.engine) as session:
session.execute(
text("UPDATE entries SET path = :path WHERE id = :id"),
{"path": nfc_name, "id": entry_b_id},
)
session.commit()
library.path_cache = None # Force a rebuild to pick up the raw SQL change
library.get_or_build_path_cache()
assert library.duplicate_path_entry_ids is not None
assert len(library.duplicate_path_entry_ids) == 1
dupe_id = library.duplicate_path_entry_ids[0]
kept_id = entry_b_id if dupe_id == entry_a_id else entry_a_id
entries_before = library.entries_count
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == dupe_id
assert dupe_id not in {e.id for e in engine.unlinked_entries}
assert library.entries_count == entries_before - 1
assert unwrap(library.get_entry_full(kept_id)).id == kept_id
@pytest.mark.skipif(platform.system() == "Windows", reason="Windows is treated as case-insensitive")
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_respects_case_sensitivity(library: Library):
"""The filename-only fallback pass must not relink across a case difference when sensitive.
Currently applies broadly to any non-Windows system.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "Other").mkdir()
(library_dir / "Other" / "name.txt").touch()
library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])])
library.is_fs_case_sensitive = True
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("Folder/Name.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_respects_case_sensitivity_false(library: Library):
"""The filename-only fallback pass must relink across a case difference when insensitive.
Currently only applies to Windows, but the test can run on any system.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "Other").mkdir()
(library_dir / "Other" / "name.txt").touch()
library.add_entries([Entry(path=Path("Folder/Name.txt"), fields=[])])
library.is_fs_case_sensitive = False
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].path == Path("Folder/Name.txt")
assert library.get_entry_id_from_path(Path("Other/name.txt")) >= 0
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_modified_file_stays_linked(library: Library):
"""[Case #0] Modifying a file's content in place must not create an unlinked entry."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "stable.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "stable.txt").write_text("modified content")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert Path("stable.txt") not in {e.path for e in engine.unlinked_entries}
assert engine.new_paths == []
assert len(engine.paths_to_restat) == 1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_deleted_file(library: Library):
"""[Case #1] A deleted file with no candidates anywhere must not auto-relink."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "gone.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "gone.txt").unlink()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert engine.new_paths == []
assert Path("gone.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_and_renamed_file_modified(library: Library):
"""[Case #2] A moved, renamed, and modified file must not auto-relink."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "original_name.txt").unlink()
(library_dir / "sub").mkdir()
(library_dir / "sub" / "renamed.txt").write_text("different content, different size")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("sub/renamed.txt") in engine.new_paths
assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_and_renamed_file(library: Library):
"""[Case #3] A single file that is both moved and renamed must still auto-relink."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("original_name.txt"))
assert original_id >= 0
(library_dir / "sub").mkdir()
(library_dir / "original_name.txt").rename(library_dir / "sub" / "renamed.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert library.get_entry_id_from_path(Path("sub/renamed.txt")) == original_id
assert library.get_entry_id_from_path(Path("original_name.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_single_entry_multiple_moved_and_renamed_files(library: Library):
"""[Case #4] A single entry must not auto-relink to 2+ equally-matching moved+renamed files.
NOTE: This may change in the future as capability expands.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_stat = (library_dir / "original_name.txt").stat()
(library_dir / "original_name.txt").unlink()
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "a" / "renamed1.txt").touch()
(library_dir / "b" / "renamed2.txt").touch()
os.utime(library_dir / "a" / "renamed1.txt", (original_stat.st_atime, original_stat.st_mtime))
os.utime(library_dir / "b" / "renamed2.txt", (original_stat.st_atime, original_stat.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("a/renamed1.txt") in engine.new_paths
assert Path("b/renamed2.txt") in engine.new_paths
assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_multiple_entries_one_moved_and_renamed_file(library: Library):
"""[Case #5] Two or more entries must not auto-relink to one matching moved+renamed file."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "a" / "one.txt").touch()
(library_dir / "b" / "two.txt").touch()
st = (library_dir / "a" / "one.txt").stat()
os.utime(library_dir / "b" / "two.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "a" / "one.txt").unlink()
(library_dir / "b" / "two.txt").unlink()
(library_dir / "c").mkdir()
(library_dir / "c" / "renamed.txt").touch()
os.utime(library_dir / "c" / "renamed.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("c/renamed.txt") in engine.new_paths
unlinked_paths = {e.path for e in engine.unlinked_entries}
assert Path("a/one.txt") in unlinked_paths
assert Path("b/two.txt") in unlinked_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_file_modified(library: Library):
"""[Case #6] A moved + modified file must still auto-relink by its unique filename."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "notmoved.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("notmoved.txt"))
assert original_id >= 0
(library_dir / "notmoved.txt").unlink()
(library_dir / "sub").mkdir()
(library_dir / "sub" / "notmoved.txt").write_text("different content, different size")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert library.get_entry_id_from_path(Path("sub/notmoved.txt")) == original_id
# The original path should no longer be associated with an entry
assert library.get_entry_id_from_path(Path("notmoved.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_file(library: Library):
"""[Case #7] A moved file (same filename + stats, different path) must auto-relink."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "moveme.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("moveme.txt"))
assert original_id >= 0
(library_dir / "sub").mkdir()
(library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert Path("sub/moveme.txt") not in {e.path for e in engine.unlinked_entries}
assert library.get_entry_id_from_path(Path("sub/moveme.txt")) == original_id
# The original path should no longer be associated with an entry
assert library.get_entry_id_from_path(Path("moveme.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_moved_files_same_names(library: Library):
"""[Case #7] A file candidate with same name + different stats must not block the real match."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "photo.jpg").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("photo.jpg"))
assert original_id >= 0
original_stat = (library_dir / "photo.jpg").stat()
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "photo.jpg").rename(library_dir / "a" / "photo.jpg")
(library_dir / "b" / "photo.jpg").touch()
# Ensure a different mtime than "a/photo.jpg"
os.utime(
library_dir / "b" / "photo.jpg", (original_stat.st_atime, original_stat.st_mtime + 100)
)
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert library.get_entry_id_from_path(Path("a/photo.jpg")) == original_id
assert Path("b/photo.jpg") in engine.new_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_single_entry_multiple_moved_files(library: Library):
"""[Case #8] A single entry must not auto-relink to multiple equally-matching file candidates.
NOTE: This may change in the future as capability expands.
"""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "photo.jpg").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
assert library.get_entry_id_from_path(Path("photo.jpg")) >= 0
original_stat = (library_dir / "photo.jpg").stat()
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "photo.jpg").rename(library_dir / "a" / "photo.jpg")
(library_dir / "b" / "photo.jpg").touch()
os.utime(library_dir / "b" / "photo.jpg", (original_stat.st_atime, original_stat.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("a/photo.jpg") in engine.new_paths
assert Path("b/photo.jpg") in engine.new_paths
assert Path("photo.jpg") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_multiple_entries_one_moved_file(library: Library):
"""[Case #9] Two or more entries must not auto-relink to one matching file candidate."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "a" / "dupe.txt").touch()
(library_dir / "b" / "dupe.txt").touch()
st = (library_dir / "a" / "dupe.txt").stat()
os.utime(library_dir / "b" / "dupe.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "a" / "dupe.txt").unlink()
(library_dir / "b" / "dupe.txt").unlink()
(library_dir / "c").mkdir()
(library_dir / "c" / "dupe.txt").touch()
os.utime(library_dir / "c" / "dupe.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("c/dupe.txt") in engine.new_paths
unlinked_paths = {e.path for e in engine.unlinked_entries}
assert Path("a/dupe.txt") in unlinked_paths
assert Path("b/dupe.txt") in unlinked_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_renamed_file_modified(library: Library):
"""[Case #10] A renamed file with different stats must not auto-relink."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "original_name.txt").unlink()
(library_dir / "renamed_and_modified.txt").write_text("different content, different size")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("renamed_and_modified.txt") in engine.new_paths
assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_renamed_file(library: Library):
"""[Case #11] A renamed file (different filename, same stats) must auto-relink to its entry."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_id = library.get_entry_id_from_path(Path("original_name.txt"))
assert original_id >= 0
(library_dir / "original_name.txt").rename(library_dir / "renamed.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert Path("renamed.txt") not in {e.path for e in engine.unlinked_entries}
assert library.get_entry_id_from_path(Path("renamed.txt")) == original_id
# The original path should no longer be associated with an entry
assert library.get_entry_id_from_path(Path("original_name.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_single_entry_multiple_renamed_files(library: Library):
"""[Case #12] A single entry must not auto-relink to 2+ matched renamed files."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
original_stat = (library_dir / "original_name.txt").stat()
(library_dir / "original_name.txt").unlink()
(library_dir / "renamed1.txt").touch()
(library_dir / "renamed2.txt").touch()
os.utime(library_dir / "renamed1.txt", (original_stat.st_atime, original_stat.st_mtime))
os.utime(library_dir / "renamed2.txt", (original_stat.st_atime, original_stat.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("renamed1.txt") in engine.new_paths
assert Path("renamed2.txt") in engine.new_paths
assert Path("original_name.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_multiple_entries_one_renamed_file(library: Library):
"""[Case #13] Two or more entries must not auto-relink to one matching renamed file."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "one.txt").touch()
(library_dir / "two.txt").touch()
st = (library_dir / "one.txt").stat()
os.utime(library_dir / "two.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
(library_dir / "one.txt").unlink()
(library_dir / "two.txt").unlink()
(library_dir / "renamed.txt").touch()
os.utime(library_dir / "renamed.txt", (st.st_atime, st.st_mtime))
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("renamed.txt") in engine.new_paths
unlinked_paths = {e.path for e in engine.unlinked_entries}
assert Path("one.txt") in unlinked_paths
assert Path("two.txt") in unlinked_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_separate_moved_and_renamed_files(library: Library):
"""[Cases #7, #11] Two separate files, one moved and one renamed, must each auto-relink."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "moveme.txt").touch()
(library_dir / "rename_orig.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
moved_id = library.get_entry_id_from_path(Path("moveme.txt"))
renamed_id = library.get_entry_id_from_path(Path("rename_orig.txt"))
assert moved_id >= 0
assert renamed_id >= 0
(library_dir / "sub").mkdir()
(library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt")
(library_dir / "rename_orig.txt").rename(library_dir / "rename_new.txt")
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 2
assert {e.id for e in engine.relinked_entries} == {moved_id, renamed_id}
assert library.get_entry_id_from_path(Path("sub/moveme.txt")) == moved_id
assert library.get_entry_id_from_path(Path("rename_new.txt")) == renamed_id
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_matches_entry_without_stored_stats_by_filename(library: Library):
"""An entry with no stored stats must auto-relink to a single matching filename."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
library.add_entries([Entry(path=Path("legacy.txt"), fields=[])])
original_id = library.get_entry_id_from_path(Path("legacy.txt"))
assert original_id >= 0
(library_dir / "sub").mkdir()
(library_dir / "sub" / "legacy.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == []
assert engine.relinked_entries_count == 1
assert engine.relinked_entries[0].id == original_id
assert Path("legacy.txt") not in {e.path for e in engine.unlinked_entries}
assert library.get_entry_id_from_path(Path("sub/legacy.txt")) == original_id
# The original path should no longer be associated with an entry
assert library.get_entry_id_from_path(Path("legacy.txt")) == -1
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_auto_relink_skips_ambiguous_entry_without_stored_stats(library: Library):
"""An entry with no stored stats must not auto-relink to 2+ files with the same name."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
library.add_entries([Entry(path=Path("legacy.txt"), fields=[])])
(library_dir / "a").mkdir()
(library_dir / "b").mkdir()
(library_dir / "a" / "legacy.txt").touch()
(library_dir / "b" / "legacy.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.relinked_entries_count == 0
assert Path("a/legacy.txt") in engine.new_paths
assert Path("b/legacy.txt") in engine.new_paths
assert Path("legacy.txt") in {e.path for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_unlinked_finalization(library: Library):
"""A cancelled `sync_dir()` call must not mark any entries as unlinked."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "a.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
baseline_unlinked_count = engine.unlinked_entries_count
# Case where sync is cancelled before this scan even starts
engine.cancelled = True
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.unlinked_entries_count == baseline_unlinked_count
assert engine.new_paths == []
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_duplicate_merge(library: Library):
"""Cancelling right after the scan must stop duplicate merging before it starts."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
library.is_fs_case_sensitive = False # Force a collision using a case difference
(library_dir / "Dupe").mkdir()
(library_dir / "Dupe" / "photo.jpg").touch()
library.add_entries(
[
Entry(path=Path("Dupe/photo.jpg"), fields=[]),
Entry(path=Path("Dupe/PHOTO.JPG"), fields=[]),
]
)
library.get_or_build_path_cache()
assert library.duplicate_path_entry_ids is not None
assert len(library.duplicate_path_entry_ids) == 1
dupe_id = library.duplicate_path_entry_ids[0]
# Cancel right as the repair phase signal comes through, before merging actually runs
generator = engine.sync_dir(library_dir, force_internal_scanner=True)
for progress in generator:
if progress == (-1, -1):
engine.cancelled = True
break
list(generator)
assert engine.relinked_entries_count == 0
assert dupe_id in {e.id for e in engine.unlinked_entries}
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_remaining_relink_passes(library: Library):
"""Cancelling right after the scan must let Pass 1 finish, but stop before Pass 2."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "moveme.txt").touch()
(library_dir / "original_name.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
moved_id = library.get_entry_id_from_path(Path("moveme.txt"))
renamed_id = library.get_entry_id_from_path(Path("original_name.txt"))
(library_dir / "sub").mkdir()
(library_dir / "moveme.txt").rename(library_dir / "sub" / "moveme.txt") # Pass 1 match
(library_dir / "original_name.txt").rename(library_dir / "renamed.txt") # Pass 2 match
# Cancel right as the repair phase signal comes through, before relinking actually runs
generator = engine.sync_dir(library_dir, force_internal_scanner=True)
for progress in generator:
if progress == (-1, -1):
engine.cancelled = True
break
list(generator)
relinked_ids = {e.id for e in engine.relinked_entries}
assert moved_id in relinked_ids
assert renamed_id not in relinked_ids
assert Path("renamed.txt") in engine.new_paths
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_save_new_entries(library: Library):
"""A cancelled `save_new_entries()` call must not save any new entries."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
baseline_count = library.entries_count
(library_dir / "f0.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert engine.new_paths == [Path("f0.txt")]
engine.cancelled = True
list(engine.save_new_entries())
assert library.entries_count == baseline_count
assert engine.new_paths == [Path("f0.txt")]
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_sync_cancel_skips_sync_entry_stats(library: Library):
"""A cancelled `sync_entry_stats()` call must not touch `paths_to_restat`."""
library_dir = unwrap(library.library_dir)
engine = LibrarySyncEngine(library=library)
(library_dir / "f0.txt").touch()
list(engine.sync_dir(library_dir, force_internal_scanner=True))
list(engine.save_new_entries())
# A second scan finds the same file again, marking it for a restat
list(engine.sync_dir(library_dir, force_internal_scanner=True))
assert len(engine.paths_to_restat) == 1
expected = list(engine.paths_to_restat)
engine.cancelled = True
list(engine.sync_entry_stats())
assert engine.paths_to_restat == expected
@@ -1,38 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.registries.unlinked_registry import UnlinkedRegistry
from tagstudio.core.utils.types import unwrap
CWD = Path(__file__).parent
# NOTE: Does this test actually work?
@pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)
def test_refresh_unlinked_entries(library: Library):
registry = UnlinkedRegistry(lib=library)
# touch the file `one/two/bar.md` but in wrong location to simulate a moved file
(unwrap(library.library_dir) / "bar.md").touch()
# no files actually exist, so it should return all entries
assert list(registry.refresh_unlinked_files()) == [0, 1]
# neither of the library entries exist
assert len(registry.unlinked_entries) == 2
# iterate through two files
assert list(registry.fix_unlinked_entries()) == [0, 1]
# `bar.md` should be relinked to new correct path
results = library.search_library(BrowsingState.from_path("bar.md"), page_size=500)
entries = library.get_entries(results.ids)
assert entries[0].path == Path("bar.md")
Binary file not shown.
+1 -1
View File
@@ -134,7 +134,7 @@ def test_title_update(
qt_driver.main_window.menu_bar.ignore_modal_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.save_library_backup_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.close_library_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.refresh_dir_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.sync_library_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.tag_manager_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.color_manager_action = QAction(menu_bar)
qt_driver.main_window.menu_bar.new_tag_action = QAction(menu_bar)