Compare commits

..

1 Commits

Author SHA1 Message Date
Travis Abendshien 6b66695ab3 feat!: add file date metadata to db 2026-07-12 21:09:05 -07:00
50 changed files with 1460 additions and 1338 deletions
+4 -11
View File
@@ -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
View File
@@ -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
View File
@@ -118,7 +118,7 @@ If you choose to manually set up a virtual environment and install dependencies
!!! Warning "Linux Library Dependencies"
If developing TagStudio on Linux, certain libraries are required that may not be included with your distribution. A full list of these can be found [here](install.md#linux).
## Nix & NixOS
## Nix(OS)
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
+2 -2
View File
@@ -27,12 +27,12 @@ hide:
![TagStudio screenshot](./assets/tag_bubbles.png)
<span style="font-family: Bai Jamjuree, Roboto, sans-serif; font-size: 1.1rem; letter-spacing: -0.05rem;"><span style="font-weight: 900;">Tag</span><span style="font-weight: 500;"><i>Studio</i></span></span> is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
**TagStudio** is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
</div>
<figure markdown="span">
[:material-github: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
[:material-download: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
</figure>
## :material-star: Core Features
+3 -3
View File
@@ -95,9 +95,9 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
Aborted (core dumped)
```
### :material-nix: Nix & NixOS
### :material-nix: Nix(OS)
For [Nix](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
Two packages are provided: `tagstudio` and `tagstudio-jxl`. The distinction was made because `tagstudio-jxl` has an extra compilation step for [JPEG-XL](https://jpeg.org/jpegxl) image support. To give either of them a test run, you can execute `nix run github:TagStudioDev/TagStudio#tagstudio`. If you are in a cloned repository and wish to run a package with the context of the repository, you can simply use `nix run` with no arguments.
@@ -256,4 +256,4 @@ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extracto
### ripgrep
A recommended tool to improve the performance of directory scanning is [ripgrep](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
+2 -69
View File
@@ -3,14 +3,11 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
/* Dark Theme */
[data-md-color-scheme="slate"] {
--md-primary-fg-color: rgb(197, 110, 255);
--md-accent-fg-color: rgb(92, 222, 255);
--md-default-bg-color: #060617;
--md-default-fg-color: #eae1ff;
--md-default-fg-color--light: #c2a5ff;
--md-code-fg-color: #d8c7ffcc;
--md-default-fg-color--light: #b898ff;
--md-code-fg-color: #eae1ffcc;
--md-code-hl-string-color: rgb(92, 255, 228);
--md-code-hl-keyword-color: rgb(61, 155, 255);
--md-code-hl-constant-color: rgb(205, 78, 255);
@@ -20,8 +17,6 @@
/* Light Theme */
[data-md-color-scheme="default"] {
--md-primary-fg-color: #7758ff;
--md-accent-fg-color: rgb(22, 166, 255);
--md-default-fg-color--light: #090a26;
}
@@ -78,11 +73,6 @@ td {
padding: 0.5em 1em 0.5em 1em !important;
}
hr {
border-bottom-width: 2px !important;
border-color: #9988ff50 !important;
}
.md-typeset ul li ul {
margin-top: 0;
margin-bottom: 0.1rem;
@@ -129,18 +119,6 @@ h2,
margin-right: -0.8rem;
}
.md-code__nav,
.md-content__button,
.headerlink {
border-radius: 0.2rem;
background: none;
color: #9988ff50 !important;
}
.md-code__nav:hover {
background-color: #9988ff50;
}
figcaption {
margin-top: 0 !important;
}
@@ -171,51 +149,6 @@ td code {
padding: 0 !important;
}
.admonition {
border-radius: 0.4rem !important;
border-width: 2px !important;
}
.highlight > .filename {
border-width: 2px !important;
border: solid;
border-color: #9988ff10 !important;
border-radius: 0.4rem 0.4rem 0 0 !important;
}
code {
border-radius: 0.4rem !important;
border-width: 2px !important;
border: solid;
border-color: #9988ff10;
}
:is(span, ul, li, td, p, a) > code {
border-width: 1px !important;
border-radius: 0.1rem !important;
}
.filename + pre > code {
border-top-width: 0 !important;
border-radius: 0 0 0.4rem 0.4rem !important;
}
.tabbed-labels.tabbed-labels--linked {
padding-left: 0.4rem;
padding-right: 0.4rem;
box-shadow: none !important;
}
.admonition-title {
font-size: 0.7rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
font-weight: 600 !important;
}
.admonition-title span {
margin-top: 0.05rem !important;
}
/* Matches the palette used by mkdocs-material */
.priority-high {
color: #f1185a;
-27
View File
@@ -29,30 +29,3 @@ h2 {
margin-right: 0;
}
}
.grid.cards > ul > li {
border-radius: 0.4rem !important;
border-width: 2px !important;
border-color: #9988ff20 !important;
}
.md-button--primary {
margin: 1rem;
padding: 0.3rem 1.2rem !important;
border-radius: 0.4rem !important;
font-size: 1rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
background: linear-gradient(60deg, rgb(205, 78, 255) 0%, rgb(116, 123, 255) 100%);
border-style: solid;
border-width: 0 0 2px 0 !important;
border-color: #ffffff33 !important;
}
.md-button--primary:hover {
background: linear-gradient(60deg, rgb(205, 78, 255) 30%, rgb(116, 123, 255) 100%);
border-color: #ffffff55 !important;
}
.md-button--primary span {
margin-top: 0.1rem !important;
}
+4 -4
View File
@@ -70,16 +70,16 @@ theme:
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: custom
accent: custom
primary: purple
accent: purple
toggle:
icon: material/lightbulb
name: Switch to Dark Mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: custom
accent: custom
primary: purple
accent: purple
toggle:
icon: material/lightbulb-night-outline
name: Switch to System Preference
+3 -3
View File
@@ -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"
@@ -88,7 +88,7 @@ filterwarnings = [
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/renderers/vendored/pydub/",
"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
@@ -117,7 +117,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "E402"]
"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
[tool.ruff.lint.pydocstyle]
convention = "google"
+2 -2
View File
@@ -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 (
+117 -46
View File
@@ -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
@@ -1728,8 +1801,6 @@ class Library:
session.flush()
if aliases is not None:
for a in aliases:
a.tag_id = tag.id
self.update_aliases(tag, aliases, session)
session.flush()
@@ -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
+48 -13
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,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
+14 -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
@@ -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!")
+1 -3
View File
@@ -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",
@@ -305,7 +304,6 @@ class MediaCategories:
".nef",
".nrw",
".orf",
".r3d",
".raf",
".raw",
".rw2",
@@ -582,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",
)
+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
+2 -3
View File
@@ -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()
+6 -2
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import ffmpeg
from tagstudio.renderers.vendored.probe import probe
from tagstudio.qt.previews.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -23,7 +23,11 @@ def is_readable_video(filepath: Path | str):
return False
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]:
if stream.get("codec_tag_string") in [
"drma",
"drms",
"drmi",
]:
return False
except ffmpeg.Error:
return False
+4 -8
View File
@@ -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(
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,11 @@
# SPDX-FileCopyrightText: (c) 2017 The Blender Foundation
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# <pep8 compliant>
## This file is a modified script that gets the thumbnail data stored in a blend file
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
@@ -43,7 +43,7 @@ from pydub.utils import (
)
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
basestring = str
xrange = range
@@ -256,7 +256,7 @@ class _AudioSegment:
self.sample_width = 4
self.frame_width = self.channels * self.sample_width
super().__init__(*args, **kwargs)
super(_AudioSegment, self).__init__(*args, **kwargs)
@property
def raw_data(self):
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
+2 -2
View File
@@ -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 = {}
+2 -7
View File
@@ -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__(
-158
View File
@@ -1,158 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Literal
import py7zr
import py7zr.io
import rarfile
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
class SevenZipFile(py7zr.SevenZipFile):
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
super().__init__(filepath, mode)
def read(self, name: str) -> bytes:
# SevenZipFile must be reset after every extraction
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
self.reset()
factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB
self.extract(targets=[name], factory=factory)
return factory.get(name).read()
class TarFile:
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
self.tar: tarfile.TarFile
self.filepath = filepath
self.mode: Literal["r"] = mode
def namelist(self) -> list[str]:
return self.tar.getnames()
def read(self, name: str) -> bytes:
return unwrap(self.tar.extractfile(name)).read()
def __enter__(self) -> "TarFile":
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
return self
def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
self.tar.__exit__(*args)
def open_archive(filepath: Path, ext: str = "") -> Archive:
"""Open an archive with its corresponding archiver.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension.
Returns:
Archive: The opened archive.
"""
archiver: type[Archive] = zipfile.ZipFile
if ext in {".7z", ".cb7", ".s7z"}:
archiver = SevenZipFile
elif ext in {".cbr", ".rar"}:
archiver = rarfile.RarFile
elif ext in {".cbt", ".tar", ".tgz"}:
archiver = TarFile
return archiver(filepath, "r")
def first_image_in_archive(archive: Archive) -> Image.Image | None:
"""Find and extract the first renderable image in the archive.
Args:
archive (Archive): The current archive.
Returns:
Image: The first renderable image in the archive.
"""
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
ext = Path(file_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(image_data))
return None
def archive_thumb(
filepath: Path, ext: str = "", image_names: list[Path] | list[str] | None = None
) -> Image.Image | None:
"""Extract an embedded preview image from an archive.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension. Used to help determine more specific archive type.
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
Returns:
Image: The first image found in the archive.
"""
try:
with open_archive(filepath, ext) as archive:
# If no list of image names to search for was provided, default to the first image.
if not image_names:
return first_image_in_archive(archive)
for image_name in image_names:
if image_name in archive.namelist():
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(file_data))
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return None
def apple_embedded_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
image_names: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
return archive_thumb(filepath, image_names=image_names)
def krita_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Krita file."""
image_names = ["preview.png"]
return archive_thumb(filepath, image_names=image_names)
def open_doc_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for an OpenDocument file."""
image_names = ["Thumbnails/thumbnail.png"]
return archive_thumb(filepath, image_names=image_names)
def powerpoint_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
image_names = ["docProps/thumbnail.jpeg"]
return archive_thumb(filepath, image_names=image_names)
-149
View File
@@ -1,149 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from io import BytesIO
from pathlib import Path
from warnings import catch_warnings
import numpy as np
import structlog
from mutagen import flac, id3, mp4
from mutagen._util import MutagenError
from PIL import Image, ImageDraw
from tagstudio.renderers.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
logger = structlog.get_logger(__name__)
def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
"""
image: Image.Image | None = None
try:
if not filepath.is_file():
raise FileNotFoundError
artwork = None
if ext in [".mp3"]:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = Image.open(BytesIO(id3_covers[0].data))
elif ext in [".flac"]:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = Image.open(BytesIO(flac_covers[0].data))
elif ext in [".mp4", ".m4a", ".aac"]:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = Image.open(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage]
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
def audio_waveform_thumb(
filepath: Path, ext: str, size: int, pixel_ratio: float
) -> Image.Image | None:
"""Render a waveform image from an audio file.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
size (tuple[int,int]): The size of the thumbnail.
pixel_ratio (float): The screen pixel ratio.
"""
# BASE_SCALE used for drawing on a larger image and resampling down
# to provide an antialiased effect.
base_scale: int = 2
samples_per_bar: int = 3
size_scaled: int = size * base_scale
allow_small_min: bool = False
im: Image.Image | None = None
try:
bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64)
audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType]
data = np.frombuffer(buffer=audio._data, dtype=np.int16)
data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar)
bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2
line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale
bar_height: float = (size_scaled) - (size_scaled // bar_margin)
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
for i in range(-1, len(data_indices)):
d = data[math.ceil(data_indices[i]) - 1]
if count < samples_per_bar:
count = count + 1
with catch_warnings(record=True):
if abs(d) > maximum_item:
maximum_item = int(abs(d))
else:
max_array.append(maximum_item)
if maximum_item > highest_line:
highest_line = maximum_item
maximum_item = 0
count = 1
line_ratio = max(highest_line / bar_height, 1)
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
current_x = current_x + line_width + bar_margin
im.resize((size, size), Image.Resampling.BILINEAR)
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
return im
-43
View File
@@ -1,43 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.renderers.vendored.blender_renderer import (
blend_thumb, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
def blender_thumb(filepath: Path) -> Image.Image | None:
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
Args:
filepath (Path): The path of the file.
"""
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
im: Image.Image | None = None
try:
if (blend_image := blend_thumb(str(filepath))) is not None:
bg = Image.new("RGB", blend_image.size, color=bg_color)
bg.paste(blend_image, mask=blend_image.getchannel(3))
im = bg
else:
logger.info(
f"[ThumbRenderer][BLENDER][INFO] {filepath.name} "
"Doesn't have an embedded thumbnail."
)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-80
View File
@@ -1,80 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import base64
import sqlite3
import struct
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
Args:
filepath (Path): The path of the .clip file.
Returns:
Image: The embedded thumbnail, if extractable.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
blob = f.read()
sqlite_index = blob.find(b"SQLite format 3")
if sqlite_index == -1:
return im
with sqlite3.connect(":memory:") as conn:
conn.deserialize(blob[sqlite_index:])
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
if thumbnail:
im = Image.open(BytesIO(thumbnail[0]))
conn.close()
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def pdn_thumb(filepath: Path) -> Image.Image | None:
"""Extract the base64-encoded thumbnail from a .pdn file header.
Args:
filepath (Path): The path of the .pdn file.
Returns:
Image: the decoded PNG thumbnail or None by default.
"""
im: Image.Image | None = None
with open(filepath, "rb") as f:
try:
# First 4 bytes are the magic number
if f.read(4) != b"PDN3":
return im
# Header length is a little-endian 24-bit int
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
if thumb_element is None:
return im
encoded_png = thumb_element.get("png")
if encoded_png:
decoded_png = base64.b64decode(encoded_png)
im = Image.open(BytesIO(decoded_png))
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-73
View File
@@ -1,73 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from xml.etree.ElementTree import Element
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.archive import Archive, first_image_in_archive, open_archive
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
filepath (Path): The path to the ePub file.
ext (str): The file extension.
Returns:
Image: The cover specified in ComicInfo.xml,
the first image found in the ePub file, or None by default.
"""
im: Image.Image | None = None
try:
with open_archive(filepath, ext) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = _cover_from_comic_info(archive, comic_info, "FrontCover")
if not im:
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
if not im:
im = first_image_in_archive(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def _cover_from_comic_info(
archive: Archive, comic_info: Element, cover_type: str
) -> Image.Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
archive (Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
Returns:
Image: The cover specified in ComicInfo.xml.
"""
im: Image.Image | None = None
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType]
page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType]
ext = Path(page_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
im = image_from_bytes(BytesIO(image_data))
return im
-108
View File
@@ -1,108 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
from typing import cast
import numpy as np
import structlog
from PIL import Image, ImageDraw, ImageFont
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
logger = structlog.get_logger(__name__)
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a small font preview ("Aa") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
try:
bg = Image.new("RGB", (size, size), color="#000000")
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
draw = ImageDraw.Draw(raw)
font = ImageFont.truetype(filepath, size=size)
# NOTE: While a stroke effect is desired, the text
# method only allows for outer strokes, which looks
# a bit weird when rendering fonts.
draw.text(
(size // 8, size // 8),
"Aa",
font=font,
fill="#FF0000",
# stroke_width=math.ceil(size / 96),
# stroke_fill="#FFFF00",
)
# NOTE: Change to getchannel(1) if using an outline.
data = np.asarray(raw.getchannel(0))
m, n = data.shape[:2]
col: np.ndarray = cast(np.ndarray, data.any(0))
row: np.ndarray = cast(np.ndarray, data.any(1))
cropped_data = np.asarray(raw)[
row.argmax() : m - row[::-1].argmax(),
col.argmax() : n - col[::-1].argmax(),
]
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
margin: int = math.ceil(size // 16)
orig_x, orig_y = cropped_im.size
new_x, new_y = (size, size)
if orig_x > orig_y:
new_x = size
new_y = math.ceil(size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = size
new_x = math.ceil(size * (orig_x / orig_y))
cropped_im = cropped_im.resize(
size=(new_x - (margin * 2), new_y - (margin * 2)),
resample=Image.Resampling.BILINEAR,
)
bg.paste(
cropped_im,
box=(margin, margin + ((size - new_y) // 2)),
)
im = bg
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
"""Render a large font preview ("Alphabet") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
# Scale the sample font sizes to the preview image
# resolution,assuming the sizes are tuned for 256px.
im: Image.Image | None = None
try:
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
bg = Image.new("RGBA", (size, size), color="#00000000")
draw = ImageDraw.Draw(bg)
lines_of_padding = 2
y_offset = 0.0
for font_size in scaled_sizes:
font = ImageFont.truetype(filepath, size=font_size)
text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw)
draw.multiline_text((0, y_offset), text_wrapped, font=font)
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
(0, 0), "A", font=font
)[-1]
im = auto_theme_overlay(bg, use_alpha=False)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-58
View File
@@ -1,58 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
import struct
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
import structlog
from PIL import Image
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from a .mdp file.
Args:
filepath (Path): The path of the .mdp file.
Returns:
Image: The embedded thumbnail.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
magic = struct.unpack("<7sx", f.read(8))[0]
if magic != b"mdipack":
return im
bin_header = struct.unpack("<LLL", f.read(12))
xml_header = ET.fromstring(f.read(bin_header[1]))
mdibin_count = len(xml_header.findall("./*Layer")) + 1
for _ in range(mdibin_count):
pac_header = struct.unpack("<3sxLLLL48s64s", f.read(132))
if not pac_header[6].startswith(b"thumb"):
f.seek(pac_header[3], os.SEEK_CUR)
continue
thumb_element = unwrap(xml_header.find("Thumb"))
dimensions = (
int(unwrap(thumb_element.get("width"))),
int(unwrap(thumb_element.get("height"))),
)
thumb_blob = f.read(pac_header[3])
if pac_header[2] == 1:
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
break
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-66
View File
@@ -1,66 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
from PySide6.QtGui import QImage
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
logger = structlog.get_logger(__name__)
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
"""Render a thumbnail for a PDF or Adobe Illustrator 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
file: QFile = QFile(filepath)
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
if not success:
logger.error("Couldn't render thumbnail", filepath=filepath)
return im
document: QPdfDocument = QPdfDocument()
document.load(file)
file.close()
# Transform page_size in points to pixels with proper aspect ratio
page_size: QSizeF = document.pagePointSize(0)
ratio_hw: float = page_size.height() / page_size.width()
if ratio_hw >= 1:
page_size *= size / page_size.height()
else:
page_size *= size / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5 if ext in {".pdf"} else 1
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
render_options.setRenderFlags(
QPdfDocumentRenderOptions.RenderFlag.TextAliased
| QPdfDocumentRenderOptions.RenderFlag.ImageAliased
| QPdfDocumentRenderOptions.RenderFlag.PathAliased
)
# Convert QImage to PIL Image
q_image: QImage = document.render(0, page_size.toSize(), render_options)
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
try:
q_image.save(buffer, "PNG") # pyright: ignore
im = Image.open(BytesIO(buffer.buffer().data()))
finally:
buffer.close()
# Replace transparent pixels with white (otherwise Background defaults to transparent)
return replace_transparent_pixels(im)
-126
View File
@@ -1,126 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
from io import BytesIO
from pathlib import Path
import cv2
import numpy as np
import rawpy
import structlog
from PIL import Image, ImageOps, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
try:
import pillow_jxl # noqa: F401 # pyright: ignore
except ImportError as e:
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
register_heif_opener()
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
def raster_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a standard image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with filepath.open("rb") as file:
im = image_from_bytes(BytesIO(file.read()))
except (
FileNotFoundError,
UnidentifiedImageError,
DecompressionBombError,
NotImplementedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def exr_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a EXR image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
# Load the EXR data to an array and rotate the color space from BGRA -> RGBA
raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED)
assert raw_array is not None
raw_array[..., :3] = raw_array[..., 2::-1]
# Correct the gamma of the raw array
gamma = 2.2
array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma)
array = (array_gamma * 255).astype(np.uint8)
im = Image.fromarray(array, mode="RGBA")
# Paste solid background
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def raw_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a RAW image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess(use_camera_wb=True)
im = Image.frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except (
DecompressionBombError,
LibRawIOError,
LibRawFileUnsupportedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def image_from_bytes(image_data: BytesIO) -> Image.Image:
"""Load a raster image and add a background if it's transparent.
Args:
image_data (BytesIO): The binary image data.
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image.Image = Image.open(image_data)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
return unwrap(ImageOps.exif_transpose(im))
-30
View File
@@ -1,30 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import srctools
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def vtf_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
Uses the srctools library for reading VTF files.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
vtf = srctools.VTF.read(f)
im = vtf.get(frame=0).to_PIL()
except (ValueError, FileNotFoundError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-55
View File
@@ -1,55 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import cv2
import structlog
from PIL import Image, ImageDraw, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.core.utils.encoding import detect_char_encoding
logger = structlog.get_logger(__name__)
def text_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a plaintext file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
fg_color: str = (
"#FFFFFF"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#111111"
)
try:
encoding = detect_char_encoding(filepath)
with open(filepath, encoding=encoding) as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color=bg_color)
draw = ImageDraw.Draw(bg)
draw.text((16, 16), text, fill=fg_color)
im = bg
except (
UnidentifiedImageError,
cv2.error,
DecompressionBombError,
UnicodeDecodeError,
OSError,
FileNotFoundError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
-56
View File
@@ -1,56 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
from io import BytesIO
from pathlib import Path
import structlog
from PIL import (
Image,
UnidentifiedImageError,
)
from PySide6.QtCore import QBuffer, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtSvg import QSvgRenderer
logger = structlog.get_logger(__name__)
def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
"""Render a thumbnail for a vector image, such as SVG.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
# Create an image to draw the svg to and a painter to do the drawing
q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32)
q_image.fill("#1e1e1e")
# Create an svg renderer, then render to the painter
svg: QSvgRenderer = QSvgRenderer(str(filepath))
if not svg.isValid():
raise UnidentifiedImageError
painter: QPainter = QPainter(q_image)
svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
svg.render(painter)
painter.end()
# Write the image to a buffer as png
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType]
# Load the image from the buffer
im = Image.new("RGB", (size, size), color="#1e1e1e")
im.paste(Image.open(BytesIO(buffer.data().data())))
im = im.convert(mode="RGB")
buffer.close()
return im
-55
View File
@@ -1,55 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
import cv2
import structlog
from cv2.typing import MatLike
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from tagstudio.qt.helpers.file_tester import is_readable_video
logger = structlog.get_logger(__name__)
def video_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a video file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
frame: MatLike | None = None
try:
if is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
# TODO: Move this check to is_readable_video()
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
raise cv2.error("File is invalid or has 0 frames")
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
# NOTE: Depending on the video format, compression, and
# frame count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
max_frame_seek: int = 10
for i in range(
0,
min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
if frame is not None:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
im = Image.fromarray(frame)
except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
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",
+8
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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))