Compare commits

..

6 Commits

Author SHA1 Message Date
Travis Abendshien ed9850e60f feat!: add file date metadata to db 2026-06-30 16:40:24 -07:00
Travis Abendshien daaae17113 tests: filter irrelevant SQLAlchemy DISTINCT ON warning spam 2026-06-30 14:17:05 -07:00
Xarvex c38fbb034d chore(nix): relax more dependencies
fixes: #1403
2026-06-30 12:51:39 -05:00
Travis Abendshien 0433908ae8 refactor: remove QPushButtonWrapper 2026-06-30 02:22:48 -07:00
Travis Abendshien 9e04922e7b fix(docs): fix header levels in changelog 2026-06-29 14:36:10 -07:00
Travis Abendshien c6f2e3c7d1 docs: update changelog 2026-06-29 14:33:56 -07:00
15 changed files with 390 additions and 92 deletions
+82 -7
View File
@@ -9,6 +9,85 @@ toc_depth: 2
# :material-script-text: Changelog
## 9.6.0 <small>June 29th, 2026</small>
<p align="center">
<img width="480" alt="aurora" src="https://github.com/user-attachments/assets/6103bb88-bc66-4222-9844-ed64d27dd2e7" />
</p>
### Added
#### Customizable Fields
[Fields](fields.md) are now fully customizable!
- The built-in list of fields (now referred to as "field templates") has been reduced to a list of handy essential templates that can be fully modified or deleted
- "Text Line" and "Text Box" field types have been combined into a single "Text" type with a "Multiline" option that can be configured on templates or toggled dynamically on existing text fields
- You can now create your own field templates in with custom names, types, and other options
- Fields applied to file entries can now have their names edited
<img width="314" height="507" alt="field_template_manager" src="https://github.com/user-attachments/assets/523fac64-e0d6-4697-ba25-6ff89273d0cb" />
<img width="494" height="342" alt="text_field_editor" src="https://github.com/user-attachments/assets/918956ee-647b-43ca-9843-e8acb67ee58f" />
- refactor!: uncouple fields from hard-coded values by @CyanVoxel in #1354
- fix: drop type_key column from text_fields and datetime_fields tables by @CyanVoxel in #1370
- feat: field template manager by @TrigamDev in #1374
- feat: add field template editor, editable field names by @CyanVoxel in #1396
#### Other Additions
- feat: add ability to configure automatic loading of new files over 10000 by @socalledtheraven in #1349
- feat: dismiss update notification, change missing ffmpeg popup to banner by @CyanVoxel in #1400
- feat: allow all raster images to be used as archive thumbnails. by @Sola-ris in #1373
### Fixed
- fix: Fix update notification crash by @Sola-ris in #1278
- fix: correct ESCAPABLE_CHARS in query_lang tokenizer by @hieuit095 in #1334
- fix: empty ORLists now return false by @TheBobBobs in #1297
- fix: update thumb grid when adding tags from preview panel by @TheBobBobs in #1245
- fix: match against the correct path in the ignore registry by @TrigamDev in #1382
- fix: fix tag aliases by @CyanVoxel in #1399
- fix: fix faulty progress bars by @ludvig-sandh in #1293
- perf: Optimize searching tags with DB indexes by @TheBobBobs in #1129
### Changed
- ui: add v9.6 assets, update misc resources by @CyanVoxel in #1398
#### Internal Changes
- minor refactor: cleanup parameters of open_library and open_sqlite_library by @Computerdores in #1294
- refactor: sql migrations by @Computerdores in #1295
- fix: remove preferences table by @Computerdores in #1298
- doc: add REUSE license information by @Computerdores in #1361
- docs/chore: add .editorconfig and .prettierrc.toml configs by @CyanVoxel in #1362
- refactor(docs): uniform formatting pass by @CyanVoxel in #1363
- fix: pyright errors in ts_qt.py by @Computerdores in #1237
- fix(ci): address remaining pyright errors by @CyanVoxel in #1368
- ci: add pyright workflow by @Computerdores in #1232
- refactor(ci): remove mypy by @CyanVoxel in #1371
- chore(deps-dev): bump pytest from 8.3.5 to 9.0.3 by @dependabot[bot] in #1372
- doc(install): add common error message help text by @Computerdores in #1369
- chore(ci): bump ruff to 0.15.17 by @CyanVoxel in #1393
- fix(pyproject): allow Python 3.13 to function by @xarvex in #1047
#### Translations
- **Amharic** added by @Birhant _(11%, can manually enable in settings file)_
- **Hebrew** added by @JonathanGlixman _(2%, can manually enable in settings file)_
- **French** updated by @SodiumBismuth, @kitsumed
- **Russian** updated by @WerDei, @NikitaNik-of, @Dott-rus
- **Hungarian** updated by @smileyhead
- **Japanese** updated by wany-oh
- **Italian** updated by @EdelFlosWeiss
- **Spanish** updated by @JulArr22
- **Toki Pona** updated by Cyborus
- **Viossa** updated by @Nginearing
- **Portuguese** updated by ssantos
---
## 9.5.7 <small>May 5th, 2026</small>
This update adds several bugfixes and additions that have been sitting on the main branch for quite some time.
@@ -68,12 +147,6 @@ This update adds several bugfixes and additions that have been sitting on the ma
- **Tamil** updated by @TamilNeram
- **Toki Pona** updated by @Math-Bee, Star Athendwyl
### New Contributors
- @Ambossmann made their first contribution in #1189
- @CallMeHein made their first contribution in #1173
- @terahidro2003 made their first contribution in #1328
---
## 9.5.6 <small>October 20th, 2025</small>
@@ -422,7 +495,9 @@ A new "Library Information" window has been added and is accessible under the "V
## 9.5.0 <small>March 3rd, 2025</small>
<img width="500" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" alt="TagStudio 9.5 Banner" />
<p align="center">
<img width="480" alt="TagStudio 9.5 Banner" src="https://github.com/user-attachments/assets/858f1494-216f-4521-aefe-d0aa4f754b9e" />
</p>
### Added
+2 -2
View File
@@ -174,8 +174,8 @@ File or file-like [entries](entries.md) stored in the library.
- [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]**
- [ ] Numeric Fields :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
- [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
- [x] Removal of Deprecated Fields **[v9.6.0]**
- [x] Custom Field Names **[[v9.6.0](changelog.md#960-june-29th-2026)]**
- [x] Removal of Deprecated Fields **[[v9.6.0](changelog.md#960-june-29th-2026)]**
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
Generated
+6 -6
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1763759067,
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763835633,
"narHash": "sha256-HzxeGVID5MChuCPESuC0dlQL1/scDKu+MmzoVBJxulM=",
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "050e09e091117c3d7328c7b2b7b577492c43c134",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"type": "github"
},
"original": {
+3 -1
View File
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
{
ffmpeg-headless,
lib,
@@ -84,8 +83,10 @@ python3Packages.buildPythonApplication {
"py7zr"
"pyside6"
"rarfile"
"rawpy"
"requests"
"semver"
"Send2Trash"
"structlog"
"typing-extensions"
];
@@ -130,6 +131,7 @@ python3Packages.buildPythonApplication {
"test_close_library" # TODO: Look into segfault.
"test_flow_layout_happy_path"
"test_get" # TODO: Look further into, might be possible to run.
"test_github_api_unavailable"
"test_json_migration"
"test_library_migrations"
"test_update_tags"
+5 -2
View File
@@ -65,9 +65,11 @@ packages = ["src/tagstudio"]
[tool.pytest.ini_options]
#addopts = "-m 'not qt'"
qt_api = "pyside6"
pythonpath = ["src"]
filterwarnings = [
"ignore:DISTINCT ON is currently supported only by the PostgreSQL dialect:sqlalchemy.exc.SADeprecationWarning",
]
[tool.pyright]
ignore = [
@@ -76,7 +78,8 @@ ignore = [
"src/tagstudio/qt/previews/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
# Some rules exclusive to basedpyright: https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/
reportAny = false
reportIgnoreCommentWithoutRule = false
reportImplicitStringConcatenation = false
+86 -5
View File
@@ -7,6 +7,7 @@
# pyright: reportDeprecated=false
import platform
import re
import shutil
import sys
@@ -93,6 +94,7 @@ from tagstudio.core.library.alchemy.fields import (
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.metadata import FileMetadata
from tagstudio.core.library.alchemy.models import (
Entry,
Folder,
@@ -105,6 +107,7 @@ 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.stat import get_date_created, get_date_modified
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.translations import Translations
@@ -935,7 +938,11 @@ class Library:
return entry
def get_entry_full(
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
self,
entry_id: int,
with_fields: bool = True,
with_tags: bool = True,
with_metadata: bool = True,
) -> Entry | None:
"""Load entry and join with all joins and all tags."""
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
@@ -965,6 +972,11 @@ class Library:
)
)
if with_metadata:
entry_stmt = entry_stmt.outerjoin(Entry.file_metadata).options(
selectinload(Entry.file_metadata),
)
start_time = time.time()
entry = session.scalar(entry_stmt)
if with_tags:
@@ -1153,10 +1165,79 @@ class Library:
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
session.commit()
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 update_entry_file_metadata(
# self, entry_id: int, date_created: datetime | None, date_modified: datetime | None
# ):
# with Session(self.engine) as session:
# stmt = update(FileMetadata).where(
# and_(
# FileMetadata.entry_id == entry_id,
# )
# )
# if date_created:
# stmt = stmt.values(date_created=date_created)
# if date_modified:
# stmt = stmt.values(date_modified=date_modified)
# session.execute(stmt)
# session.commit()
def refresh_file_entry_stats(self, entry_id: int, path: Path | None):
"""Updates a file entry's associated stat() data."""
needs_update = False
entry = self.get_entry_full(
entry_id, with_fields=False, with_tags=False, with_metadata=True
)
if not entry:
return
if not path:
full_path = unwrap(self.library_dir) / entry.path
else:
full_path = unwrap(self.library_dir) / path
logger.info(full_path)
file_date_created = get_date_created(full_path)
file_date_modified = get_date_modified(full_path)
# Log info
if entry.date_created != file_date_created:
logger.info(f"Difference in date_created!: {entry.date_created}/{file_date_created}")
needs_update = True
else:
logger.info("No difference in date_created.")
if entry.date_modified != file_date_modified:
logger.info(f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}")
needs_update = True
else:
logger.info("No difference in date_modified")
if needs_update:
return
else:
logger.info(f"Updating entry file_metadata for {full_path}")
with Session(self.engine) as session:
stmt = update(FileMetadata).where(
and_(
FileMetadata.entry_id == entry_id,
)
)
if file_date_created:
stmt = stmt.values(date_created=file_date_created)
if file_date_modified:
stmt = stmt.values(date_modified=file_date_modified)
session.execute(stmt)
session.commit()
def get_paths(self, limit: int = -1) -> list[str]:
path_strings: list[str] = []
@@ -1470,7 +1551,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
@@ -0,0 +1,116 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from __future__ import annotations
from datetime import datetime as dt
from pathlib import Path
from typing import TYPE_CHECKING, Any, override
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, null
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.joins import TagParent
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.models import Entry
class FileMetadata(Base):
"""Table that includes file data and metadata obtained from os.stat() for entries."""
__tablename__ = "file_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
# NOTE: These dates are stored as floats because that's their natural form from os.stat()
# and comparisons are quicker without having to convert to/from datetime objects.
date_created: Mapped[float | None]
date_modified: Mapped[float | None]
def __init__(
self,
entry_id: int,
date_created: float | None = None,
date_modified: float | None = None,
) -> None:
super().__init__()
self.entry_id = entry_id
# # Path data
# self.path = path
# self.filename = path.name
# self.suffix = path.suffix.lstrip(".").lower()
# File metadata
self.date_created = date_created # st_birthtime on Windows and Mac, st_ctime on Linux
self.date_modified = date_modified # st_mtime
class ExifMetadata(Base):
"""Contains Exif metadata for a entries."""
__tablename__ = "exif_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
date_taken: Mapped[dt | None]
def __init__(
self,
entry_id: int,
date_taken: dt | None = None,
) -> None:
super().__init__()
self.entry_id = entry_id
self.date_taken = date_taken # Exif.Image.DateTime
class DimensionMetadata(Base):
"""Contains dimension metadata for entries (e.g. image and video files)."""
__tablename__ = "dimension_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
width: Mapped[int] = mapped_column(nullable=False)
height: Mapped[int] = mapped_column(nullable=False)
def __init__(
self,
entry_id: int,
width: int,
height: int,
) -> None:
super().__init__()
self.entry_id = entry_id
self.width = width
self.height = height
class DurationMetadata(Base):
"""Contains duration metadata for entries (e.g. audio and video files)."""
__tablename__ = "duration_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
duration: Mapped[float] = mapped_column(nullable=False)
def __init__(
self,
entry_id: int,
duration: float,
) -> None:
super().__init__()
self.entry_id = entry_id
self.duration = duration
+35 -14
View File
@@ -17,6 +17,8 @@ from tagstudio.core.library.alchemy.fields import (
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.metadata import FileMetadata
from tagstudio.core.utils.stat import get_date_created, get_date_modified
class Namespace(Base):
@@ -182,6 +184,7 @@ class Tag(Base):
return self.name >= other.name
# TODO: Use or replace these with an actual multi-root implementation
class Folder(Base):
__tablename__ = "folders"
@@ -196,15 +199,16 @@ class Entry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
# TODO: Use or replace these with an actual multi-root implementation
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
folder: Mapped[Folder] = relationship("Folder")
# TODO: Possibly move to FileMetadata table if Entry is split into Entry/FileEntry (see #588)
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_added: Mapped[dt | None]
date_added: Mapped[dt | None] # The date this entry was added to the library
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
@@ -217,6 +221,11 @@ class Entry(Base):
cascade="all, delete",
)
file_metadata: Mapped["FileMetadata"] = relationship(
uselist=False,
cascade="all, delete-orphan",
)
@property
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
@@ -232,30 +241,35 @@ class Entry(Base):
def is_archived(self) -> bool:
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
@property
def date_created(self) -> float | None:
return self.file_metadata.date_created if self.file_metadata else None
@property
def date_modified(self) -> float | None:
return self.file_metadata.date_modified if self.file_metadata else None
def __init__(
self,
path: Path,
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
# date_created: float | None = None,
# date_modified: float | None = None,
path_for_file_metadata: Path | None = None,
) -> None:
super().__init__()
self.path = path
self.folder = folder
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.folder = folder # NOTE: Currently unused
self.path = path
self.filename = path.name
self.suffix = 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 date this entry was added to the library.
self.date_added = date_added
self.date_added = date_added # The date this entry was added to the library
for field in fields:
if isinstance(field, TextField):
@@ -265,6 +279,13 @@ class Entry(Base):
else:
raise ValueError(f"Invalid field type: {field}")
if path_for_file_metadata:
self.file_metadata = FileMetadata(
entry_id=self.id,
date_created=get_date_created(path_for_file_metadata),
date_modified=get_date_modified(path_for_file_metadata),
)
def has_tag(self, tag: Tag) -> bool:
return tag in self.tags
+12 -2
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from datetime import datetime as dt
from pathlib import Path
from time import time
import platform
import structlog
from wcmatch import pathlib
@@ -38,12 +39,14 @@ class RefreshTracker:
while index < len(self.files_not_in_library):
yield index
end = min(len(self.files_not_in_library), index + batch_size)
lib_dir = unwrap(self.library.library_dir)
entries = [
Entry(
path=entry_path,
folder=unwrap(self.library.folder),
fields=[],
date_added=dt.now(),
path_for_file_metadata=(lib_dir / entry_path),
)
for entry_path in self.files_not_in_library[index:end]
]
@@ -144,8 +147,11 @@ class RefreshTracker:
dir_file_count += 1
self.library.included_files.add(f)
if not self.library.has_entry_with_path(f):
entry_id = self.library.get_entry_id_from_path(f)
if entry_id < 0:
self.files_not_in_library.append(f)
else:
self.library.refresh_file_entry_stats(entry_id, path=f)
end_time_total = time()
yield dir_file_count
@@ -189,8 +195,12 @@ class RefreshTracker:
relative_path = f.relative_to(library_dir)
if not self.library.has_entry_with_path(relative_path):
entry_id = self.library.get_entry_id_from_path(relative_path)
if entry_id < 0:
self.files_not_in_library.append(relative_path)
else:
self.library.refresh_file_entry_stats(entry_id, path=relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
+16
View File
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import platform
from pathlib import Path
def get_date_modified(path: Path) -> float:
return path.stat().st_mtime
def get_date_created(path: Path) -> float:
if platform.system() in {"Windows", "Darwin"}:
return path.stat().st_birthtime
else:
return path.stat().st_ctime
+7 -9
View File
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QLabel,
QMessageBox,
QProgressDialog,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -47,7 +48,6 @@ from tagstudio.qt.translations import Translations
from tagstudio.qt.utils.custom_runnable import CustomRunnable
from tagstudio.qt.utils.function_iterator import FunctionIterator
from tagstudio.qt.views.paged_body_wrapper import PagedBodyWrapper
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
from tagstudio.qt.views.stylesheets.stylesheets import header
logger = structlog.get_logger(__name__)
@@ -99,8 +99,8 @@ class JsonMigrationModal(QObject):
body_wrapper.layout().addWidget(body_label)
body_wrapper.layout().setContentsMargins(0, 36, 0, 0)
cancel_button = QPushButtonWrapper(Translations["generic.cancel"])
next_button = QPushButtonWrapper(Translations["generic.continue"])
cancel_button = QPushButton(Translations["generic.cancel"])
next_button = QPushButton(Translations["generic.continue"])
cancel_button.clicked.connect(self.migration_cancelled.emit)
self.stack.append(
@@ -289,14 +289,12 @@ class JsonMigrationModal(QObject):
self.body_wrapper_01.layout().addWidget(desc_label)
self.body_wrapper_01.layout().setSpacing(12)
back_button = QPushButtonWrapper(Translations["generic.navigation.back"])
start_button = QPushButtonWrapper(Translations["json_migration.start_and_preview"])
back_button = QPushButton(Translations["generic.navigation.back"])
start_button = QPushButton(Translations["json_migration.start_and_preview"])
start_button.setMinimumWidth(120)
start_button.clicked.connect(self.migrate)
start_button.clicked.connect(lambda: start_button.setDisabled(True))
finish_button: QPushButtonWrapper = QPushButtonWrapper(
Translations["json_migration.finish_migration"]
)
finish_button = QPushButton(Translations["json_migration.finish_migration"])
finish_button.setMinimumWidth(120)
finish_button.setDisabled(True)
finish_button.clicked.connect(self.finish_migration)
@@ -381,7 +379,7 @@ class JsonMigrationModal(QObject):
pb.setMinimum(1),
pb.setValue(1),
# Enable the finish button
cast(QPushButtonWrapper, self.stack[1].buttons[4]).setDisabled(False),
cast(QPushButton, self.stack[1].buttons[4]).setDisabled(False),
)
)
QThreadPool.globalInstance().start(r)
+14 -15
View File
@@ -5,15 +5,15 @@
"""A pagination widget created for TagStudio."""
from typing import cast, override
from warnings import catch_warnings
from PIL import Image, ImageQt
from PySide6.QtCore import QSize, Signal
from PySide6.QtGui import QIntValidator, QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QWidget
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
class Pagination(QWidget):
@@ -21,8 +21,8 @@ class Pagination(QWidget):
index = Signal(int)
def __init__(self, parent=None) -> None:
super().__init__(parent)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent=parent)
self.rm = ResourceManager()
self.page_count: int = 0
self.current_page_index: int = 0
@@ -43,7 +43,7 @@ class Pagination(QWidget):
self.root_layout.setSpacing(3)
# [<] ----------------------------------
self.prev_button = QPushButtonWrapper()
self.prev_button = QPushButton()
prev_icon: Image.Image = self.rm.bxs_left_arrow
prev_icon = auto_theme_overlay(prev_icon, use_alpha=False)
self.prev_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(prev_icon)))
@@ -52,7 +52,7 @@ class Pagination(QWidget):
self.prev_button.setMaximumSize(self.button_size)
# --- [1] ------------------------------
self.start_button = QPushButtonWrapper()
self.start_button = QPushButton()
self.start_button.setMinimumSize(self.button_size)
self.start_button.setMaximumSize(self.button_size)
@@ -91,12 +91,12 @@ class Pagination(QWidget):
self.end_ellipses.setText(". . .")
# ----------------------------- [42] ---
self.end_button = QPushButtonWrapper()
self.end_button = QPushButton()
self.end_button.setMinimumSize(self.button_size)
self.end_button.setMaximumSize(self.button_size)
# ---------------------------------- [>]
self.next_button = QPushButtonWrapper()
self.next_button = QPushButton()
next_icon: Image.Image = self.rm.bxs_right_arrow
next_icon = auto_theme_overlay(next_icon, use_alpha=False)
self.next_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(next_icon)))
@@ -223,7 +223,7 @@ class Pagination(QWidget):
)
self._assign_click(
cast(
QPushButtonWrapper,
QPushButton,
self.start_buffer_layout.itemAt(i - start_offset).widget(),
),
i,
@@ -240,7 +240,7 @@ class Pagination(QWidget):
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(str(i + 1)) # pyright: ignore[reportAttributeAccessIssue]
self._assign_click(
cast(
QPushButtonWrapper,
QPushButton,
self.end_buffer_layout.itemAt(i - end_offset).widget(),
),
i,
@@ -268,21 +268,20 @@ class Pagination(QWidget):
def _goto_page(self, index: int):
self.update_buttons(self.page_count, index)
def _assign_click(self, button: QPushButtonWrapper, index):
if button.is_connected:
def _assign_click(self, button: QPushButton, index: int):
with catch_warnings(record=True):
button.clicked.disconnect()
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
button.is_connected = True
def _populate_buffer_buttons(self):
for _ in range(max(self.buffer_page_count * 2, 5)):
button = QPushButtonWrapper()
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
self.start_buffer_layout.addWidget(button)
end_button = QPushButtonWrapper()
end_button = QPushButton()
end_button.setMinimumSize(self.button_size)
end_button.setMaximumSize(self.button_size)
end_button.setHidden(True)
-21
View File
@@ -1,21 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from PySide6.QtWidgets import QPushButton
from typing_extensions import deprecated
@deprecated("Use QPushButton with 'catch warnings' to silence disconnect warnings instead.")
class QPushButtonWrapper(QPushButton):
"""Custom QPushButton wrapper.
This is a customized implementation of the PySide6 QPushButton that allows to suppress
the warning that is triggered by disconnecting a signal that is not currently connected.
"""
is_connected: bool
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_connected = False
+2 -4
View File
@@ -16,12 +16,10 @@ from PySide6.QtGui import (
QPalette,
QPen,
)
from PySide6.QtWidgets import QWidget
from tagstudio.qt.views.qbutton_wrapper import QPushButtonWrapper
from PySide6.QtWidgets import QPushButton, QWidget
class ThumbButton(QPushButtonWrapper):
class ThumbButton(QPushButton):
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
super().__init__(parent)
self.thumb_size: tuple[int, int] = thumb_size
+4 -4
View File
@@ -81,9 +81,9 @@ 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 not library.get_entry_id_from_path(entry.path)
assert library.add_entries([entry])
assert library.has_entry_with_path(entry.path)
assert library.get_entry_id_from_path(entry.path)
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
@@ -347,8 +347,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 not library.get_entry_id_from_path(Path("a"))
assert library.get_entry_id_from_path(Path("b"))
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))