mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-21 21:16:20 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b66695ab3 |
@@ -210,18 +210,11 @@ jobs:
|
||||
steps:
|
||||
- *checkout
|
||||
|
||||
- name: Setup Ruff
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
with:
|
||||
# No-op operation, since executing Ruff cannot be disabled for the action.
|
||||
# Note that `--version` has different behavior than `version`, as the
|
||||
# latter will fail if passed any extra args, even if that arg is empty.
|
||||
args: --version
|
||||
src: ''
|
||||
|
||||
- parallel:
|
||||
- name: Run Ruff linter
|
||||
run: ruff check
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
|
||||
- name: Run Ruff formatter
|
||||
run: ruff format --check
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
with:
|
||||
args: format --check
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ path = [
|
||||
"docs/CNAME",
|
||||
"docs/assets/**",
|
||||
"src/tagstudio/qt/resources.json",
|
||||
"src/tagstudio/resources/icon*.*",
|
||||
"src/tagstudio/resources/icon.*",
|
||||
"src/tagstudio/resources/tagstudio.desktop",
|
||||
"src/tagstudio/resources/templates/ts_ignore_template.txt",
|
||||
"src/tagstudio/resources/templates/ts_ignore_template_blank.txt",
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.6.2"
|
||||
version = "9.6.1"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
VERSION: str = version("tagstudio")
|
||||
BUILD_TYPE: str = "" # Usually "", "app.nightly", or "app.pre_release"
|
||||
VERSION: str = version("tagstudio") # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
COPYRIGHT_YEARS: str = "2021-2026"
|
||||
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
|
||||
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 300
|
||||
DB_VERSION: int = 202
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# pyright: reportDeprecated=false
|
||||
|
||||
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
@@ -18,6 +19,7 @@ from datetime import UTC, datetime
|
||||
from os import makedirs
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy
|
||||
import structlog
|
||||
@@ -92,8 +94,10 @@ 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,
|
||||
Namespace,
|
||||
Tag,
|
||||
TagAlias,
|
||||
@@ -103,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
|
||||
|
||||
@@ -232,6 +237,7 @@ class Library:
|
||||
|
||||
library_dir: Path | None = None
|
||||
engine: Engine | None = None
|
||||
folder: Folder | None = None
|
||||
included_files: set[Path] = set()
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -256,6 +262,7 @@ class Library:
|
||||
"""Migrate JSON library data to the SQLite database."""
|
||||
logger.info("Starting Library Conversion...")
|
||||
start_time = time.time()
|
||||
folder: Folder = Folder(path=self.library_dir, uuid=str(uuid4()))
|
||||
|
||||
# Tags
|
||||
for tag in json_lib.tags:
|
||||
@@ -308,6 +315,7 @@ class Library:
|
||||
[
|
||||
Entry(
|
||||
path=entry.path / entry.filename,
|
||||
folder=folder,
|
||||
fields=[],
|
||||
id=entry.id + 1, # NOTE: JSON IDs start at 0 instead of 1
|
||||
date_added=datetime.now(),
|
||||
@@ -474,6 +482,16 @@ class Library:
|
||||
session.add(Version(key=DB_VERSION_CURRENT_KEY, value=DB_VERSION))
|
||||
session.flush()
|
||||
|
||||
# add folder for current path
|
||||
folder = Folder(
|
||||
path=library_dir,
|
||||
uuid=str(uuid4()),
|
||||
)
|
||||
session.add(folder)
|
||||
session.expunge(folder)
|
||||
session.flush()
|
||||
self.folder = folder
|
||||
|
||||
# Generate default .ts_ignore file
|
||||
try:
|
||||
ts_ignore_template = (
|
||||
@@ -569,7 +587,6 @@ class Library:
|
||||
(self.__apply_db200_migration, 200, None), # changes: field tables
|
||||
(self.__apply_db201_migration, 201, 200), # changes: field tables
|
||||
(self.__apply_db202_migration, 202, None), # changes: tag_parents
|
||||
(self.__apply_db300_migration, 300, None), # changes: deletes folders
|
||||
]
|
||||
for migration, v, iv in migrations:
|
||||
if loaded_db_version < v and (iv is None or initial_db_version < iv):
|
||||
@@ -587,6 +604,22 @@ class Library:
|
||||
)
|
||||
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
|
||||
|
||||
with Session(self.engine) as session:
|
||||
# TODO: the folder logic has no use and was never finished, remove it
|
||||
# check if folder matching current path exists already
|
||||
# NOTE: this has been causing new Folders to be created when the library is moved, since
|
||||
# its introduction
|
||||
self.folder = session.scalar(select(Folder).where(Folder.path == library_dir))
|
||||
if not self.folder:
|
||||
folder = Folder(
|
||||
path=library_dir,
|
||||
uuid=str(uuid4()),
|
||||
)
|
||||
session.add(folder)
|
||||
session.expunge(folder)
|
||||
session.commit()
|
||||
self.folder = folder
|
||||
|
||||
# everything is fine, set the library path
|
||||
self.library_dir = library_dir
|
||||
return LibraryStatus(success=True, library_path=library_dir)
|
||||
@@ -876,44 +909,6 @@ class Library:
|
||||
session.flush()
|
||||
logger.info("[Library][Migration][202] Verified TagParent table data")
|
||||
|
||||
def __apply_db300_migration(self, session: Session, library_dir: Path):
|
||||
## remove folder_id column from entries table
|
||||
# create new table in the desired scheme (without folder_id column)
|
||||
session.execute(
|
||||
text("""
|
||||
CREATE TABLE entries_new (
|
||||
id INTEGER NOT NULL,
|
||||
path VARCHAR NOT NULL,
|
||||
suffix VARCHAR NOT NULL,
|
||||
date_created DATETIME,
|
||||
date_modified DATETIME,
|
||||
date_added DATETIME,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (path)
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
# transfer data to new table
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
|
||||
filename)
|
||||
SELECT id, path, suffix, date_created, date_modified, date_added, filename
|
||||
FROM entries
|
||||
""")
|
||||
)
|
||||
# delete old table
|
||||
session.execute(text("DROP TABLE entries"))
|
||||
# rename new table to old table
|
||||
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
|
||||
session.flush()
|
||||
|
||||
## drop table "folders"
|
||||
session.execute(text("DROP TABLE folders"))
|
||||
session.flush()
|
||||
|
||||
@property
|
||||
def field_templates(self) -> Sequence[BaseFieldTemplate]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -932,7 +927,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
|
||||
@@ -962,6 +961,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:
|
||||
@@ -1155,10 +1159,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] = []
|
||||
@@ -1476,7 +1549,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
|
||||
@@ -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,17 +184,31 @@ class Tag(Base):
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
# TODO: Use or replace these with an actual multi-root implementation
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
# TODO - implement this
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
uuid: Mapped[str] = mapped_column(unique=True)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
|
||||
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")
|
||||
|
||||
@@ -205,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] = []
|
||||
@@ -220,28 +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.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):
|
||||
@@ -251,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +17,7 @@ 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
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -37,11 +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]
|
||||
]
|
||||
@@ -142,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
|
||||
@@ -187,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!")
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ class MediaCategories:
|
||||
# These sets are used either individually or together to form the final sets
|
||||
# for the MediaCategory(s).
|
||||
# These sets may be combined and are NOT 1:1 with the final categories.
|
||||
_ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"}
|
||||
_ADOBE_PHOTOSHOP_SET: set[str] = {
|
||||
".pdd",
|
||||
".psb",
|
||||
@@ -581,7 +580,7 @@ class MediaCategories:
|
||||
)
|
||||
PDF_TYPES = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET,
|
||||
extensions=_PDF_SET,
|
||||
is_iana=False,
|
||||
name="pdf",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -10,8 +10,7 @@ import traceback
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.constants import BUILD_TYPE, VERSION
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.core.constants import VERSION, VERSION_BRANCH
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -60,7 +59,7 @@ def main():
|
||||
"--version",
|
||||
action="version",
|
||||
help="Displays TagStudio version information.",
|
||||
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
|
||||
version=f"TagStudio v{VERSION} {VERSION_BRANCH}",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.constants import (
|
||||
BUILD_TYPE,
|
||||
COPYRIGHT,
|
||||
DISCORD_URL,
|
||||
DOCS_URL,
|
||||
GITHUB_REPO_URL,
|
||||
VERSION,
|
||||
VERSION_BRANCH,
|
||||
)
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
@@ -42,12 +42,7 @@ from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, heade
|
||||
class AboutModal(QWidget):
|
||||
"""Modal window showing information about the TagStudio application."""
|
||||
|
||||
VERSION_STR: str = " ".join(
|
||||
[
|
||||
f"{Translations['about.version']}",
|
||||
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
|
||||
]
|
||||
)
|
||||
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
|
||||
|
||||
def __init__(self, config_path: Path | str):
|
||||
super().__init__()
|
||||
@@ -201,7 +196,8 @@ class AboutModal(QWidget):
|
||||
|
||||
# ripgrep Status
|
||||
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
|
||||
ripgrep_path_content = ClickableLabel(f"{ripgrep_status}")
|
||||
ripgrep_path_content = ClickableLabel()
|
||||
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
|
||||
ripgrep_location = RipgrepStatus.which()
|
||||
if ripgrep_location:
|
||||
ripgrep_path_content.clicked.connect(
|
||||
|
||||
@@ -1295,12 +1295,11 @@ class ThumbRenderer(QObject):
|
||||
return im
|
||||
|
||||
@staticmethod
|
||||
def _pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
|
||||
"""Render a thumbnail for a PDF or Adobe Illustator file.
|
||||
def _pdf_thumb(filepath: Path, size: int) -> Image.Image | None:
|
||||
"""Render a thumbnail for a PDF file.
|
||||
|
||||
filepath (Path): The path of the file.
|
||||
size (int): The size of the icon.
|
||||
ext (str): The file extension.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
|
||||
@@ -1322,7 +1321,7 @@ class ThumbRenderer(QObject):
|
||||
else:
|
||||
page_size *= size / page_size.width()
|
||||
# Enlarge image for anti-aliasing
|
||||
scale_factor = 2.5 if ext in {".pdf"} else 1
|
||||
scale_factor = 2.5
|
||||
page_size *= scale_factor
|
||||
# Render image with no anti-aliasing for speed
|
||||
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
|
||||
@@ -1911,7 +1910,7 @@ class ThumbRenderer(QObject):
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.PDF_TYPES, mime_fallback=True
|
||||
):
|
||||
image = self._pdf_thumb(_filepath, adj_size, ext)
|
||||
image = self._pdf_thumb(_filepath, adj_size)
|
||||
# Archives =====================================================
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
|
||||
image = self._archive_thumb(_filepath, ext)
|
||||
|
||||
@@ -40,7 +40,7 @@ from PySide6.QtGui import (
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
|
||||
|
||||
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
from tagstudio.core.constants import BUILD_TYPE, TAG_ARCHIVED, TAG_FAVORITE, VERSION
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.driver import DriverMixin
|
||||
from tagstudio.core.enums import AppCacheItems, MacroID, ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
|
||||
@@ -203,7 +203,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
self.scrollbar_pos = 0
|
||||
self.spacing = None
|
||||
|
||||
self.branch: str = (" (" + Translations[BUILD_TYPE] + ")") if BUILD_TYPE else ""
|
||||
self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
|
||||
self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}"
|
||||
# self.title_text: str = self.base_title
|
||||
# self.buffer = {}
|
||||
|
||||
@@ -10,7 +10,7 @@ from PySide6.QtCore import QRect, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import QSplashScreen, QWidget
|
||||
|
||||
from tagstudio.core.constants import BUILD_TYPE, COPYRIGHT, COPYRIGHT_COMPACT, VERSION
|
||||
from tagstudio.core.constants import COPYRIGHT, COPYRIGHT_COMPACT, VERSION, VERSION_BRANCH
|
||||
from tagstudio.qt.global_settings import Splash
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
@@ -21,12 +21,7 @@ logger = structlog.get_logger(__name__)
|
||||
class SplashScreen:
|
||||
"""The custom splash screen widget for TagStudio."""
|
||||
|
||||
VERSION_STR: str = " ".join(
|
||||
[
|
||||
f"{Translations['about.version']}",
|
||||
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
|
||||
]
|
||||
)
|
||||
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
|
||||
DEFAULT_SPLASH = Splash.AURORA
|
||||
|
||||
def __init__(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 43 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -10,7 +10,6 @@
|
||||
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
|
||||
"about.website": "Website",
|
||||
"app.git": "Git Commit",
|
||||
"app.nightly": "Nightly",
|
||||
"app.pre_release": "Pre-Release",
|
||||
"app.title": "{base_title} - Library '{library_dir}'",
|
||||
"color_manager.title": "Manage Tag Colors",
|
||||
|
||||
@@ -20,6 +20,7 @@ sys.path.insert(0, str(CWD.parent))
|
||||
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
@@ -35,18 +36,22 @@ def file_mediatypes_library():
|
||||
|
||||
status = lib.open_library(Path(""), in_memory=True)
|
||||
assert status.success
|
||||
folder = unwrap(lib.folder)
|
||||
|
||||
entry1 = Entry(
|
||||
folder=folder,
|
||||
path=Path("foo.png"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
entry2 = Entry(
|
||||
folder=folder,
|
||||
path=Path("bar.png"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
entry3 = Entry(
|
||||
folder=folder,
|
||||
path=Path("baz.apng"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
@@ -82,6 +87,7 @@ def library(request, library_dir: Path): # pyright: ignore
|
||||
lib = Library()
|
||||
status = lib.open_library(library_path, in_memory=True)
|
||||
assert status.success
|
||||
folder = unwrap(lib.folder)
|
||||
|
||||
tag = Tag(
|
||||
name="foo",
|
||||
@@ -110,6 +116,7 @@ def library(request, library_dir: Path): # pyright: ignore
|
||||
# default item with deterministic name
|
||||
entry = Entry(
|
||||
id=1,
|
||||
folder=folder,
|
||||
path=Path("foo.txt"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
@@ -117,6 +124,7 @@ def library(request, library_dir: Path): # pyright: ignore
|
||||
|
||||
entry2 = Entry(
|
||||
id=2,
|
||||
folder=folder,
|
||||
path=Path("one/two/bar.md"),
|
||||
fields=[TextField(name="Title", value="I'm a Test Title")],
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,21 +8,25 @@ from tagstudio.core.library.alchemy.fields import BaseField, TextField
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
|
||||
def test_refresh_dupe_files(library: Library):
|
||||
library.library_dir = Path("/tmp/")
|
||||
folder = unwrap(library.folder)
|
||||
|
||||
fields: list[BaseField] = [TextField(name="Title", value="I'm a Test Title")]
|
||||
|
||||
entry = Entry(
|
||||
folder=folder,
|
||||
path=Path("bar/foo.txt"),
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
entry2 = Entry(
|
||||
folder=folder,
|
||||
path=Path("foo/foo.txt"),
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
+14
-5
@@ -77,12 +77,13 @@ def test_library_add_file(library: Library):
|
||||
"""Check Entry.path handling for insert vs lookup"""
|
||||
entry = Entry(
|
||||
path=Path("bar.txt"),
|
||||
folder=unwrap(library.folder),
|
||||
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]):
|
||||
@@ -138,7 +139,8 @@ def test_get_entry(library: Library, entry_min: Entry):
|
||||
|
||||
|
||||
def test_entries_count(library: Library):
|
||||
entries = [Entry(path=Path(f"{x}.txt"), fields=[]) for x in range(10)]
|
||||
folder = unwrap(library.folder)
|
||||
entries = [Entry(path=Path(f"{x}.txt"), folder=folder, fields=[]) for x in range(10)]
|
||||
new_ids = library.add_entries(entries)
|
||||
assert len(new_ids) == 10
|
||||
|
||||
@@ -252,6 +254,7 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr
|
||||
def test_mirror_entry_fields(library: Library):
|
||||
# Create and add entries with fields
|
||||
entry_a = Entry(
|
||||
folder=unwrap(library.folder),
|
||||
path=Path("title_and_date.txt"),
|
||||
fields=[
|
||||
TextField(name="Title", value="I'm a Test Title"),
|
||||
@@ -259,6 +262,7 @@ def test_mirror_entry_fields(library: Library):
|
||||
],
|
||||
)
|
||||
entry_b = Entry(
|
||||
folder=unwrap(library.folder),
|
||||
path=Path("notes.txt"),
|
||||
fields=[
|
||||
TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True),
|
||||
@@ -266,6 +270,7 @@ def test_mirror_entry_fields(library: Library):
|
||||
],
|
||||
)
|
||||
entry_c = Entry(
|
||||
folder=unwrap(library.folder),
|
||||
path=Path("date_published.txt"),
|
||||
fields=[
|
||||
DatetimeField(name="Date Published", value="2000-01-01 12:00:00"),
|
||||
@@ -314,11 +319,14 @@ def test_mirror_entry_fields(library: Library):
|
||||
|
||||
|
||||
def test_merge_entries(library: Library):
|
||||
folder = unwrap(library.folder)
|
||||
|
||||
tag_0: Tag = unwrap(library.add_tag(Tag(id=1010, name="tag_0")))
|
||||
tag_1: Tag = unwrap(library.add_tag(Tag(id=1011, name="tag_1")))
|
||||
tag_2: Tag = unwrap(library.add_tag(Tag(id=1012, name="tag_2")))
|
||||
|
||||
entry_a = Entry(
|
||||
folder=folder,
|
||||
path=Path("a"),
|
||||
fields=[
|
||||
TextField(name="Author", value="Author McAuthorson"),
|
||||
@@ -326,6 +334,7 @@ def test_merge_entries(library: Library):
|
||||
],
|
||||
)
|
||||
entry_b = Entry(
|
||||
folder=folder,
|
||||
path=Path("b"),
|
||||
fields=[TextField(name="Notes", value="test note", is_multiline=True)],
|
||||
)
|
||||
@@ -338,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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user