mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-09-22 02:18:22 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c6558509 | |||
| 1f1d86e1c9 | |||
| 6e07fd37d8 | |||
| a25a9baf1e | |||
| 4d19ebeea5 | |||
| 658b7840c4 |
+11
@@ -55,3 +55,14 @@ path = [
|
||||
]
|
||||
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
|
||||
SPDX-License-Identifier = "Apache-2.0"
|
||||
|
||||
[[annotations]]
|
||||
path = ["src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf"]
|
||||
SPDX-FileCopyrightText = "(c) 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)"
|
||||
SPDX-License-Identifier = "OFL-1.1"
|
||||
|
||||
|
||||
[[annotations]]
|
||||
path = ["src/tagstudio/resources/fonts/JetBrainsMono/**"]
|
||||
SPDX-FileCopyrightText = "(c) 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)"
|
||||
SPDX-License-Identifier = "OFL-1.1"
|
||||
|
||||
+5
-4
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.6.4"
|
||||
version = "9.7.0"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14,<3.15"
|
||||
@@ -21,15 +21,18 @@ dependencies = [
|
||||
"mutagen~=1.47",
|
||||
"numpy~=2.2",
|
||||
"opencv_python~=4.11",
|
||||
"Pillow>=10.2,<12",
|
||||
"pillow-heif~=1.5.0",
|
||||
"pillow-jxl-plugin~=1.3",
|
||||
"Pillow>=10.2,<12",
|
||||
"py7zr~=1.1.3",
|
||||
"pydantic~=2.10",
|
||||
"pydub~=0.25",
|
||||
"Pygments~=2.21",
|
||||
"PySide6==6.11.2",
|
||||
"rarfile==4.2",
|
||||
"rawpy~=0.27",
|
||||
"requests~=2.31.0",
|
||||
"semver~=3.0.4",
|
||||
"Send2Trash>=1.8,<3",
|
||||
"SQLAlchemy~=2.0",
|
||||
"srctools~=2.6",
|
||||
@@ -38,8 +41,6 @@ dependencies = [
|
||||
"typing_extensions~=4.13",
|
||||
"ujson~=5.10",
|
||||
"wcmatch==10.*",
|
||||
"requests~=2.31.0",
|
||||
"semver~=3.0.4",
|
||||
]
|
||||
|
||||
[project.gui-scripts]
|
||||
|
||||
@@ -13,6 +13,7 @@ from tomllib import load
|
||||
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
||||
from PyInstaller.building.build_main import Analysis
|
||||
from PyInstaller.building.osx import BUNDLE
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
|
||||
parser = ArgumentParser()
|
||||
# HACK: Without this, the script will fail if empty arguments are passed.
|
||||
@@ -45,7 +46,7 @@ a = Analysis(
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=datafiles,
|
||||
hiddenimports=[],
|
||||
hiddenimports=collect_submodules("tagstudio.previews.renderers"),
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
excludes=[],
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtCore import QSettings
|
||||
from tagstudio.core.constants import TS_FOLDER_NAME
|
||||
from tagstudio.core.enums import AppCacheItems
|
||||
from tagstudio.core.library.alchemy.library import LibraryStatus
|
||||
from tagstudio.core.query_lang.file_groups import register_types
|
||||
from tagstudio.qt.app_settings import AppSettings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -21,6 +22,8 @@ class DriverMixin:
|
||||
# TODO: AppSettings is Qt-specific and should not be in a base driver class.
|
||||
settings: AppSettings
|
||||
|
||||
register_types() # Register all filetypes for the SEARCH context.
|
||||
|
||||
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
|
||||
"""Check if the path of library is valid."""
|
||||
library_path: Path | None = None
|
||||
|
||||
@@ -31,7 +31,7 @@ class TagClickActionOption(enum.IntEnum):
|
||||
DEFAULT = OPEN_EDIT
|
||||
|
||||
|
||||
class Theme(enum.StrEnum):
|
||||
class ThemePalette(enum.StrEnum):
|
||||
COLOR_BG_DARK = "#65000000"
|
||||
COLOR_BG_LIGHT = "#22000000"
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
@@ -44,6 +44,13 @@ class Theme(enum.StrEnum):
|
||||
COLOR_FORBIDDEN_BG = "#65440D12"
|
||||
|
||||
|
||||
class Theme(enum.IntEnum):
|
||||
DARK = 0
|
||||
LIGHT = 1
|
||||
SYSTEM = 2
|
||||
DEFAULT = SYSTEM
|
||||
|
||||
|
||||
class OpenStatus(enum.IntEnum):
|
||||
NOT_FOUND = 0
|
||||
SUCCESS = 1
|
||||
|
||||
@@ -504,20 +504,19 @@ class Library:
|
||||
) -> LibraryStatus:
|
||||
logger.info("[Library] Opening SQLite Library", library_dir=library_dir)
|
||||
|
||||
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
|
||||
|
||||
# migrate if necessary
|
||||
try:
|
||||
migrations = DBMigrations(library_dir, self.engine)
|
||||
with DBMigrations(library_dir, sql_filename) as migrations:
|
||||
# save backup if patches will be applied
|
||||
if migrations.required:
|
||||
Library.save_library_backup_to_disk(library_dir)
|
||||
|
||||
# save backup if patches will be applied
|
||||
if migrations.required:
|
||||
Library.save_library_backup_to_disk(library_dir)
|
||||
|
||||
migrations.run()
|
||||
migrations.run()
|
||||
except MigrationError as e:
|
||||
return LibraryStatus(success=False, message=e.args[0])
|
||||
|
||||
# everything is fine, set the library path
|
||||
# open up-to-date library
|
||||
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
|
||||
self.library_dir = library_dir
|
||||
return LibraryStatus(success=True, library_path=library_dir)
|
||||
|
||||
|
||||
@@ -2,27 +2,23 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from sqlite3 import Connection
|
||||
from typing import override
|
||||
|
||||
import sqlalchemy
|
||||
import structlog
|
||||
import ujson
|
||||
from sqlalchemy import Engine, and_, delete, select, text, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from tagstudio.core.constants import IGNORE_NAME, TAG_ARCHIVED, TS_FOLDER_NAME
|
||||
from tagstudio.core.library.alchemy import default_color_groups
|
||||
from tagstudio.core.library.alchemy.constants import (
|
||||
DB_VERSION,
|
||||
DB_VERSION_CURRENT_KEY,
|
||||
DB_VERSION_INITIAL_KEY,
|
||||
DEFAULT_FIELD_TEMPLATES,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag, TagColorGroup, Version
|
||||
from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP
|
||||
from tagstudio.core.library.alchemy.utils import list_tables
|
||||
from tagstudio.core.library.ignore import migrate_ext_list
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.translations import Translations
|
||||
@@ -41,14 +37,16 @@ class DBMigration:
|
||||
initial_version: int | None = None
|
||||
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DBMigrations:
|
||||
def __init__(self, library_dir: Path, engine: Engine) -> None:
|
||||
def __init__(self, library_dir: Path, sql_filename: str) -> None:
|
||||
self.library_dir = library_dir
|
||||
self.engine = engine
|
||||
self._connection = sqlite3.connect(
|
||||
str(library_dir / TS_FOLDER_NAME / sql_filename), autocommit=False
|
||||
)
|
||||
|
||||
# Don't check DB version when creating new library
|
||||
self.loaded_db_version = self._get_version(DB_VERSION_CURRENT_KEY)
|
||||
@@ -77,11 +75,21 @@ class DBMigrations:
|
||||
f"Opening Library with DB Version {self.loaded_db_version}/{DB_VERSION}"
|
||||
)
|
||||
|
||||
self._exited = False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
self._connection.close()
|
||||
self._exited = True
|
||||
|
||||
@property
|
||||
def required(self) -> bool:
|
||||
return self.loaded_db_version < DB_VERSION
|
||||
|
||||
def run(self):
|
||||
assert not self._exited
|
||||
if not self.required:
|
||||
return
|
||||
|
||||
@@ -102,70 +110,65 @@ class DBMigrations:
|
||||
MigrationTo300, # changes: deletes folders
|
||||
MigrationTo400, # changes: add category_exclusions
|
||||
]
|
||||
with Session(self.engine) as session:
|
||||
for migration in migrations:
|
||||
if self.loaded_db_version < migration.version and (
|
||||
migration.initial_version is None
|
||||
or self.initial_db_version < migration.initial_version
|
||||
):
|
||||
logger.info(f"[Library][Migration][{migration.version}] Starting DB Migration")
|
||||
# any error causes transaction to rollback
|
||||
migration.run(
|
||||
session,
|
||||
self.library_dir,
|
||||
lambda msg, v=migration.version: f"[Library][Migration][{v}] {msg}",
|
||||
for migration in migrations:
|
||||
if self.loaded_db_version < migration.version and (
|
||||
migration.initial_version is None
|
||||
or self.initial_db_version < migration.initial_version
|
||||
):
|
||||
logger.info(f"[Library][Migration][{migration.version}] Starting DB Migration")
|
||||
# any error causes transaction to rollback
|
||||
migration.run(
|
||||
self._connection,
|
||||
self.library_dir,
|
||||
lambda msg, v=migration.version: f"[Library][Migration][{v}] {msg}",
|
||||
)
|
||||
self.loaded_db_version = migration.version
|
||||
try:
|
||||
self._set_version(DB_VERSION_CURRENT_KEY, migration.version)
|
||||
logger.info(f"[Library][Migration][{migration.version}] Completed DB Migration")
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f"[Library][Migration][{migration.version}] "
|
||||
"Couldn't update version, continuing without commit",
|
||||
error=e,
|
||||
)
|
||||
self.loaded_db_version = migration.version
|
||||
try:
|
||||
self._set_version(session, DB_VERSION_CURRENT_KEY, migration.version)
|
||||
logger.info(
|
||||
f"[Library][Migration][{migration.version}] Completed DB Migration"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f"[Library][Migration][{migration.version}] "
|
||||
"Couldn't update version, continuing without commit",
|
||||
error=e,
|
||||
)
|
||||
session.flush()
|
||||
else:
|
||||
session.commit()
|
||||
else:
|
||||
self._connection.commit()
|
||||
|
||||
assert self.loaded_db_version >= DB_VERSION, (
|
||||
"Ran all migrations, but the DB is still not on the newest version"
|
||||
)
|
||||
|
||||
def _get_version(self, key: str) -> int:
|
||||
with Session(self.engine) as session:
|
||||
inspector = sqlalchemy.inspect(self.engine)
|
||||
try:
|
||||
# "Version" table added in DB_VERSION 101
|
||||
if inspector and inspector.has_table("versions"):
|
||||
version = session.scalar(select(Version).where(Version.key == key))
|
||||
assert version
|
||||
return version.value
|
||||
# "Preferences" table deprecated in TagStudio 9.5.4
|
||||
else:
|
||||
return int(
|
||||
unwrap(
|
||||
session.scalar(
|
||||
text("SELECT value FROM preferences WHERE key == 'DB_VERSION'")
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
"""Get a version value from the DB.
|
||||
|
||||
def _set_version(self, session: Session, key: str, value: int) -> None:
|
||||
Args:
|
||||
key(str): The name of the version type to retrieve.
|
||||
"""
|
||||
# "Version" table added in DB_VERSION 101
|
||||
if "versions" in list_tables(self._connection):
|
||||
query = ("SELECT value FROM versions WHERE key == ?", [key])
|
||||
# "Preferences" table deprecated in TagStudio 9.5.4
|
||||
else:
|
||||
query = ("SELECT value FROM preferences WHERE key == 'DB_VERSION'", [])
|
||||
|
||||
return int(unwrap(self._connection.execute(*query).fetchone())[0])
|
||||
|
||||
def _set_version(self, key: str, value: int) -> None:
|
||||
"""Set a version value to the DB.
|
||||
|
||||
Args:
|
||||
session(Session): The SQLAlchemy DB Session to use.
|
||||
key(str): The key for the name of the version type to set.
|
||||
key(str): The the name of the version type to set.
|
||||
value(int): The version value to set.
|
||||
"""
|
||||
# Insert if key has no value yet, otherwise update the value
|
||||
session.merge(Version(key=key, value=value))
|
||||
self._connection.execute(
|
||||
"""
|
||||
INSERT INTO versions (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
||||
""",
|
||||
[key, value],
|
||||
)
|
||||
|
||||
|
||||
class MigrationTo7(DBMigration):
|
||||
@@ -173,19 +176,18 @@ class MigrationTo7(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB from DB_VERSION 6 to 7."""
|
||||
logger.info(fmt_log("Applying patches to DB_VERSION: 6 library..."))
|
||||
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
# TODO: combine into single sql statement
|
||||
all_tag_ids = session.scalars(text("SELECT DISTINCT id FROM tags")).all()
|
||||
disam_stmt = (
|
||||
update(Tag)
|
||||
.where(Tag.disambiguation_id.not_in(all_tag_ids))
|
||||
.values(disambiguation_id=None)
|
||||
)
|
||||
session.execute(disam_stmt)
|
||||
session.flush()
|
||||
conn.execute("""
|
||||
UPDATE tags
|
||||
SET disambiguation_id = null
|
||||
WHERE NOT disambiguation_id IN (
|
||||
SELECT id
|
||||
FROM tags
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
class MigrationTo8(DBMigration):
|
||||
@@ -193,52 +195,59 @@ class MigrationTo8(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB from DB_VERSION 7 to 8."""
|
||||
# Add the missing color_border column to the TagColorGroups table.
|
||||
session.execute(
|
||||
text("ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL")
|
||||
)
|
||||
session.flush()
|
||||
conn.execute("""
|
||||
ALTER TABLE tag_colors
|
||||
ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL
|
||||
""")
|
||||
logger.info(fmt_log("Added color_border column to tag_colors table"))
|
||||
|
||||
# collect new default tag colors
|
||||
tag_colors: list[TagColorGroup] = [
|
||||
color
|
||||
for color in default_color_groups.shades()
|
||||
if color.slug in ["burgundy", "dark-teal", "dark_lavender"]
|
||||
tag_colors = [
|
||||
{
|
||||
"slug": "burgundy",
|
||||
"namespace": "tagstudio-shades",
|
||||
"name": "Burgundy",
|
||||
"primary": "#6E1C24",
|
||||
"secondary": None,
|
||||
},
|
||||
{
|
||||
"slug": "dark-teal",
|
||||
"namespace": "tagstudio-shades",
|
||||
"name": "Dark Teal",
|
||||
"primary": "#1F5E47",
|
||||
"secondary": None,
|
||||
},
|
||||
{
|
||||
"slug": "dark_lavender",
|
||||
"namespace": "tagstudio-shades",
|
||||
"name": "Dark Lavender",
|
||||
"primary": "#3D3B6C",
|
||||
"secondary": None,
|
||||
},
|
||||
]
|
||||
|
||||
# Add any new default colors introduced in DB_VERSION 8
|
||||
for color in tag_colors:
|
||||
session.add(color)
|
||||
session.flush()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO tag_colors (slug, namespace, name, \"primary\", secondary)
|
||||
VALUES (:slug, :namespace, :name, :primary, :secondary)
|
||||
""",
|
||||
tag_colors,
|
||||
)
|
||||
logger.info(
|
||||
fmt_log("Migrated tag colors to DB_VERSION 8+"),
|
||||
color_name=tag_colors,
|
||||
)
|
||||
|
||||
# Update Neon colors to use the the color_border property
|
||||
for color in default_color_groups.neon():
|
||||
neon_stmt = (
|
||||
update(TagColorGroup)
|
||||
.where(
|
||||
and_(
|
||||
TagColorGroup.namespace == color.namespace,
|
||||
TagColorGroup.slug == color.slug,
|
||||
)
|
||||
)
|
||||
.values(
|
||||
slug=color.slug,
|
||||
namespace=color.namespace,
|
||||
name=color.name,
|
||||
primary=color.primary,
|
||||
secondary=color.secondary,
|
||||
color_border=color.color_border,
|
||||
)
|
||||
)
|
||||
session.execute(neon_stmt)
|
||||
session.flush()
|
||||
conn.execute("""
|
||||
UPDATE tag_colors
|
||||
SET color_border = TRUE
|
||||
WHERE namespace = 'tagstudio-neon'
|
||||
""")
|
||||
|
||||
|
||||
class MigrationTo9(DBMigration):
|
||||
@@ -246,23 +255,18 @@ class MigrationTo9(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB from DB_VERSION 8 to 9."""
|
||||
# Apply database schema changes
|
||||
add_filename_column = text(
|
||||
"ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
session.execute(add_filename_column)
|
||||
session.flush()
|
||||
conn.execute("ALTER TABLE entries ADD COLUMN filename TEXT NOT NULL DEFAULT ''")
|
||||
logger.info(fmt_log("Added filename column to entries table"))
|
||||
|
||||
# Populate the new filename column.
|
||||
# TODO: this could still break in the future through changes to the definition of Entry
|
||||
entries = session.execute(select(Entry).distinct()).scalars()
|
||||
for entry in entries:
|
||||
entry.filename = entry.path.name
|
||||
session.merge(entry)
|
||||
session.flush()
|
||||
filenames = [
|
||||
(Path(path_str).name, id)
|
||||
for id, path_str in conn.execute("SELECT id, path FROM entries").fetchall()
|
||||
]
|
||||
conn.executemany("UPDATE entries SET filename = ? WHERE id = ?", filenames)
|
||||
logger.info(fmt_log("Populated filename column in entries table"))
|
||||
|
||||
|
||||
@@ -271,15 +275,10 @@ class MigrationTo100(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB to DB_VERSION 100."""
|
||||
# Repair parent-child tag relationships that are the wrong way around.
|
||||
stmt = update(TagParent).values(
|
||||
parent_id=TagParent.child_id,
|
||||
child_id=TagParent.parent_id,
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.flush()
|
||||
conn.execute("UPDATE tag_parents SET parent_id = child_id, child_id = parent_id")
|
||||
logger.info(fmt_log("Refactored TagParent table"))
|
||||
|
||||
|
||||
@@ -288,21 +287,19 @@ class MigrationTo101(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB to DB_VERSION 101."""
|
||||
# Create versions table
|
||||
session.execute(
|
||||
text("""
|
||||
CREATE TABLE versions (
|
||||
"key" VARCHAR NOT NULL PRIMARY KEY,
|
||||
value INTEGER NOT NULL
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE versions (
|
||||
"key" VARCHAR NOT NULL PRIMARY KEY,
|
||||
value INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
# Ensure version rows are present
|
||||
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
|
||||
session.flush()
|
||||
conn.execute(
|
||||
'INSERT INTO versions ("key", value) VALUES (?, ?)', [DB_VERSION_INITIAL_KEY, 100]
|
||||
)
|
||||
logger.info(fmt_log("Created versions table"))
|
||||
|
||||
|
||||
@@ -311,12 +308,16 @@ class MigrationTo102(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB to DB_VERSION 102."""
|
||||
# delete TagParents with a dangling parent reference
|
||||
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.flush()
|
||||
conn.execute("""
|
||||
DELETE FROM tag_parents
|
||||
WHERE NOT parent_id IN (
|
||||
SELECT id
|
||||
FROM tags
|
||||
)
|
||||
""")
|
||||
logger.info(fmt_log("Verified TagParent table data"))
|
||||
|
||||
|
||||
@@ -325,16 +326,14 @@ class MigrationTo103(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB from DB_VERSION 102 to 103."""
|
||||
# add the new hidden column for tags
|
||||
session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"))
|
||||
session.flush()
|
||||
conn.execute("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0")
|
||||
logger.info(fmt_log("Added is_hidden column to tags table"))
|
||||
|
||||
# mark the "Archived" tag as hidden
|
||||
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
|
||||
session.flush()
|
||||
conn.execute("UPDATE tags SET is_hidden = true WHERE id = ?", [TAG_ARCHIVED])
|
||||
logger.info(fmt_log("Updated archived tag to be hidden"))
|
||||
|
||||
|
||||
@@ -343,15 +342,14 @@ class MigrationTo104(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB from DB_VERSION 103 to 104."""
|
||||
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
|
||||
cls.__migrate_sql_to_ts_ignore(session, library_dir)
|
||||
session.execute(text("DROP TABLE preferences"))
|
||||
session.flush()
|
||||
cls.__migrate_sql_to_ts_ignore(conn, library_dir)
|
||||
conn.execute("DROP TABLE preferences")
|
||||
|
||||
@classmethod
|
||||
def __migrate_sql_to_ts_ignore(cls, session: Session, library_dir: Path):
|
||||
def __migrate_sql_to_ts_ignore(cls, conn: Connection, library_dir: Path):
|
||||
# Do not continue if existing '.ts_ignore' file is found
|
||||
ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME
|
||||
if Path(ts_ignore).exists():
|
||||
@@ -360,12 +358,18 @@ class MigrationTo104(DBMigration):
|
||||
# Load legacy extension data
|
||||
extensions: list[str] = ujson.loads(
|
||||
unwrap(
|
||||
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
|
||||
)
|
||||
conn.execute(
|
||||
"SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"
|
||||
).fetchone()
|
||||
)[0]
|
||||
)
|
||||
is_exclude_list: bool = unwrap(
|
||||
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
|
||||
)
|
||||
conn.execute("""
|
||||
SELECT value
|
||||
FROM preferences
|
||||
WHERE key = 'IS_EXCLUDE_LIST'
|
||||
""").fetchone()
|
||||
)[0]
|
||||
|
||||
with open(ts_ignore, "w") as f:
|
||||
f.write(migrate_ext_list(extensions, is_exclude_list))
|
||||
@@ -376,111 +380,111 @@ class MigrationTo200(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB to DB_VERSION 200."""
|
||||
# Drop unused 'boolean_fields' and 'value_type' tables
|
||||
logger.info(fmt_log("Dropping boolean_fields and value_type tables..."))
|
||||
session.execute(text("DROP TABLE boolean_fields"))
|
||||
session.execute(text("DROP TABLE value_type"))
|
||||
conn.execute("DROP TABLE boolean_fields")
|
||||
conn.execute("DROP TABLE value_type")
|
||||
|
||||
# Add 'name' column to text_fields and datetime_fields tables
|
||||
logger.info(fmt_log("Adding name columns to field tables..."))
|
||||
stmt = text('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
|
||||
session.execute(stmt)
|
||||
stmt = text('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
|
||||
session.execute(stmt)
|
||||
conn.execute('ALTER TABLE text_fields ADD COLUMN name VARCHAR DEFAULT ""')
|
||||
conn.execute('ALTER TABLE datetime_fields ADD COLUMN name VARCHAR DEFAULT ""')
|
||||
|
||||
# Drop unnecessary 'position' columns
|
||||
logger.info(fmt_log("Dropping position columns to field tables..."))
|
||||
session.execute(text("ALTER TABLE datetime_fields DROP COLUMN position"))
|
||||
session.execute(text("ALTER TABLE text_fields DROP COLUMN position"))
|
||||
conn.execute("ALTER TABLE datetime_fields DROP COLUMN position")
|
||||
conn.execute("ALTER TABLE text_fields DROP COLUMN position")
|
||||
|
||||
# Add 'is_multiline' column to text_fields table
|
||||
logger.info(fmt_log("Adding is_multiline column to text_fields..."))
|
||||
stmt = text("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
|
||||
session.execute(stmt)
|
||||
session.flush()
|
||||
conn.execute("ALTER TABLE text_fields ADD COLUMN is_multiline BOOLEAN NOT NULL DEFAULT 0")
|
||||
|
||||
# Move values from old `type_key` columns into new `name` columns
|
||||
logger.info(fmt_log("Moving values from type_key columns to name..."))
|
||||
session.execute(text("UPDATE text_fields SET name = type_key"))
|
||||
session.execute(text("UPDATE datetime_fields SET name = type_key"))
|
||||
session.flush()
|
||||
conn.execute("UPDATE text_fields SET name = type_key")
|
||||
conn.execute("UPDATE datetime_fields SET name = type_key")
|
||||
|
||||
# Change `name` values to title case
|
||||
logger.info(fmt_log("Normalizing TextField names..."))
|
||||
for text_field in session.execute(select(TextField)).scalars():
|
||||
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
|
||||
text_field.name = text_field.name.title().replace("Url", "URL").replace("_", " ")
|
||||
# NOTE: The only exception to the "Title Case" conversion is the "URL" field.
|
||||
names = [
|
||||
(name.title().replace("Url", "URL").replace("_", " "), id)
|
||||
for id, name in conn.execute("SELECT id, name FROM text_fields").fetchall()
|
||||
]
|
||||
conn.executemany("UPDATE text_fields SET name = ? WHERE id = ?", names)
|
||||
|
||||
logger.info(fmt_log("Normalizing DatetimeField names..."))
|
||||
for datetime_field in session.execute(select(DatetimeField)).scalars():
|
||||
datetime_field.name = datetime_field.name.title().replace("_", " ")
|
||||
session.flush()
|
||||
names = [
|
||||
(name.title().replace("_", " "), id)
|
||||
for id, name in conn.execute("SELECT id, name FROM datetime_fields").fetchall()
|
||||
]
|
||||
conn.executemany("UPDATE datetime_fields SET name = ? WHERE id = ?", names)
|
||||
|
||||
# Add correct `is_multiline` values to text_fields table
|
||||
logger.info(fmt_log("Updating is_multiline for legacy TEXT_BOXes..."))
|
||||
text_boxes = [
|
||||
x.get("name") for x in LEGACY_FIELD_MAP.values() if x.get("is_multiline") is True
|
||||
]
|
||||
update_stmt = (
|
||||
update(TextField).where(TextField.name.in_(text_boxes)).values(is_multiline=True)
|
||||
conn.execute(
|
||||
f"""
|
||||
UPDATE text_fields
|
||||
SET is_multiline = true
|
||||
WHERE name IN ({",".join(["?"] * len(text_boxes))})
|
||||
""",
|
||||
text_boxes,
|
||||
)
|
||||
session.execute(update_stmt)
|
||||
session.flush()
|
||||
|
||||
# Repair legacy "Description" fields to use is_multiline = True
|
||||
logger.info(fmt_log("Repairing legacy Description fields..."))
|
||||
desc_stmt = (
|
||||
update(TextField)
|
||||
.where(TextField.name == "Description" and TextField.is_multiline == False) # noqa: E712
|
||||
.values(is_multiline=True)
|
||||
)
|
||||
session.execute(desc_stmt)
|
||||
|
||||
# Repair legacy "Comments" fields to use is_multiline = True
|
||||
logger.info(fmt_log("Repairing legacy Comment fields..."))
|
||||
comm_stmt = (
|
||||
update(TextField)
|
||||
.where(TextField.name == "Comments" and TextField.is_multiline == False) # noqa: E712
|
||||
.values(is_multiline=True)
|
||||
)
|
||||
session.execute(comm_stmt)
|
||||
# Repair legacy "Description" and "Comments" fields to use is_multiline = True
|
||||
logger.info(fmt_log("Repairing legacy Description and Comments fields..."))
|
||||
conn.execute("""
|
||||
UPDATE text_fields
|
||||
SET is_multiline = true
|
||||
WHERE name in ('Description', 'Comments') AND is_multiline = false
|
||||
""")
|
||||
|
||||
# Add field templates tables
|
||||
session.execute(
|
||||
text("""
|
||||
CREATE TABLE text_field_templates (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
is_multiline BOOLEAN NOT NULL,
|
||||
name VARCHAR NOT NULL
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE text_field_templates (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
is_multiline BOOLEAN NOT NULL,
|
||||
name VARCHAR NOT NULL
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.execute(
|
||||
text("""
|
||||
CREATE TABLE datetime_field_templates (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name VARCHAR NOT NULL
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE datetime_field_templates (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name VARCHAR NOT NULL
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
|
||||
# Add default field templates
|
||||
logger.info(fmt_log("Adding default field templates..."))
|
||||
for template in DEFAULT_FIELD_TEMPLATES:
|
||||
session.add(template)
|
||||
session.flush()
|
||||
conn.executemany(
|
||||
"INSERT INTO text_field_templates (name, is_multiline) VALUES (:name, :is_multiline)",
|
||||
[
|
||||
{"name": "Title", "is_multiline": False},
|
||||
{"name": "Author", "is_multiline": False},
|
||||
{"name": "Artist", "is_multiline": False},
|
||||
{"name": "URL", "is_multiline": False},
|
||||
{"name": "Description", "is_multiline": True},
|
||||
{"name": "Notes", "is_multiline": True},
|
||||
{"name": "Comments", "is_multiline": True},
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO datetime_field_templates (name) VALUES (:name)",
|
||||
{"name": "Date"},
|
||||
)
|
||||
|
||||
# DB indices for improved performance
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)"
|
||||
)
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tag_parents_child_id ON tag_parents (child_id)")
|
||||
)
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tag_entries_entry_id ON tag_entries (entry_id)"
|
||||
)
|
||||
|
||||
|
||||
@@ -490,55 +494,44 @@ class MigrationTo201(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB to DB_VERSION 201."""
|
||||
create_text_fields_table = text("""
|
||||
CREATE TABLE text_fields_new (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
value VARCHAR,
|
||||
is_multiline BOOLEAN NOT NULL,
|
||||
FOREIGN KEY(entry_id) REFERENCES entries (id)
|
||||
)
|
||||
""")
|
||||
create_datetime_fields_table = text("""
|
||||
CREATE TABLE datetime_fields_new (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
value VARCHAR,
|
||||
FOREIGN KEY(entry_id) REFERENCES entries (id)
|
||||
)
|
||||
""")
|
||||
|
||||
logger.info(fmt_log("Dropping type_key from text_fields table..."))
|
||||
session.execute(create_text_fields_table)
|
||||
session.flush()
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
|
||||
SELECT id, name, entry_id, value, is_multiline
|
||||
FROM text_fields
|
||||
""")
|
||||
)
|
||||
session.execute(text("DROP TABLE text_fields"))
|
||||
session.execute(text("ALTER TABLE text_fields_new RENAME TO text_fields"))
|
||||
conn.execute("""
|
||||
CREATE TABLE text_fields_new (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
value VARCHAR,
|
||||
is_multiline BOOLEAN NOT NULL,
|
||||
FOREIGN KEY(entry_id) REFERENCES entries (id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO text_fields_new (id, name, entry_id, value, is_multiline)
|
||||
SELECT id, name, entry_id, value, is_multiline
|
||||
FROM text_fields
|
||||
""")
|
||||
conn.execute("DROP TABLE text_fields")
|
||||
conn.execute("ALTER TABLE text_fields_new RENAME TO text_fields")
|
||||
|
||||
logger.info(fmt_log("Dropping type_key from datetime_fields table..."))
|
||||
session.execute(create_datetime_fields_table)
|
||||
session.flush()
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO datetime_fields_new (id, name, entry_id, value)
|
||||
SELECT id, name, entry_id, value
|
||||
FROM datetime_fields
|
||||
""")
|
||||
)
|
||||
session.execute(text("DROP TABLE datetime_fields"))
|
||||
session.execute(text("ALTER TABLE datetime_fields_new RENAME TO datetime_fields"))
|
||||
|
||||
session.flush()
|
||||
conn.execute("""
|
||||
CREATE TABLE datetime_fields_new (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
value VARCHAR,
|
||||
FOREIGN KEY(entry_id) REFERENCES entries (id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO datetime_fields_new (id, name, entry_id, value)
|
||||
SELECT id, name, entry_id, value
|
||||
FROM datetime_fields
|
||||
""")
|
||||
conn.execute("DROP TABLE datetime_fields")
|
||||
conn.execute("ALTER TABLE datetime_fields_new RENAME TO datetime_fields")
|
||||
|
||||
|
||||
class MigrationTo202(DBMigration):
|
||||
@@ -546,11 +539,15 @@ class MigrationTo202(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
"""Migrate DB to DB_VERSION 202."""
|
||||
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.flush()
|
||||
conn.execute("""
|
||||
DELETE FROM tag_parents
|
||||
WHERE NOT child_id IN (
|
||||
SELECT id
|
||||
FROM tags
|
||||
)
|
||||
""")
|
||||
logger.info(fmt_log("Verified TagParent table data"))
|
||||
|
||||
|
||||
@@ -559,43 +556,39 @@ class MigrationTo300(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod):
|
||||
## 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)
|
||||
)
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log: LoggingMethod):
|
||||
# remove folder_id column from entries table
|
||||
## create new table in the desired scheme (without folder_id column)
|
||||
conn.execute("""
|
||||
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("""
|
||||
|
||||
## transfer data to new table
|
||||
conn.execute("""
|
||||
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()
|
||||
## delete old table
|
||||
conn.execute("DROP TABLE entries")
|
||||
|
||||
## rename new table to old table
|
||||
conn.execute("ALTER TABLE entries_new RENAME TO entries")
|
||||
|
||||
# drop table "folders"
|
||||
conn.execute("DROP TABLE folders")
|
||||
|
||||
|
||||
class MigrationTo400(DBMigration):
|
||||
@@ -603,16 +596,13 @@ class MigrationTo400(DBMigration):
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log):
|
||||
def run(cls, conn: Connection, library_dir: Path, fmt_log):
|
||||
logger.info(fmt_log("Creating category_exclusions table..."))
|
||||
session.execute(
|
||||
text("""
|
||||
CREATE TABLE category_exclusions (
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
category_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
conn.execute("""
|
||||
CREATE TABLE category_exclusions (
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
category_id INTEGER NOT NULL REFERENCES tags(id),
|
||||
|
||||
PRIMARY KEY (tag_id, category_id)
|
||||
)
|
||||
PRIMARY KEY (tag_id, category_id)
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from sqlite3 import Connection
|
||||
|
||||
|
||||
def list_tables(con: Connection) -> list[str]:
|
||||
return [
|
||||
row[0]
|
||||
for row in con.execute("SELECT name FROM sqlite_master WHERE type == 'table'").fetchall()
|
||||
]
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.sql.operators import ilike_op
|
||||
from tagstudio.core.library.alchemy.constants import TAG_CHILDREN_ID_QUERY
|
||||
from tagstudio.core.library.alchemy.joins import TagEntry
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag, TagAlias
|
||||
from tagstudio.core.media_types import FILETYPE_EQUIVALENTS, MediaCategories
|
||||
from tagstudio.core.media_types import MediaTypeGroup, MediaTypes
|
||||
from tagstudio.core.query_lang.ast import (
|
||||
AST,
|
||||
ANDList,
|
||||
@@ -24,6 +24,7 @@ from tagstudio.core.query_lang.ast import (
|
||||
ORList,
|
||||
Property,
|
||||
)
|
||||
from tagstudio.core.query_lang.file_groups import SEARCH
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -34,13 +35,6 @@ else:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def get_filetype_equivalency_list(item: str) -> list[str] | set[str]:
|
||||
for s in FILETYPE_EQUIVALENTS:
|
||||
if item in s:
|
||||
return s
|
||||
return [item]
|
||||
|
||||
|
||||
class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
|
||||
def __init__(self, lib: Library) -> None:
|
||||
super().__init__()
|
||||
@@ -95,15 +89,27 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
|
||||
)
|
||||
return Entry.path.regexp_match(re.escape(node.value))
|
||||
elif node.type == ConstraintType.MediaType:
|
||||
extensions: set[str] = set[str]()
|
||||
for media_cat in MediaCategories.ALL_CATEGORIES:
|
||||
if node.value == media_cat.name:
|
||||
extensions = extensions | media_cat.extensions
|
||||
break
|
||||
key = (
|
||||
MediaTypes.get_group_key_from_name(
|
||||
node.value, case_sensitive=False, ignore_whitespace=True
|
||||
)
|
||||
or node.value
|
||||
)
|
||||
|
||||
media_type: MediaTypeGroup | None = getattr(MediaTypes, key, None)
|
||||
extensions: set[str] = (
|
||||
media_type.context_sets.get(SEARCH, set()) if media_type else set()
|
||||
)
|
||||
return Entry.suffix.in_(map(lambda x: x.replace(".", ""), extensions))
|
||||
|
||||
elif node.type == ConstraintType.FileType:
|
||||
# NOTE: Entries store their suffix without a leading dot, the MediaTypes system includes
|
||||
# the leading dot (if any), and this search system should take in either variant.
|
||||
return or_(
|
||||
*[Entry.suffix.ilike(ft) for ft in get_filetype_equivalency_list(node.value)]
|
||||
*[
|
||||
Entry.suffix.ilike(ft.removeprefix("."))
|
||||
for ft in MediaTypes.get_equivalent_exts(f".{node.value.removeprefix('.')}")
|
||||
]
|
||||
)
|
||||
elif node.type == ConstraintType.Special: # noqa: SIM102 unnecessary once there is a second special constraint
|
||||
if node.value.lower() == "untagged":
|
||||
|
||||
@@ -105,7 +105,14 @@ class RefreshTracker:
|
||||
shell=True,
|
||||
encoding="UTF-8",
|
||||
)
|
||||
compiled_ignore_path.unlink()
|
||||
try:
|
||||
compiled_ignore_path.unlink()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Refresh] Could not remove compiled ignore path",
|
||||
path=compiled_ignore_path,
|
||||
error=e,
|
||||
)
|
||||
|
||||
if result.stderr:
|
||||
logger.error(result.stderr)
|
||||
|
||||
+278
-687
@@ -1,714 +1,305 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import enum
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.utils.sanitized_attr import SanitizedAttr
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
FILETYPE_EQUIVALENTS = [
|
||||
{"aif", "aiff", "aifc"},
|
||||
{"html", "htm", "xhtml", "shtml", "dhtml"},
|
||||
{"jfif", "jpeg_large", "jpeg", "jpg_large", "jpg"},
|
||||
{"json", "jsonc", "json5"},
|
||||
{"md", "markdown", "mkd", "rmd"},
|
||||
{"tar.gz", "tgz"},
|
||||
{"xml", "xul"},
|
||||
{"yaml", "yml"},
|
||||
]
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Return a sanitized string with no whitespace or hyphens."""
|
||||
# Replace non-word characters with underscores, strip whitespace and make lowercase
|
||||
text = re.sub(r"\W", "_", text.strip().lower())
|
||||
# Replace remaining spaces and hyphens with underscores
|
||||
text = re.sub(r"[\s-]+", "_", text)
|
||||
return text
|
||||
|
||||
|
||||
class MediaType(enum.StrEnum):
|
||||
"""Names of media types."""
|
||||
|
||||
ADOBE_PHOTOSHOP = "adobe_photoshop"
|
||||
AFFINITY_PHOTO = "affinity_photo"
|
||||
ARCHIVE = "archive"
|
||||
AUDIO_MIDI = "audio_midi"
|
||||
AUDIO = "audio"
|
||||
BLENDER = "blender"
|
||||
CLIP_STUDIO_PAINT = "clip_studio_paint"
|
||||
CODE = "code"
|
||||
DATABASE = "database"
|
||||
DISK_IMAGE = "disk_image"
|
||||
DOCUMENT = "document"
|
||||
EBOOK = "ebook"
|
||||
FONT = "font"
|
||||
IMAGE_ANIMATED = "image_animated"
|
||||
IMAGE_RAW = "image_raw"
|
||||
IMAGE_VECTOR = "image_vector"
|
||||
IMAGE = "image"
|
||||
INSTALLER = "installer"
|
||||
IWORK = "iwork"
|
||||
MATERIAL = "material"
|
||||
MDIPACK = "mdipack"
|
||||
MODEL = "model"
|
||||
OPEN_DOCUMENT = "open_document"
|
||||
PACKAGE = "package"
|
||||
PAINT_DOT_NET = "paint_dot_net"
|
||||
PDF = "pdf"
|
||||
PLAINTEXT = "plaintext"
|
||||
PRESENTATION = "presentation"
|
||||
PROGRAM = "program"
|
||||
SHADER = "shader"
|
||||
SHORTCUT = "shortcut"
|
||||
SOURCE_ENGINE = "source_engine"
|
||||
SPREADSHEET = "spreadsheet"
|
||||
TEXT = "text"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaCategory:
|
||||
"""An object representing a category of media.
|
||||
|
||||
Includes a MediaType identifier, extensions set, and IANA status flag.
|
||||
class FileType:
|
||||
"""A description of a single file type, along with an associated context.
|
||||
|
||||
Args:
|
||||
media_type (MediaType): The MediaType Enum representing this category.
|
||||
|
||||
extensions (set[str]): The set of file extensions associated with this category.
|
||||
Includes leading ".", all lowercase, and does not need to be unique to this category.
|
||||
|
||||
is_iana (bool): Represents whether this is an IANA registered category.
|
||||
exts (str | list[str]): The file extention(s), including a leading dot if there is one.
|
||||
Passing a list of extensions will treat them as equivalent/interchangeable.
|
||||
E.g. [".jpg", ".jpeg", ".jfif"] would be treated as the same extention.
|
||||
"""
|
||||
|
||||
media_type: MediaType
|
||||
extensions: set[str]
|
||||
name: str
|
||||
is_iana: bool = False
|
||||
def __init__(self, exts: str | list[str], contexts: str | list[str]) -> None:
|
||||
self.exts: set[str]
|
||||
self.contexts: set[str]
|
||||
|
||||
def contains(self, ext: str, mime_fallback: bool = False) -> bool:
|
||||
"""Check if an extension is a member of this MediaCategory.
|
||||
if isinstance(exts, str):
|
||||
self.exts = set([exts])
|
||||
else:
|
||||
self.exts = set(exts)
|
||||
|
||||
if isinstance(contexts, str):
|
||||
self.contexts = set([contexts])
|
||||
else:
|
||||
self.contexts = set(contexts)
|
||||
|
||||
|
||||
class MediaTypeGroup:
|
||||
"""A named group of FileTypes and context associations that represents a media group.
|
||||
|
||||
For example, "Image" files may be represented by a MediaTypeGroup, consisting of FileType
|
||||
objects that are associated with individual extensions such as ".jpg" and ".png".
|
||||
"""
|
||||
|
||||
def __init__(self, key: str, types: list[FileType]) -> None:
|
||||
"""Initialize the MediaTypeGroup.
|
||||
|
||||
Args:
|
||||
ext (str): File extension with a leading "." and in all lowercase.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
key (str): A key for the name of this group.
|
||||
Dots are used to separate group levels (e.g. "microsoft.office.word").
|
||||
This key is slugified before being used as an attribute.
|
||||
types (list[FileType]): A list of FileType objects to include in the group.
|
||||
"""
|
||||
if ext in self.extensions:
|
||||
return True
|
||||
elif mime_fallback and self.is_iana:
|
||||
mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if mime_type is not None and mime_type.startswith(self.media_type.value):
|
||||
return True
|
||||
return False
|
||||
self.context_sets: dict[str, set[str]] = {}
|
||||
self.name_aliases: list[str] = []
|
||||
self.key = key
|
||||
self.types: list[FileType] = []
|
||||
self.add_types(types)
|
||||
|
||||
|
||||
class MediaCategories:
|
||||
"""Contain pre-made MediaCategory objects as well as methods to interact with them."""
|
||||
|
||||
# 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",
|
||||
".psd",
|
||||
}
|
||||
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
|
||||
_KRITA_SET: set[str] = {".kra", ".krz"}
|
||||
_ARCHIVE_SET: set[str] = {
|
||||
".7z",
|
||||
".gz",
|
||||
".rar",
|
||||
".s7z",
|
||||
".tar",
|
||||
".tgz",
|
||||
".zip",
|
||||
}
|
||||
_AUDIO_MIDI_SET: set[str] = {
|
||||
".mid",
|
||||
".midi",
|
||||
}
|
||||
_AUDIO_SET: set[str] = {
|
||||
".aac",
|
||||
".aif",
|
||||
".aifc",
|
||||
".aiff",
|
||||
".alac",
|
||||
".caf",
|
||||
".flac",
|
||||
".m4a",
|
||||
".m4p",
|
||||
".mp3",
|
||||
".mpeg4",
|
||||
".ogg",
|
||||
".wav",
|
||||
".wma",
|
||||
}
|
||||
_BLENDER_SET: set[str] = {
|
||||
".blen_tc",
|
||||
".blend",
|
||||
".blend1",
|
||||
".blend2",
|
||||
".blend3",
|
||||
".blend4",
|
||||
".blend5",
|
||||
".blend6",
|
||||
".blend7",
|
||||
".blend8",
|
||||
".blend9",
|
||||
".blend10",
|
||||
".blend11",
|
||||
".blend12",
|
||||
".blend13",
|
||||
".blend14",
|
||||
".blend15",
|
||||
".blend16",
|
||||
".blend17",
|
||||
".blend18",
|
||||
".blend19",
|
||||
".blend20",
|
||||
".blend21",
|
||||
".blend22",
|
||||
".blend23",
|
||||
".blend24",
|
||||
".blend25",
|
||||
".blend26",
|
||||
".blend27",
|
||||
".blend28",
|
||||
".blend29",
|
||||
".blend30",
|
||||
".blend31",
|
||||
".blend32",
|
||||
}
|
||||
_CLIP_STUDIO_PAINT_SET: set[str] = {".clip"}
|
||||
_CODE_SET: set[str] = {
|
||||
".bat",
|
||||
".cfg",
|
||||
".conf",
|
||||
".cpp",
|
||||
".cs",
|
||||
".csh",
|
||||
".css",
|
||||
".d",
|
||||
".dhtml",
|
||||
".fgd",
|
||||
".fish",
|
||||
".gitignore",
|
||||
".h",
|
||||
".hpp",
|
||||
".htm",
|
||||
".html",
|
||||
".inf",
|
||||
".ini",
|
||||
".js",
|
||||
".json",
|
||||
".json5",
|
||||
".jsonc",
|
||||
".jsx",
|
||||
".kv3",
|
||||
".lua",
|
||||
".meta",
|
||||
".nix",
|
||||
".nu",
|
||||
".nut",
|
||||
".php",
|
||||
".plist",
|
||||
".prefs",
|
||||
".ps1",
|
||||
".py",
|
||||
".pyi",
|
||||
".qml",
|
||||
".qrc",
|
||||
".qss",
|
||||
".rs",
|
||||
".sh",
|
||||
".shtml",
|
||||
".sip",
|
||||
".spec",
|
||||
".tcl",
|
||||
".timestamp",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".vcfg",
|
||||
".vdf",
|
||||
".vmt",
|
||||
".vqlayout",
|
||||
".vsc",
|
||||
".vsnd_template",
|
||||
".xhtml",
|
||||
".xml",
|
||||
".xul",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
_DATABASE_SET: set[str] = {
|
||||
".accdb",
|
||||
".mdb",
|
||||
".pdb",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
}
|
||||
_DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".fhdx", ".iso"}
|
||||
_DOCUMENT_SET: set[str] = {
|
||||
".doc",
|
||||
".docm",
|
||||
".docx",
|
||||
".dot",
|
||||
".dotm",
|
||||
".dotx",
|
||||
".odt",
|
||||
".pages",
|
||||
".pdf",
|
||||
".pxd",
|
||||
".rtf",
|
||||
".tex",
|
||||
".wpd",
|
||||
".wps",
|
||||
}
|
||||
_EBOOK_SET: set[str] = {
|
||||
".azw",
|
||||
".azw3",
|
||||
".cb7",
|
||||
".cba",
|
||||
".cbr",
|
||||
".cbt",
|
||||
".cbz",
|
||||
".djvu",
|
||||
".epub",
|
||||
".fb2",
|
||||
".ibook",
|
||||
".inf",
|
||||
".kfx",
|
||||
".lit",
|
||||
".mobi",
|
||||
".pdb",
|
||||
".prc",
|
||||
}
|
||||
_FONT_SET: set[str] = {
|
||||
".fon",
|
||||
".otf",
|
||||
".ttc",
|
||||
".ttf",
|
||||
".woff",
|
||||
".woff2",
|
||||
}
|
||||
_IMAGE_ANIMATED_SET: set[str] = {
|
||||
".apng",
|
||||
".gif",
|
||||
".webp",
|
||||
}
|
||||
_IMAGE_RAW_SET: set[str] = {
|
||||
".arw",
|
||||
".cr2",
|
||||
".cr3",
|
||||
".crw",
|
||||
".dng",
|
||||
".nef",
|
||||
".nrw",
|
||||
".orf",
|
||||
".r3d",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
".srf",
|
||||
".srf2",
|
||||
}
|
||||
_IMAGE_VECTOR_SET: set[str] = {".eps", ".epsf", ".epsi", ".svg", ".svgz"}
|
||||
_IMAGE_RASTER_SET: set[str] = {
|
||||
".apng",
|
||||
".avif",
|
||||
".bmp",
|
||||
".exr",
|
||||
".gif",
|
||||
".heic",
|
||||
".heif",
|
||||
".icns",
|
||||
".j2k",
|
||||
".jfif",
|
||||
".jp2",
|
||||
".jpeg_large",
|
||||
".jpeg",
|
||||
".jpg_large",
|
||||
".jpg",
|
||||
".jpg2",
|
||||
".jxl",
|
||||
".png",
|
||||
".psb",
|
||||
".psd",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
}
|
||||
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
|
||||
_IWORK_SET: set[str] = {".key", ".numbers", ".pages"}
|
||||
_MATERIAL_SET: set[str] = {".mtl"}
|
||||
_MDIPACK_SET: set[str] = {".mdp"}
|
||||
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
|
||||
_OPEN_DOCUMENT_SET: set[str] = {
|
||||
".fodg",
|
||||
".fodp",
|
||||
".fods",
|
||||
".fodt",
|
||||
".mscz",
|
||||
".odf",
|
||||
".odg",
|
||||
".odp",
|
||||
".ods",
|
||||
".odt",
|
||||
".ora",
|
||||
}
|
||||
_PACKAGE_SET: set[str] = {
|
||||
".aab",
|
||||
".akp",
|
||||
".apk",
|
||||
".apkm",
|
||||
".apks",
|
||||
".pkg",
|
||||
".xapk",
|
||||
}
|
||||
_PAINT_DOT_NET_SET: set[str] = {".pdn"}
|
||||
_PDF_SET: set[str] = {".pdf"}
|
||||
_PLAINTEXT_SET: set[str] = {
|
||||
".csv",
|
||||
".i3u",
|
||||
".lang",
|
||||
".lock",
|
||||
".log",
|
||||
".markdown",
|
||||
".md",
|
||||
".mkd",
|
||||
".rmd",
|
||||
".text",
|
||||
".txt",
|
||||
"contributing",
|
||||
"license",
|
||||
"readme",
|
||||
}
|
||||
_PRESENTATION_SET: set[str] = {
|
||||
".key",
|
||||
".odp",
|
||||
".ppt",
|
||||
".pptx",
|
||||
}
|
||||
_PROGRAM_SET: set[str] = {".app", ".bin", ".exe"}
|
||||
_SOURCE_ENGINE_SET: set[str] = {".vtf"}
|
||||
_SHADER_SET: set[str] = {
|
||||
".effect",
|
||||
".frag",
|
||||
".fsh",
|
||||
".glsl",
|
||||
".shader",
|
||||
".vert",
|
||||
".vsh",
|
||||
}
|
||||
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
|
||||
_SPREADSHEET_SET: set[str] = {
|
||||
".csv",
|
||||
".numbers",
|
||||
".ods",
|
||||
".xls",
|
||||
".xlsx",
|
||||
}
|
||||
_VIDEO_SET: set[str] = {
|
||||
".3gp",
|
||||
".avi",
|
||||
".flv",
|
||||
".gifv",
|
||||
".hevc",
|
||||
".m4p",
|
||||
".m4v",
|
||||
".mkv",
|
||||
".mov",
|
||||
".mp4",
|
||||
".webm",
|
||||
".wmv",
|
||||
".ts",
|
||||
}
|
||||
|
||||
ADOBE_PHOTOSHOP_TYPES = MediaCategory(
|
||||
media_type=MediaType.ADOBE_PHOTOSHOP,
|
||||
extensions=_ADOBE_PHOTOSHOP_SET,
|
||||
is_iana=False,
|
||||
name="photoshop",
|
||||
)
|
||||
AFFINITY_PHOTO_TYPES = MediaCategory(
|
||||
media_type=MediaType.AFFINITY_PHOTO,
|
||||
extensions=_AFFINITY_PHOTO_SET,
|
||||
is_iana=False,
|
||||
name="affinity photo",
|
||||
)
|
||||
ARCHIVE_TYPES = MediaCategory(
|
||||
media_type=MediaType.ARCHIVE,
|
||||
extensions=_ARCHIVE_SET,
|
||||
is_iana=False,
|
||||
name="archive",
|
||||
)
|
||||
AUDIO_MIDI_TYPES = MediaCategory(
|
||||
media_type=MediaType.AUDIO_MIDI,
|
||||
extensions=_AUDIO_MIDI_SET,
|
||||
is_iana=False,
|
||||
name="audio midi",
|
||||
)
|
||||
AUDIO_TYPES = MediaCategory(
|
||||
media_type=MediaType.AUDIO,
|
||||
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
|
||||
is_iana=True,
|
||||
name="audio",
|
||||
)
|
||||
BLENDER_TYPES = MediaCategory(
|
||||
media_type=MediaType.BLENDER,
|
||||
extensions=_BLENDER_SET,
|
||||
is_iana=False,
|
||||
name="blender",
|
||||
)
|
||||
CLIP_STUDIO_PAINT_TYPES = MediaCategory(
|
||||
media_type=MediaType.CLIP_STUDIO_PAINT,
|
||||
extensions=_CLIP_STUDIO_PAINT_SET,
|
||||
is_iana=False,
|
||||
name="clip studio paint",
|
||||
)
|
||||
CODE_TYPES = MediaCategory(
|
||||
media_type=MediaType.CODE,
|
||||
extensions=_CODE_SET,
|
||||
is_iana=False,
|
||||
name="code",
|
||||
)
|
||||
DATABASE_TYPES = MediaCategory(
|
||||
media_type=MediaType.DATABASE,
|
||||
extensions=_DATABASE_SET,
|
||||
is_iana=False,
|
||||
name="database",
|
||||
)
|
||||
DISK_IMAGE_TYPES = MediaCategory(
|
||||
media_type=MediaType.DISK_IMAGE,
|
||||
extensions=_DISK_IMAGE_SET,
|
||||
is_iana=False,
|
||||
name="disk image",
|
||||
)
|
||||
DOCUMENT_TYPES = MediaCategory(
|
||||
media_type=MediaType.DOCUMENT,
|
||||
extensions=_DOCUMENT_SET,
|
||||
is_iana=False,
|
||||
name="document",
|
||||
)
|
||||
EBOOK_TYPES = MediaCategory(
|
||||
media_type=MediaType.EBOOK,
|
||||
extensions=_EBOOK_SET,
|
||||
is_iana=False,
|
||||
name="ebook",
|
||||
)
|
||||
FONT_TYPES = MediaCategory(
|
||||
media_type=MediaType.FONT,
|
||||
extensions=_FONT_SET,
|
||||
is_iana=True,
|
||||
name="font",
|
||||
)
|
||||
IMAGE_ANIMATED_TYPES = MediaCategory(
|
||||
media_type=MediaType.IMAGE_ANIMATED,
|
||||
extensions=_IMAGE_ANIMATED_SET,
|
||||
is_iana=False,
|
||||
name="animated image",
|
||||
)
|
||||
IMAGE_RAW_TYPES = MediaCategory(
|
||||
media_type=MediaType.IMAGE_RAW,
|
||||
extensions=_IMAGE_RAW_SET,
|
||||
is_iana=False,
|
||||
name="raw image",
|
||||
)
|
||||
IMAGE_VECTOR_TYPES = MediaCategory(
|
||||
media_type=MediaType.IMAGE_VECTOR,
|
||||
extensions=_IMAGE_VECTOR_SET,
|
||||
is_iana=False,
|
||||
name="vector image",
|
||||
)
|
||||
IMAGE_RASTER_TYPES = MediaCategory(
|
||||
media_type=MediaType.IMAGE,
|
||||
extensions=_IMAGE_RASTER_SET,
|
||||
is_iana=False,
|
||||
name="raster image",
|
||||
)
|
||||
IMAGE_TYPES = MediaCategory(
|
||||
media_type=MediaType.IMAGE,
|
||||
extensions=_IMAGE_RASTER_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET,
|
||||
is_iana=True,
|
||||
name="image",
|
||||
)
|
||||
INSTALLER_TYPES = MediaCategory(
|
||||
media_type=MediaType.INSTALLER,
|
||||
extensions=_INSTALLER_SET,
|
||||
is_iana=False,
|
||||
name="installer",
|
||||
)
|
||||
IWORK_TYPES = MediaCategory(
|
||||
media_type=MediaType.IWORK,
|
||||
extensions=_IWORK_SET,
|
||||
is_iana=False,
|
||||
name="iwork",
|
||||
)
|
||||
MATERIAL_TYPES = MediaCategory(
|
||||
media_type=MediaType.MATERIAL,
|
||||
extensions=_MATERIAL_SET,
|
||||
is_iana=False,
|
||||
name="material",
|
||||
)
|
||||
MDIPACK_TYPES = MediaCategory(
|
||||
media_type=MediaType.MDIPACK,
|
||||
extensions=_MDIPACK_SET,
|
||||
is_iana=False,
|
||||
name="mdipack",
|
||||
)
|
||||
MODEL_TYPES = MediaCategory(
|
||||
media_type=MediaType.MODEL,
|
||||
extensions=_MODEL_SET,
|
||||
is_iana=True,
|
||||
name="model",
|
||||
)
|
||||
OPEN_DOCUMENT_TYPES = MediaCategory(
|
||||
media_type=MediaType.OPEN_DOCUMENT,
|
||||
extensions=_OPEN_DOCUMENT_SET,
|
||||
is_iana=False,
|
||||
name="open document",
|
||||
)
|
||||
PACKAGE_TYPES = MediaCategory(
|
||||
media_type=MediaType.PACKAGE,
|
||||
extensions=_PACKAGE_SET,
|
||||
is_iana=False,
|
||||
name="package",
|
||||
)
|
||||
PAINT_DOT_NET_TYPES = MediaCategory(
|
||||
media_type=MediaType.PAINT_DOT_NET,
|
||||
extensions=_PAINT_DOT_NET_SET,
|
||||
is_iana=False,
|
||||
name="paint.net",
|
||||
)
|
||||
PDF_TYPES = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET,
|
||||
is_iana=False,
|
||||
name="pdf",
|
||||
)
|
||||
PLAINTEXT_TYPES = MediaCategory(
|
||||
media_type=MediaType.PLAINTEXT,
|
||||
extensions=_PLAINTEXT_SET | _CODE_SET,
|
||||
is_iana=False,
|
||||
name="plaintext",
|
||||
)
|
||||
PRESENTATION_TYPES = MediaCategory(
|
||||
media_type=MediaType.PRESENTATION,
|
||||
extensions=_PRESENTATION_SET,
|
||||
is_iana=False,
|
||||
name="presentation",
|
||||
)
|
||||
PROGRAM_TYPES = MediaCategory(
|
||||
media_type=MediaType.PROGRAM,
|
||||
extensions=_PROGRAM_SET,
|
||||
is_iana=False,
|
||||
name="program",
|
||||
)
|
||||
SHADER_TYPES = MediaCategory(
|
||||
media_type=MediaType.SHADER,
|
||||
extensions=_SHADER_SET,
|
||||
is_iana=False,
|
||||
name="shader",
|
||||
)
|
||||
SHORTCUT_TYPES = MediaCategory(
|
||||
media_type=MediaType.SHORTCUT,
|
||||
extensions=_SHORTCUT_SET,
|
||||
is_iana=False,
|
||||
name="shortcut",
|
||||
)
|
||||
SOURCE_ENGINE_TYPES = MediaCategory(
|
||||
media_type=MediaType.SOURCE_ENGINE,
|
||||
extensions=_SOURCE_ENGINE_SET,
|
||||
is_iana=False,
|
||||
name="source engine",
|
||||
)
|
||||
SPREADSHEET_TYPES = MediaCategory(
|
||||
media_type=MediaType.SPREADSHEET,
|
||||
extensions=_SPREADSHEET_SET,
|
||||
is_iana=False,
|
||||
name="spreadsheet",
|
||||
)
|
||||
TEXT_TYPES = MediaCategory(
|
||||
media_type=MediaType.TEXT,
|
||||
extensions=_DOCUMENT_SET | _PLAINTEXT_SET,
|
||||
is_iana=True,
|
||||
name="text",
|
||||
)
|
||||
VIDEO_TYPES = MediaCategory(
|
||||
media_type=MediaType.VIDEO,
|
||||
extensions=_VIDEO_SET,
|
||||
is_iana=True,
|
||||
name="video",
|
||||
)
|
||||
KRITA_TYPES = MediaCategory(
|
||||
media_type=MediaType.IMAGE,
|
||||
extensions=_KRITA_SET,
|
||||
is_iana=False,
|
||||
name="krita",
|
||||
)
|
||||
|
||||
ALL_CATEGORIES = [
|
||||
ADOBE_PHOTOSHOP_TYPES,
|
||||
AFFINITY_PHOTO_TYPES,
|
||||
ARCHIVE_TYPES,
|
||||
AUDIO_MIDI_TYPES,
|
||||
AUDIO_TYPES,
|
||||
BLENDER_TYPES,
|
||||
CLIP_STUDIO_PAINT_TYPES,
|
||||
DATABASE_TYPES,
|
||||
DISK_IMAGE_TYPES,
|
||||
DOCUMENT_TYPES,
|
||||
EBOOK_TYPES,
|
||||
FONT_TYPES,
|
||||
IMAGE_ANIMATED_TYPES,
|
||||
IMAGE_RAW_TYPES,
|
||||
IMAGE_TYPES,
|
||||
IMAGE_VECTOR_TYPES,
|
||||
INSTALLER_TYPES,
|
||||
IWORK_TYPES,
|
||||
MATERIAL_TYPES,
|
||||
MDIPACK_TYPES,
|
||||
MODEL_TYPES,
|
||||
OPEN_DOCUMENT_TYPES,
|
||||
PACKAGE_TYPES,
|
||||
PAINT_DOT_NET_TYPES,
|
||||
PDF_TYPES,
|
||||
PLAINTEXT_TYPES,
|
||||
PRESENTATION_TYPES,
|
||||
PROGRAM_TYPES,
|
||||
CODE_TYPES,
|
||||
SHADER_TYPES,
|
||||
SHORTCUT_TYPES,
|
||||
SOURCE_ENGINE_TYPES,
|
||||
SPREADSHEET_TYPES,
|
||||
TEXT_TYPES,
|
||||
VIDEO_TYPES,
|
||||
KRITA_TYPES,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]:
|
||||
"""Return a set of MediaTypes given a file extension.
|
||||
def add_types(self, types: list[FileType]) -> None:
|
||||
"""Add one or more types to the group.
|
||||
|
||||
Args:
|
||||
ext (str): File extension with a leading "." and in all lowercase.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
types (list[FileType]): A list of FileType objects to add to the group.
|
||||
"""
|
||||
media_types: set[MediaType] = set()
|
||||
for type_ in types:
|
||||
updated_types: set[FileType] = set()
|
||||
for existing_type in self.types:
|
||||
# If there's any overlap between the extensions, it's the same type
|
||||
if not existing_type.exts.isdisjoint(type_.exts):
|
||||
existing_type.contexts |= type_.contexts
|
||||
existing_type.exts |= type_.exts
|
||||
updated_types.add(type_)
|
||||
if type_ not in updated_types:
|
||||
self.types.append(type_)
|
||||
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if cat.contains(ext, mime_fallback):
|
||||
media_types.add(cat.media_type)
|
||||
contexts_ = [type_.contexts] if isinstance(type_.contexts, str) else type_.contexts
|
||||
for context in contexts_:
|
||||
self.context_sets.setdefault(context, set())
|
||||
for ext in type_.exts:
|
||||
self.context_sets[context].add(ext)
|
||||
|
||||
return media_types
|
||||
|
||||
@staticmethod
|
||||
def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool:
|
||||
"""Check if an extension is a member of a MediaCategory.
|
||||
def contains(self, ext: str, context: str) -> bool:
|
||||
"""Return true if the group contains an extention under a given context, otherwise False.
|
||||
|
||||
Args:
|
||||
ext (str): File extension with a leading "." and in all lowercase.
|
||||
media_cat (MediaCategory): The MediaCategory to check for extension membership.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
ext (str): The file extention to check for in the group.
|
||||
context (str): The context to check for group membership under.
|
||||
"""
|
||||
return media_cat.contains(ext, mime_fallback)
|
||||
equivalent_exts = MediaTypes.equivalent_exts.get(ext) or [ext]
|
||||
return any(e in self.context_sets.get(context, []) for e in equivalent_exts)
|
||||
|
||||
|
||||
class MediaTypes(metaclass=SanitizedAttr):
|
||||
"""A singleton class that manages registered media types and their relationships."""
|
||||
|
||||
_chained_groups: dict[str, set[str]] = {}
|
||||
_name_to_key_map: dict[str, str] = {}
|
||||
all_groups: list[MediaTypeGroup] = []
|
||||
equivalent_exts: dict[str, set[str]] = {}
|
||||
|
||||
@classmethod
|
||||
def _snapshot(cls) -> dict[str, Any]:
|
||||
"""Return a snapshot of the class's attributes. Used in tests."""
|
||||
return {attr: getattr(cls, attr) for attr in dir(cls) if not attr.startswith("__")}
|
||||
|
||||
@classmethod
|
||||
def _restore(cls, attrs: dict[str, Any]) -> None:
|
||||
"""Restore the state of the class from a snapshot. Used in tests."""
|
||||
attrs_to_delete: list[str] = []
|
||||
for name in cls.__dict__:
|
||||
if not name.startswith("__"):
|
||||
try:
|
||||
setattr(cls, name, attrs[name])
|
||||
except KeyError:
|
||||
attrs_to_delete.append(name)
|
||||
|
||||
for attr in attrs_to_delete:
|
||||
delattr(cls, attr)
|
||||
|
||||
@classmethod
|
||||
def add_name_aliases(cls, group_key: str, names: str | list[str]) -> None:
|
||||
"""Adds one or more user-facing names for a MediaTypeGroup.
|
||||
|
||||
For example, "Adobe" and "Adobe Photoshop" would be proper group names.
|
||||
|
||||
Args:
|
||||
group_key (str): The name key associated with a MediaTypeGroup.
|
||||
If a group with this group_key does not exist, it will be created.
|
||||
names (str | list[str]): One or more user-facing names for the group.
|
||||
"""
|
||||
group = getattr(MediaTypes, group_key, None)
|
||||
if group is None:
|
||||
cls.register(group_key, [], [])
|
||||
group = getattr(MediaTypes, group_key, None)
|
||||
|
||||
if not isinstance(group, MediaTypeGroup):
|
||||
return
|
||||
|
||||
if isinstance(names, str):
|
||||
names = [names]
|
||||
for name in names:
|
||||
group.name_aliases.append(name)
|
||||
# Map the name and common variants of the name to the group key.
|
||||
name_no_whitespace = name.replace(" ", "").replace("-", "").replace("_", "")
|
||||
name_no_space_lower = name_no_whitespace.lower()
|
||||
|
||||
cls._name_to_key_map[name] = group_key
|
||||
cls._name_to_key_map[name.lower()] = group_key
|
||||
cls._name_to_key_map[name_no_whitespace] = group_key
|
||||
cls._name_to_key_map[name_no_space_lower] = group_key
|
||||
|
||||
@classmethod
|
||||
def chain_group(cls, parent_group: str, child_groups: str | list[str]) -> None:
|
||||
"""Chain groups so when a type is added to a child group it's also added to a parent group.
|
||||
|
||||
Args:
|
||||
parent_group (str): The group that will also update whenever a child group is updated.
|
||||
If the group doesn't exist, it will be created.
|
||||
child_groups (str | list[str]): Groups that tell a parent group to update as well.
|
||||
If the group doesn't exist, it will be created.
|
||||
"""
|
||||
if isinstance(child_groups, str):
|
||||
child_groups = [child_groups]
|
||||
|
||||
# If the groups don't exist, register it.
|
||||
if getattr(MediaTypes, slugify(parent_group), None) is None:
|
||||
cls.register(parent_group, [], [])
|
||||
for child_group in child_groups:
|
||||
if getattr(MediaTypes, slugify(child_group), None) is None:
|
||||
cls.register(child_group, [], [])
|
||||
cls._chained_groups.setdefault(parent_group, set())
|
||||
|
||||
for c_group in child_groups:
|
||||
cls._chained_groups[parent_group].add(c_group)
|
||||
|
||||
@classmethod
|
||||
def find(cls, ext: str, context: str) -> list[MediaTypeGroup]:
|
||||
"""Return a list of MediaTypeGroups this extention is found in with the given context.
|
||||
|
||||
Args:
|
||||
ext (str): The file extention to check for in the group.
|
||||
context (str): The context to check for group membership under.
|
||||
"""
|
||||
groups: list[MediaTypeGroup] = []
|
||||
for group in cls.all_groups:
|
||||
for type_ in group.types:
|
||||
equivalent_exts = cls.equivalent_exts.get(ext) or [ext]
|
||||
for e in equivalent_exts:
|
||||
if e in type_.exts and context in type_.contexts:
|
||||
groups.append(group)
|
||||
break
|
||||
|
||||
return groups
|
||||
|
||||
@classmethod
|
||||
def contains(cls, group_key: str, ext: str, context: str) -> bool:
|
||||
"""A passthrough method for using `MediaTypeGroup.contains()` given a group name.
|
||||
|
||||
If the group does not exist, this raises an `AttributeError`.
|
||||
|
||||
Args:
|
||||
group_key (str): The name key or attribute name for the MediaTypeGroup.
|
||||
ext (str): The file extention to check for in the group.
|
||||
context (str): The context to check for group membership under.
|
||||
"""
|
||||
group: MediaTypeGroup | None = getattr(MediaTypes, slugify(group_key), None)
|
||||
if not group:
|
||||
raise AttributeError(f"'{slugify(group_key)} is not registered with MediaTypes.")
|
||||
return group.contains(ext, context)
|
||||
|
||||
@classmethod
|
||||
def get_group_key_from_name(
|
||||
cls, name: str, case_sensitive: bool = True, ignore_whitespace: bool = False
|
||||
) -> str | None:
|
||||
"""Attempt to return a group key given a proper name for the group.
|
||||
|
||||
Args:
|
||||
name (str): The user-facing name of a group, or name key.
|
||||
case_sensitive (bool): Should the name be treated with case sensitivity?
|
||||
ignore_whitespace (bool): Should whitespace in the name be ignored?
|
||||
"""
|
||||
if not case_sensitive:
|
||||
name = name.lower()
|
||||
if ignore_whitespace:
|
||||
name = name.replace(" ", "").replace("-", "").replace("_", "")
|
||||
return cls._name_to_key_map.get(name)
|
||||
|
||||
@classmethod
|
||||
def register(cls, group_key: str, ext: str | list[str], contexts: str | list[str]) -> None:
|
||||
"""Create and register or update a existing MediaTypeGroup inside MediaTypes.
|
||||
|
||||
Args:
|
||||
group_key (str): group_key (str): The name key of the MediaTypeGroup.
|
||||
ext (str | list[str]): One or more file extensions, including leading dot.
|
||||
Passing a list of extensions will treat them as equivalent/interchangeable.
|
||||
E.g. [".jpg", ".jpeg", ".jfif"] would be treated as the same extention.
|
||||
contexts (list[str] | str): One or more contexts to register the extension(s) under.
|
||||
"""
|
||||
# Sanitize and homogenize arguments
|
||||
attr_name = slugify(group_key)
|
||||
|
||||
if attr_name in _FORBIDDEN_NAMES:
|
||||
raise AttributeError(f"{attr_name}' collides with an internal attribute.")
|
||||
|
||||
if isinstance(ext, str):
|
||||
ext = [ext]
|
||||
if isinstance(contexts, str):
|
||||
contexts = [contexts]
|
||||
|
||||
# Check for existing group or create new one
|
||||
group = getattr(MediaTypes, attr_name, None)
|
||||
assert isinstance(group, MediaTypeGroup) or group is None
|
||||
|
||||
if group is None:
|
||||
group = MediaTypeGroup(group_key, [])
|
||||
group.add_types([FileType(ext, contexts)])
|
||||
setattr(MediaTypes, attr_name, group)
|
||||
cls.all_groups.append(group)
|
||||
else:
|
||||
group.add_types([FileType(ext, contexts)])
|
||||
|
||||
# Store any file extention equivalents
|
||||
if len(ext) > 1:
|
||||
for e in ext:
|
||||
cls.equivalent_exts.setdefault(e, set(ext))
|
||||
|
||||
# Create any chained groups from dot notations (e.g. "adobe.photoshop")
|
||||
name_parts = group_key.split(".")
|
||||
for i in range(1, len(name_parts)):
|
||||
parent = ".".join(name_parts[:i])
|
||||
child = ".".join(name_parts[: i + 1])
|
||||
cls.chain_group(parent, [child])
|
||||
|
||||
# Update any chained groups
|
||||
chained_groups: set[str] = set()
|
||||
for k, v in cls._chained_groups.items():
|
||||
if group_key in v:
|
||||
chained_groups.add(k)
|
||||
|
||||
if chained_groups:
|
||||
for c_name in chained_groups:
|
||||
cls.register(c_name, ext, contexts)
|
||||
|
||||
@classmethod
|
||||
def get_equivalent_exts(cls, ext: str) -> set[str]:
|
||||
"""Return a set of equivalent file extensions given an extention, including itself.
|
||||
|
||||
Args:
|
||||
ext (str): The file extension, including a leading dot (if there is one).
|
||||
"""
|
||||
return cls.equivalent_exts.get(ext, {ext})
|
||||
|
||||
|
||||
_FORBIDDEN_NAMES = set(dir(MediaTypes))
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
SEARCH = "SEARCH" # MediaType Context
|
||||
|
||||
|
||||
def register_types() -> None:
|
||||
"""Register all internally configured filetype groups with the MediaTypes system."""
|
||||
# Vendor.Suite.Product =========================================================================
|
||||
# These groups are designed so that searching for either the vendor, suite, or product
|
||||
# will return file types only under that group level.
|
||||
|
||||
# Initial Miscellaneous Chaining ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
MediaTypes.chain_group(
|
||||
"office",
|
||||
[
|
||||
"adobe.acrobat",
|
||||
"apple.iwork",
|
||||
"microsoft.office",
|
||||
"open_document",
|
||||
],
|
||||
)
|
||||
MediaTypes.add_name_aliases("office", ["Office", "Office Suite"])
|
||||
|
||||
MediaTypes.chain_group(
|
||||
"document",
|
||||
[
|
||||
"adobe.acrobat",
|
||||
"apple.iwork.pages",
|
||||
"microsoft.office.word",
|
||||
"open_document.document",
|
||||
"typesetting",
|
||||
],
|
||||
)
|
||||
|
||||
MediaTypes.chain_group(
|
||||
"presentation",
|
||||
[
|
||||
"apple.iwork.keynote",
|
||||
"microsoft.office.powerpoint",
|
||||
"open_document.presentation",
|
||||
],
|
||||
)
|
||||
|
||||
MediaTypes.chain_group(
|
||||
"spreadsheet",
|
||||
[
|
||||
"apple.iwork.numbers",
|
||||
"microsoft.office.excel",
|
||||
"open_document.spreadsheet",
|
||||
],
|
||||
)
|
||||
|
||||
# Adobe --------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("adobe", "Adobe")
|
||||
|
||||
# Adobe Acrobat/Reader
|
||||
MediaTypes.add_name_aliases(
|
||||
"adobe.acrobat",
|
||||
[
|
||||
"Acrobat",
|
||||
"Adobe Acrobat",
|
||||
"Adobe Reader",
|
||||
"PDF",
|
||||
"Reader",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("adobe.acrobat", ".fdf", SEARCH)
|
||||
MediaTypes.register("adobe.acrobat", ".pdf", SEARCH)
|
||||
MediaTypes.register("adobe.acrobat", ".pdx", SEARCH)
|
||||
MediaTypes.register("adobe.acrobat", ".ps", SEARCH)
|
||||
MediaTypes.register("adobe.acrobat", ".xfdf", SEARCH)
|
||||
MediaTypes.register("adobe.acrobat", ".xps", SEARCH)
|
||||
|
||||
# Adobe Illustrator
|
||||
MediaTypes.add_name_aliases("adobe.illustrator", ["Illustrator", "Adobe Illustrator"])
|
||||
MediaTypes.register("adobe.illustrator", ".ai", SEARCH)
|
||||
|
||||
# Adobe Photoshop
|
||||
MediaTypes.add_name_aliases("adobe.photoshop", ["Photoshop", "Adobe Photoshop"])
|
||||
MediaTypes.register("adobe.photoshop", ".pdd", SEARCH)
|
||||
MediaTypes.register("adobe.photoshop", ".psb", SEARCH)
|
||||
MediaTypes.register("adobe.photoshop", ".psd", SEARCH)
|
||||
|
||||
# Affinity -----------------------------------------------------------------
|
||||
# NOTE: Affinity suite products with generic names (e.g. "Photo") should not have those
|
||||
# names be standalone aliases as they will conflict with other, more common names.
|
||||
MediaTypes.add_name_aliases("affinity", "Affinity")
|
||||
MediaTypes.register("affinity", ".af", SEARCH)
|
||||
|
||||
# Affinity Designer
|
||||
MediaTypes.add_name_aliases("affinity.designer", ["Designer", "Affinity Designer"])
|
||||
MediaTypes.register("affinity.designer", ".afdesign", SEARCH)
|
||||
|
||||
# Affinity Photo
|
||||
MediaTypes.add_name_aliases("affinity.photo", "Affinity Photo")
|
||||
MediaTypes.register("affinity.photo", ".afphoto", SEARCH)
|
||||
|
||||
# Affinity Publisher
|
||||
MediaTypes.add_name_aliases("affinity.publisher", "Affinity Publisher")
|
||||
MediaTypes.register("affinity.publisher", [".afpublisher", ".afpub"], SEARCH)
|
||||
|
||||
# Apple & iWork ------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("apple", "Apple")
|
||||
MediaTypes.add_name_aliases("apple.iwork", "iWork")
|
||||
MediaTypes.add_name_aliases("apple.creator_studio", ["Apple Creator Studio", "Creator Studio"])
|
||||
# NOTE: iWork is a subset of Creator Studio
|
||||
MediaTypes.chain_group("apple.creator_studio", "apple.iwork")
|
||||
|
||||
# Apple Books
|
||||
MediaTypes.add_name_aliases("apple.books", ["Apple Books", "Apple iBooks", "iBooks"])
|
||||
MediaTypes.register("apple.books", ".ibook", SEARCH)
|
||||
|
||||
# Keynote (iWork + Apple Creator Studio)
|
||||
MediaTypes.add_name_aliases(
|
||||
"apple.iwork.keynote",
|
||||
[
|
||||
"Keynote",
|
||||
"Apple Keynote",
|
||||
"Apple iWork Keynote",
|
||||
"iWork Keynote",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("apple.iwork.keynote", ".key", SEARCH)
|
||||
|
||||
# Numbers (iWork + Apple Creator Studio)
|
||||
MediaTypes.add_name_aliases(
|
||||
"apple.iwork.numbers",
|
||||
[
|
||||
"Numbers",
|
||||
"Apple Numbers",
|
||||
"Apple iWork Numbers",
|
||||
"iWork Numbers",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("apple.iwork.numbers", ".numbers", SEARCH)
|
||||
|
||||
# Pages (iWork + Apple Creator Studio)
|
||||
MediaTypes.add_name_aliases(
|
||||
"apple.iwork.pages",
|
||||
[
|
||||
"Pages",
|
||||
"Apple Pages",
|
||||
"Apple iWork Pages",
|
||||
"iWork Pages",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("apple.iwork.pages", ".pages", SEARCH)
|
||||
|
||||
# Pixelmator Pro (Apple Creator Studio)
|
||||
MediaTypes.add_name_aliases(
|
||||
"apple.creator_studio.pixelmator",
|
||||
[
|
||||
"Apple Pixelmator Pro",
|
||||
"Apple Pixelmator",
|
||||
"Pixelmator Pro",
|
||||
"Pixelmator",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("apple.creator_studio.pixelmator", ".pxd", SEARCH)
|
||||
|
||||
# Autodesk -----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("autodesk", "Autodesk")
|
||||
MediaTypes.register("autodesk", ".3ds", SEARCH)
|
||||
MediaTypes.register("autodesk", ".fbx", SEARCH)
|
||||
|
||||
# Blender ------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("blender", "Blender")
|
||||
MediaTypes.register("blender", ".blen_tc", SEARCH)
|
||||
MediaTypes.register("blender", ".blend", SEARCH)
|
||||
# Numbered Blender auto-backup files (.blend1 - .blend32)
|
||||
MediaTypes.register("blender", [f".blend{i}" for i in range(1, 33)], SEARCH)
|
||||
|
||||
# Clip Studio Paint --------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("clip_studio_paint", ["Clip Studio", "Clip Studio Paint"])
|
||||
MediaTypes.register("clip_studio_paint", ".clip", SEARCH)
|
||||
MediaTypes.register("clip_studio_paint", ".cmc", SEARCH)
|
||||
MediaTypes.register("clip_studio_paint", ".lip", SEARCH)
|
||||
|
||||
# Corel --------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("corel.wordperfect", ["WordPerfect", "Corel WordPerfect"])
|
||||
MediaTypes.register("corel.wordperfect", ".wpd", SEARCH)
|
||||
MediaTypes.add_name_aliases("corel", "Corel")
|
||||
|
||||
# GIMP ---------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("gimp", "GIMP")
|
||||
MediaTypes.register("gimp", ".ora", SEARCH) # OpenRaster, used by Krita, GIMP, etc.
|
||||
MediaTypes.register("gimp", ".xcf", SEARCH)
|
||||
|
||||
# Krita --------------------------------------------------------------------
|
||||
# NOTE: As more KDE apps potentially get added, this might need to go under a KDE group.
|
||||
MediaTypes.add_name_aliases("krita", ["Krita", "KDE Krita"])
|
||||
MediaTypes.register("krita", ".kra", SEARCH)
|
||||
MediaTypes.register("krita", ".krz", SEARCH)
|
||||
MediaTypes.register("krita", ".ora", SEARCH) # OpenRaster, used by Krita, GIMP, etc.
|
||||
|
||||
# MediBang Paint / FireAlpaca ----------------------------------------------
|
||||
MediaTypes.add_name_aliases("medibang_paint", ["FireAlpaca", "MediBang Paint", "MediBang"])
|
||||
MediaTypes.register("medibang_paint", ".mdp", SEARCH)
|
||||
|
||||
# Microsoft Office ---------------------------------------------------------
|
||||
MediaTypes.add_name_aliases(
|
||||
"microsoft.office",
|
||||
[
|
||||
"Microsoft 365",
|
||||
"Microsoft Office 365",
|
||||
"Microsoft Office",
|
||||
"MS Office",
|
||||
"Office 365",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("microsoft.office", ".wdb", SEARCH) # Microsoft Works Database
|
||||
|
||||
MediaTypes.add_name_aliases(
|
||||
"microsoft.office.access",
|
||||
[
|
||||
"Access",
|
||||
"Microsoft Access",
|
||||
"Microsoft Office Access",
|
||||
"Office Access",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("microsoft.office.access", ".accdb", SEARCH)
|
||||
MediaTypes.register("microsoft.office.access", ".mdb", SEARCH)
|
||||
|
||||
MediaTypes.add_name_aliases(
|
||||
"microsoft.office.excel",
|
||||
[
|
||||
"Excel",
|
||||
"Microsoft Excel",
|
||||
"Microsoft Office Excel",
|
||||
"Office Excel",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("microsoft.office.excel", ".xlr", SEARCH)
|
||||
MediaTypes.register("microsoft.office.excel", ".xls", SEARCH)
|
||||
MediaTypes.register("microsoft.office.excel", ".xlsx", SEARCH)
|
||||
|
||||
MediaTypes.add_name_aliases(
|
||||
"microsoft.office.powerpoint",
|
||||
[
|
||||
"PowerPoint",
|
||||
"Microsoft PowerPoint",
|
||||
"Microsoft Office PowerPoint",
|
||||
"Office PowerPoint",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".pot", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".potm", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".potx", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".ppam", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".pps", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".ppsm", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".ppsx", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".ppt", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".pptm", SEARCH)
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".pptx", SEARCH)
|
||||
|
||||
MediaTypes.add_name_aliases(
|
||||
"microsoft.office.word",
|
||||
[
|
||||
"Word",
|
||||
"Microsoft Word",
|
||||
"Microsoft Office Word",
|
||||
"Office Word",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("microsoft.office.word", ".doc", SEARCH)
|
||||
MediaTypes.register("microsoft.office.word", ".docm", SEARCH)
|
||||
MediaTypes.register("microsoft.office.word", ".docx", SEARCH)
|
||||
MediaTypes.register("microsoft.office.word", ".dot", SEARCH)
|
||||
MediaTypes.register("microsoft.office.word", ".dotm", SEARCH)
|
||||
MediaTypes.register("microsoft.office.word", ".dotx", SEARCH)
|
||||
MediaTypes.register("microsoft.office.word", ".wps", SEARCH)
|
||||
|
||||
# MuseScore ----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("musescore", ["MuseScore", "MuseScore Studio"])
|
||||
MediaTypes.register("musescore", ".mscz", SEARCH)
|
||||
|
||||
# OpenDocument -------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("open_document", ["LibreOffice", "OpenDocument", "OpenOffice"])
|
||||
MediaTypes.register("open_document", ".fodg", SEARCH)
|
||||
MediaTypes.register("open_document", ".odf", SEARCH)
|
||||
MediaTypes.register("open_document", ".odg", SEARCH)
|
||||
|
||||
MediaTypes.register("open_document.document", ".fodt", SEARCH)
|
||||
MediaTypes.register("open_document.document", ".odt", SEARCH)
|
||||
|
||||
MediaTypes.register("open_document.presentation", ".fodp", SEARCH)
|
||||
MediaTypes.register("open_document.presentation", ".odp", SEARCH)
|
||||
|
||||
MediaTypes.register("open_document.spreadsheet", ".fods", SEARCH)
|
||||
MediaTypes.register("open_document.spreadsheet", ".ods", SEARCH)
|
||||
|
||||
# Paint.NET ----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("paint_dot_net", ["Paint.NET", "PaintDotNet"])
|
||||
MediaTypes.register("paint_dot_net", ".pdn", SEARCH)
|
||||
|
||||
# Unity Game Engine --------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("unity", ["Unity Engine", "Unity"])
|
||||
MediaTypes.register("unity", ".meta", SEARCH)
|
||||
|
||||
# Valve Source Engine ------------------------------------------------------
|
||||
MediaTypes.add_name_aliases(
|
||||
"source_engine",
|
||||
[
|
||||
"Source 2 Engine",
|
||||
"Source Engine",
|
||||
"Valve Source 2 Engine",
|
||||
"Valve Source Engine",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("source_engine", ".fgd", SEARCH)
|
||||
MediaTypes.register("source_engine", ".gi", SEARCH)
|
||||
MediaTypes.register("source_engine", ".kv3", SEARCH)
|
||||
MediaTypes.register("source_engine", ".nut", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vcfg", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vdf", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vmt", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vqlayout", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vsc", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vsnd_template", SEARCH)
|
||||
MediaTypes.register("source_engine", ".vtf", SEARCH)
|
||||
|
||||
# General Media Types ==========================================================================
|
||||
# These are general groups for media types based on the file formats and uses themselves, rather
|
||||
# than the vendors. Extensions may be duplicated here if they belong in both sections.
|
||||
|
||||
# 3D Models & Materials ----------------------------------------------------
|
||||
MediaTypes.add_name_aliases("material", "Material")
|
||||
MediaTypes.register("material", ".mtl", SEARCH)
|
||||
|
||||
MediaTypes.add_name_aliases("model", ["3D Model", "3D Object", "Model", "Object"])
|
||||
MediaTypes.register("model", ".3ds", SEARCH)
|
||||
MediaTypes.register("model", ".3mf", SEARCH)
|
||||
MediaTypes.register("model", ".fbx", SEARCH)
|
||||
MediaTypes.register("model", ".obj", SEARCH)
|
||||
MediaTypes.register("model", ".stl", SEARCH)
|
||||
|
||||
# Archives -----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("archive", ["Archive", "Compressed"])
|
||||
MediaTypes.register("archive", ".cba", SEARCH) # Also under "ebook.comic"
|
||||
|
||||
# RAR
|
||||
MediaTypes.add_name_aliases(
|
||||
"archive.rar",
|
||||
[
|
||||
"RAR Archive",
|
||||
"RAR",
|
||||
"WinRAR",
|
||||
"WinRAR Archive",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("archive.rar", ".cbr", SEARCH) # Also under "ebook.comic"
|
||||
MediaTypes.register("archive.rar", ".rar", SEARCH)
|
||||
MediaTypes.register("archive.rar", ".rev", SEARCH)
|
||||
|
||||
# tar
|
||||
MediaTypes.add_name_aliases(
|
||||
"archive.tar",
|
||||
[
|
||||
"Tape Archive",
|
||||
"tar Archive",
|
||||
"tarball",
|
||||
"tar",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("archive.tar", ".tar", SEARCH)
|
||||
MediaTypes.register("archive.tar", [".tar.bz", ".tb2", ".tbz", ".tbz2", ".tz2"], SEARCH)
|
||||
MediaTypes.register("archive.tar", [".tar.gz", ".taz", ".tgz"], SEARCH)
|
||||
MediaTypes.register("archive.tar", [".tar.lzma", ".tlz"], SEARCH)
|
||||
MediaTypes.register("archive.tar", [".tar.xz", ".txz"], SEARCH)
|
||||
MediaTypes.register("archive.tar", [".tar.zst", ".tzst"], SEARCH)
|
||||
MediaTypes.register("archive.tar", ".cbt", SEARCH) # Also under "ebook.comic"
|
||||
|
||||
# ZIP
|
||||
MediaTypes.add_name_aliases(
|
||||
"archive.zip",
|
||||
[
|
||||
"7-Zip Archive",
|
||||
"7-Zip",
|
||||
"SevenZip Archive",
|
||||
"SevenZip",
|
||||
"WinZIP Archive",
|
||||
"WinZIP",
|
||||
"Zip Archive",
|
||||
"ZIP",
|
||||
"ZIP File",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("archive.zip", ".7z", SEARCH)
|
||||
MediaTypes.register("archive.zip", ".cb7", SEARCH) # Also under "ebook.comic"
|
||||
MediaTypes.register("archive.zip", ".cbz", SEARCH) # Also under "ebook.comic"
|
||||
MediaTypes.register("archive.zip", ".s7z", SEARCH)
|
||||
MediaTypes.register("archive.zip", ".zip", SEARCH)
|
||||
MediaTypes.register("archive.zip", ".zipx", SEARCH)
|
||||
|
||||
# Audio --------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("audio", "Audio")
|
||||
MediaTypes.register("audio", ".aac", SEARCH)
|
||||
MediaTypes.register("audio", ".aifc", SEARCH)
|
||||
MediaTypes.register("audio", ".alac", SEARCH)
|
||||
MediaTypes.register("audio", ".caf", SEARCH)
|
||||
MediaTypes.register("audio", ".flac", SEARCH)
|
||||
MediaTypes.register("audio", ".m4a", SEARCH)
|
||||
MediaTypes.register("audio", ".m4p", SEARCH)
|
||||
MediaTypes.register("audio", ".m4r", SEARCH)
|
||||
MediaTypes.register("audio", ".mp3", SEARCH)
|
||||
MediaTypes.register("audio", ".ogg", SEARCH)
|
||||
MediaTypes.register("audio", ".wma", SEARCH)
|
||||
MediaTypes.register("audio", [".aif", ".aiff"], SEARCH)
|
||||
MediaTypes.register("audio", [".wav", ".wave"], SEARCH)
|
||||
|
||||
# MIDI
|
||||
MediaTypes.add_name_aliases("audio.midi", ["MIDI", "General MIDI"])
|
||||
MediaTypes.register("audio.midi", [".mid", ".midi"], SEARCH)
|
||||
|
||||
# Binary -------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("binary", "Binary")
|
||||
MediaTypes.register("binary", ".aab", SEARCH)
|
||||
MediaTypes.register("binary", ".dll", SEARCH)
|
||||
MediaTypes.register("binary", ".dylib", SEARCH)
|
||||
MediaTypes.register("binary", ".exe", SEARCH)
|
||||
MediaTypes.register("binary", ".o", SEARCH)
|
||||
MediaTypes.register("binary", ".pyc", SEARCH)
|
||||
MediaTypes.register("binary", ".pyd", SEARCH)
|
||||
MediaTypes.register("binary", ".pyo", SEARCH)
|
||||
|
||||
# Databases ----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("database", ["Database", "DB"])
|
||||
MediaTypes.register("database", ".db", SEARCH)
|
||||
MediaTypes.register("database", ".pdb", SEARCH)
|
||||
MediaTypes.register("database", ".sqlite", SEARCH)
|
||||
MediaTypes.register("database", ".sqlite3", SEARCH)
|
||||
MediaTypes.register("database", ".wdb", SEARCH)
|
||||
|
||||
# Documents ----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("document", ["Document", "Text Document", "Word Processor"])
|
||||
|
||||
# Disk Images --------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("disk_image", ["Disk Image", "Disc Image"])
|
||||
MediaTypes.register("disk_image", ".bios", SEARCH)
|
||||
MediaTypes.register("disk_image", ".dmg", SEARCH)
|
||||
MediaTypes.register("disk_image", ".fhdx", SEARCH)
|
||||
MediaTypes.register("disk_image", ".iso", SEARCH)
|
||||
MediaTypes.register("disk_image", ".udf", SEARCH)
|
||||
|
||||
# eBooks & Comics ----------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("ebook", "eBook")
|
||||
MediaTypes.register("ebook", ".azw", SEARCH)
|
||||
MediaTypes.register("ebook", ".azw3", SEARCH)
|
||||
MediaTypes.register("ebook", ".djvu", SEARCH)
|
||||
MediaTypes.register("ebook", ".epub", SEARCH)
|
||||
MediaTypes.register("ebook", ".fb2", SEARCH)
|
||||
MediaTypes.register("ebook", ".ibook", SEARCH) # Also under "apple.books"
|
||||
MediaTypes.register("ebook", ".kfx", SEARCH)
|
||||
MediaTypes.register("ebook", ".lit", SEARCH)
|
||||
MediaTypes.register("ebook", ".mobi", SEARCH)
|
||||
MediaTypes.register("ebook", ".prc", SEARCH)
|
||||
|
||||
# Comic Book Archives
|
||||
MediaTypes.add_name_aliases("ebook.comic", ["Comic Archive", "Comic Book Archive", "Comic"])
|
||||
MediaTypes.register("ebook.comic", ".cb7", SEARCH)
|
||||
MediaTypes.register("ebook.comic", ".cba", SEARCH)
|
||||
MediaTypes.register("ebook.comic", ".cbr", SEARCH)
|
||||
MediaTypes.register("ebook.comic", ".cbt", SEARCH)
|
||||
MediaTypes.register("ebook.comic", ".cbz", SEARCH)
|
||||
|
||||
# Fonts --------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("font", "Font")
|
||||
MediaTypes.register("font", ".fon", SEARCH)
|
||||
MediaTypes.register("font", ".otf", SEARCH)
|
||||
MediaTypes.register("font", ".ttc", SEARCH)
|
||||
MediaTypes.register("font", ".ttf", SEARCH)
|
||||
MediaTypes.register("font", ".woff", SEARCH)
|
||||
MediaTypes.register("font", ".woff2", SEARCH)
|
||||
|
||||
# Images -------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("image", ["Image", "Photo", "Picture"])
|
||||
|
||||
# Raster Images
|
||||
MediaTypes.add_name_aliases("image.raster", "Raster Image")
|
||||
MediaTypes.register("image.raster", ".apng", SEARCH)
|
||||
MediaTypes.register("image.raster", ".avif", SEARCH)
|
||||
MediaTypes.register("image.raster", ".bmp", SEARCH)
|
||||
MediaTypes.register("image.raster", ".exr", SEARCH)
|
||||
MediaTypes.register("image.raster", ".gif", SEARCH)
|
||||
MediaTypes.register("image.raster", ".jxl", SEARCH)
|
||||
MediaTypes.register("image.raster", ".png", SEARCH)
|
||||
MediaTypes.register("image.raster", ".webp", SEARCH)
|
||||
MediaTypes.register("image.raster", [".heic", ".heif"], SEARCH)
|
||||
MediaTypes.register("image.raster", [".j2k", ".jp2", ".jpg2"], SEARCH)
|
||||
MediaTypes.register(
|
||||
"image.raster",
|
||||
[
|
||||
".jfif",
|
||||
".jpeg_large",
|
||||
".jpeg",
|
||||
".jpg_large",
|
||||
".jpg",
|
||||
],
|
||||
SEARCH,
|
||||
)
|
||||
MediaTypes.register("image.raster", [".tif", ".tiff"], SEARCH)
|
||||
|
||||
# Icons
|
||||
MediaTypes.add_name_aliases("image.raster.icon", "Icon")
|
||||
MediaTypes.register("image.raster.icon", ".icns", SEARCH)
|
||||
MediaTypes.register("image.raster.icon", ".ico", SEARCH)
|
||||
MediaTypes.register("image.raster.icon", ".icon", SEARCH)
|
||||
|
||||
# Raw Images
|
||||
MediaTypes.add_name_aliases("image.raster.raw", ["Digital Negative", "Raw Image", "Raw"])
|
||||
MediaTypes.register("image.raster.raw", ".arw", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".cr2", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".cr3", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".crw", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".dng", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".nef", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".nrw", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".orf", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".r3d", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".raf", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".raw", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".rw2", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".srf", SEARCH)
|
||||
MediaTypes.register("image.raster.raw", ".srf2", SEARCH)
|
||||
|
||||
# Vector Images
|
||||
MediaTypes.add_name_aliases(
|
||||
"image.vector",
|
||||
[
|
||||
"Scalable Vector Graphic",
|
||||
"Scalable Vector",
|
||||
"Vector Graphic",
|
||||
"Vector Image",
|
||||
"Vector",
|
||||
],
|
||||
)
|
||||
MediaTypes.register("image.vector", ".eps", SEARCH)
|
||||
MediaTypes.register("image.vector", ".epsf", SEARCH)
|
||||
MediaTypes.register("image.vector", ".epsi", SEARCH)
|
||||
MediaTypes.register("image.vector", ".svg", SEARCH)
|
||||
MediaTypes.register("image.vector", ".svgz", SEARCH)
|
||||
|
||||
# Animated Images
|
||||
MediaTypes.add_name_aliases("image.animated", ["Animated Image", "Animated"])
|
||||
MediaTypes.register("image.animated", ".gif", SEARCH)
|
||||
MediaTypes.register("image.animated", ".apng", SEARCH)
|
||||
MediaTypes.register("image.animated", ".webp", SEARCH)
|
||||
MediaTypes.register("image.animated", ".jxl", SEARCH)
|
||||
|
||||
# Presentations ------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("presentation", ["Presentation", "Slide Show", "Slides"])
|
||||
MediaTypes.register("presentation", ".fodp", SEARCH)
|
||||
MediaTypes.register("presentation", ".key", SEARCH)
|
||||
MediaTypes.register("presentation", ".odp", SEARCH)
|
||||
MediaTypes.register("presentation", ".pot", SEARCH)
|
||||
MediaTypes.register("presentation", ".potm", SEARCH)
|
||||
MediaTypes.register("presentation", ".potx", SEARCH)
|
||||
MediaTypes.register("presentation", ".ppam", SEARCH)
|
||||
MediaTypes.register("presentation", ".pps", SEARCH)
|
||||
MediaTypes.register("presentation", ".ppsm", SEARCH)
|
||||
MediaTypes.register("presentation", ".ppsx", SEARCH)
|
||||
MediaTypes.register("presentation", ".ppt", SEARCH)
|
||||
MediaTypes.register("presentation", ".pptm", SEARCH)
|
||||
MediaTypes.register("presentation", ".pptx", SEARCH)
|
||||
|
||||
# Programs, Installers, & Packages -----------------------------------------
|
||||
MediaTypes.add_name_aliases("program", ["App", "Application", "Executable", "Program"])
|
||||
MediaTypes.register("program", ".apk", SEARCH)
|
||||
MediaTypes.register("program", ".apkm", SEARCH)
|
||||
MediaTypes.register("program", ".apks", SEARCH)
|
||||
MediaTypes.register("program", ".app", SEARCH)
|
||||
MediaTypes.register("program", ".appx", SEARCH)
|
||||
MediaTypes.register("program", ".bin", SEARCH)
|
||||
MediaTypes.register("program", ".exe", SEARCH)
|
||||
MediaTypes.register("program", ".msi", SEARCH)
|
||||
MediaTypes.register("program", ".msix", SEARCH)
|
||||
MediaTypes.register("program", ".pkg", SEARCH)
|
||||
MediaTypes.register("program", ".xapk", SEARCH)
|
||||
|
||||
# Rich Text ----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("rich_text", ["Rich Text", "Rich Text Document"])
|
||||
MediaTypes.register("rich_text", ".rtf", SEARCH)
|
||||
|
||||
# Shaders ------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("shader", "Shader")
|
||||
MediaTypes.register("shader", ".effect", SEARCH)
|
||||
MediaTypes.register("shader", ".frag", SEARCH)
|
||||
MediaTypes.register("shader", ".fsh", SEARCH)
|
||||
MediaTypes.register("shader", ".glsl", SEARCH)
|
||||
MediaTypes.register("shader", ".shader", SEARCH)
|
||||
MediaTypes.register("shader", ".vert", SEARCH)
|
||||
MediaTypes.register("shader", ".vsh", SEARCH)
|
||||
|
||||
# Shell Script -------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("shell", ["Shell Script", "Shell"])
|
||||
MediaTypes.register("shell", ".bat", SEARCH)
|
||||
MediaTypes.register("shell", ".csh", SEARCH)
|
||||
MediaTypes.register("shell", ".fish", SEARCH)
|
||||
MediaTypes.register("shell", ".nu", SEARCH)
|
||||
MediaTypes.register("shell", ".ps1", SEARCH)
|
||||
MediaTypes.register("shell", ".sh", SEARCH)
|
||||
MediaTypes.register("shell", "activate", SEARCH)
|
||||
|
||||
# Shortcuts ----------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("shortcut", "Shortcut")
|
||||
MediaTypes.register("shortcut", ".desktop", SEARCH)
|
||||
MediaTypes.register("shortcut", ".lnk", SEARCH)
|
||||
MediaTypes.register("shortcut", ".url", SEARCH)
|
||||
|
||||
# Spreadsheets -------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("spreadsheet", ["Spreadsheet", "Sheet"])
|
||||
MediaTypes.register("spreadsheet", ".csv", SEARCH)
|
||||
|
||||
# Plaintext ----------------------------------------------------------------
|
||||
# NOTE: If extensions here can be grouped or moved to more specific categories, do that.
|
||||
# Something like a "Code" group may be considered, but that may be too subjective.
|
||||
|
||||
MediaTypes.add_name_aliases("plaintext", "Plaintext")
|
||||
MediaTypes.register("plaintext", ".cfg", SEARCH)
|
||||
MediaTypes.register("plaintext", ".conf", SEARCH)
|
||||
MediaTypes.register("plaintext", ".config", SEARCH)
|
||||
MediaTypes.register("plaintext", ".gitignore", SEARCH)
|
||||
MediaTypes.register("plaintext", ".i3u", SEARCH)
|
||||
MediaTypes.register("plaintext", ".lang", SEARCH)
|
||||
MediaTypes.register("plaintext", ".lock", SEARCH)
|
||||
MediaTypes.register("plaintext", ".log", SEARCH)
|
||||
MediaTypes.register("plaintext", ".plist", SEARCH)
|
||||
MediaTypes.register("plaintext", ".prefs", SEARCH)
|
||||
MediaTypes.register("plaintext", ".spec", SEARCH)
|
||||
MediaTypes.register("plaintext", ".theme", SEARCH)
|
||||
MediaTypes.register("plaintext", ".timestamp", SEARCH)
|
||||
MediaTypes.register("plaintext", "contributing", SEARCH)
|
||||
MediaTypes.register("plaintext", "license", SEARCH)
|
||||
MediaTypes.register("plaintext", "readme", SEARCH)
|
||||
MediaTypes.register("plaintext", [".editorconfig", ".inf", ".ini"], SEARCH)
|
||||
MediaTypes.register("plaintext", [".patch", ".diff"], SEARCH)
|
||||
MediaTypes.register("plaintext", [".txt", ".text"], SEARCH)
|
||||
MediaTypes.register("plaintext", ["pkginfo", ".pkginfo"], SEARCH)
|
||||
|
||||
# C
|
||||
MediaTypes.add_name_aliases("plaintext.c", "C")
|
||||
MediaTypes.register("plaintext.c", ".c", SEARCH)
|
||||
MediaTypes.register("plaintext.c", ".h", SEARCH)
|
||||
|
||||
# C++
|
||||
MediaTypes.add_name_aliases("plaintext.cpp", ["C++", "CPP"])
|
||||
MediaTypes.register("plaintext.cpp", ".cpp", SEARCH)
|
||||
MediaTypes.register("plaintext.cpp", ".h", SEARCH)
|
||||
MediaTypes.register("plaintext.cpp", ".hpp", SEARCH)
|
||||
|
||||
# C#
|
||||
MediaTypes.add_name_aliases("plaintext.csharp", ["C#", "C Sharp"])
|
||||
MediaTypes.register("plaintext.csharp", ".cs", SEARCH)
|
||||
|
||||
# CSS
|
||||
MediaTypes.add_name_aliases("plaintext.css", "CSS")
|
||||
MediaTypes.register("plaintext.css", ".css", SEARCH)
|
||||
MediaTypes.register("plaintext.css", ".less", SEARCH)
|
||||
MediaTypes.register("plaintext.css", ".qss", SEARCH)
|
||||
MediaTypes.register("plaintext.css", ".sass", SEARCH)
|
||||
MediaTypes.register("plaintext.css", ".scss", SEARCH)
|
||||
MediaTypes.register("plaintext.css", ".styl", SEARCH)
|
||||
|
||||
# D
|
||||
MediaTypes.add_name_aliases("plaintext.d", "D")
|
||||
MediaTypes.register("plaintext.d", ".d", SEARCH)
|
||||
MediaTypes.register("plaintext.d", ".h", SEARCH)
|
||||
|
||||
# HTML
|
||||
MediaTypes.add_name_aliases("plaintext.html", "HTML")
|
||||
MediaTypes.register("plaintext.html", [".dhtml", ".htm", ".html", ".shtml", ".xhtml"], SEARCH)
|
||||
|
||||
# JavaScript
|
||||
MediaTypes.chain_group("plaintext.javascript", "plaintext.typescript")
|
||||
MediaTypes.add_name_aliases("plaintext.javascript", ["JavaScript", "JS"])
|
||||
MediaTypes.register("plaintext.javascript", ".cjs", SEARCH)
|
||||
MediaTypes.register("plaintext.javascript", ".js", SEARCH)
|
||||
MediaTypes.register("plaintext.javascript", ".jsx", SEARCH)
|
||||
MediaTypes.register("plaintext.javascript", ".mjs", SEARCH)
|
||||
|
||||
# JSON
|
||||
MediaTypes.add_name_aliases("plaintext.json", "JSON")
|
||||
MediaTypes.register("plaintext.json", [".json", ".json5", ".jsonc", ".jsonl"], SEARCH)
|
||||
|
||||
# Lua
|
||||
MediaTypes.add_name_aliases("plaintext.lua", "Lua")
|
||||
MediaTypes.register("plaintext.lua", ".lua", SEARCH)
|
||||
|
||||
# Markdown
|
||||
MediaTypes.add_name_aliases("plaintext.markdown", ["Markdown", "MD"])
|
||||
MediaTypes.register("plaintext.markdown", [".markdown", ".md", ".mkd", ".rmd"], SEARCH)
|
||||
|
||||
# Nix
|
||||
MediaTypes.add_name_aliases("plaintext.nix", "Nix")
|
||||
MediaTypes.register("plaintext.nix", ".nix", SEARCH)
|
||||
|
||||
# PHP
|
||||
MediaTypes.add_name_aliases("plaintext.php", "PHP")
|
||||
MediaTypes.register("plaintext.php", ".php", SEARCH)
|
||||
|
||||
# Qt
|
||||
MediaTypes.add_name_aliases("plaintext.qt", "Qt")
|
||||
MediaTypes.register("plaintext.qt", ".qml", SEARCH)
|
||||
MediaTypes.register("plaintext.qt", ".qrc", SEARCH)
|
||||
|
||||
# Rust
|
||||
MediaTypes.add_name_aliases("plaintext.rust", "Rust")
|
||||
MediaTypes.register("plaintext.rust", ".rs", SEARCH)
|
||||
|
||||
# Tcl
|
||||
MediaTypes.add_name_aliases("plaintext.tcl", "Tcl")
|
||||
MediaTypes.register("plaintext.tcl", ".tcl", SEARCH)
|
||||
|
||||
# TOML
|
||||
MediaTypes.add_name_aliases("plaintext.toml", "TOML")
|
||||
MediaTypes.register("plaintext.toml", ".toml", SEARCH)
|
||||
|
||||
# TypeScript
|
||||
MediaTypes.add_name_aliases("plaintext.typescript", "TypeScript")
|
||||
MediaTypes.register("plaintext.typescript", ".cts", SEARCH)
|
||||
MediaTypes.register("plaintext.typescript", ".ts", SEARCH)
|
||||
MediaTypes.register("plaintext.typescript", ".mts", SEARCH)
|
||||
MediaTypes.register("plaintext.typescript", ".tsx", SEARCH)
|
||||
|
||||
# XML
|
||||
MediaTypes.add_name_aliases("plaintext.xml", "XML")
|
||||
MediaTypes.register("plaintext.xml", [".xml", ".xul"], SEARCH)
|
||||
|
||||
# YAML
|
||||
MediaTypes.add_name_aliases("plaintext.yaml", "YAML")
|
||||
MediaTypes.register("plaintext.yaml", [".yaml", ".yml"], SEARCH)
|
||||
|
||||
# Python -------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("python", "Python")
|
||||
MediaTypes.register("python", ".ipynb", SEARCH)
|
||||
MediaTypes.register("python", ".py", SEARCH)
|
||||
MediaTypes.register("python", ".pyc", SEARCH)
|
||||
MediaTypes.register("python", ".pyd", SEARCH)
|
||||
MediaTypes.register("python", ".pyi", SEARCH)
|
||||
MediaTypes.register("python", ".pyo", SEARCH)
|
||||
MediaTypes.register("python", ".sip", SEARCH)
|
||||
|
||||
# Typesetting --------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("typesetting", ["Typesetting", "Typesetter"])
|
||||
|
||||
# TeX/LaTeX
|
||||
MediaTypes.add_name_aliases("typesetting.latex", ["LaTeX", "TeX"])
|
||||
MediaTypes.register("typesetting.latex", ".tex", SEARCH)
|
||||
|
||||
# Typst
|
||||
MediaTypes.add_name_aliases("typesetting.typst", "Typst")
|
||||
MediaTypes.register("typesetting.typst", ".typ", SEARCH)
|
||||
|
||||
# Video --------------------------------------------------------------------
|
||||
MediaTypes.add_name_aliases("video", "Video")
|
||||
MediaTypes.register("video", ".3gp", SEARCH)
|
||||
MediaTypes.register("video", ".avi", SEARCH)
|
||||
MediaTypes.register("video", ".flv", SEARCH)
|
||||
MediaTypes.register("video", ".gifv", SEARCH)
|
||||
MediaTypes.register("video", ".hevc", SEARCH)
|
||||
MediaTypes.register("video", ".m4v", SEARCH)
|
||||
MediaTypes.register("video", ".mkv", SEARCH)
|
||||
MediaTypes.register("video", ".mov", SEARCH)
|
||||
MediaTypes.register("video", ".mp4", SEARCH)
|
||||
MediaTypes.register("video", ".webm", SEARCH)
|
||||
MediaTypes.register("video", ".wmv", SEARCH)
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SanitizedAttr(type):
|
||||
def __getattr__(cls, name: str) -> Any: # pyright: ignore[reportExplicitAny]
|
||||
sanitized = name.replace(".", "_")
|
||||
|
||||
if sanitized == name:
|
||||
raise AttributeError(f"'{type(cls).__name__}' object has no attribute '{name}'")
|
||||
|
||||
return getattr(cls, sanitized)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from threading import Lock
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# pyright: standard
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
|
||||
RENDER = "RENDER" # MediaType Context
|
||||
|
||||
|
||||
class BasePreview:
|
||||
"""A base preview renderer class.
|
||||
|
||||
Attributes:
|
||||
_fallback_icon (str): The name of the fallback icon resource to use, if needed.
|
||||
media_type_name (str): Used for identifying the MediaType.
|
||||
priority (int): Render priority over other Preview classes.
|
||||
"""
|
||||
|
||||
_fallback_icon: str = ""
|
||||
media_type_name: str
|
||||
priority: int = 50
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def icon_name(cls) -> str:
|
||||
"""Get the name of the fallback icon resource associated with this renderer."""
|
||||
return cls._fallback_icon or cls.media_type_name
|
||||
@@ -0,0 +1,52 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from PIL.Image import Image
|
||||
from PIL.Image import new as new_image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color
|
||||
|
||||
|
||||
# TODO: Split out Qt color palette stuff from anything needed by the core.
|
||||
def apply_overlay_color(image: Image, color: UiColor, theme: Theme) -> Image:
|
||||
"""Apply a color overlay effect to an image based on its color channel data.
|
||||
|
||||
Red channel for foreground, green channel for outline, none for background.
|
||||
|
||||
Args:
|
||||
image (Image.Image): The image to apply an overlay to.
|
||||
color (UiColor): The name of the ColorType color to use.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
"""
|
||||
bg_color: str = (
|
||||
get_ui_color(ColorType.DARK_ACCENT, color)
|
||||
if theme == Theme.DARK
|
||||
else get_ui_color(ColorType.PRIMARY, color)
|
||||
)
|
||||
fg_color: str = (
|
||||
get_ui_color(ColorType.PRIMARY, color)
|
||||
if theme == Theme.DARK
|
||||
else get_ui_color(ColorType.LIGHT_ACCENT, color)
|
||||
)
|
||||
ol_color: str = (
|
||||
get_ui_color(ColorType.BORDER, color)
|
||||
if theme == Theme.DARK
|
||||
else get_ui_color(ColorType.LIGHT_ACCENT, color)
|
||||
)
|
||||
|
||||
bg: Image = new_image(image.mode, image.size, color=bg_color)
|
||||
fg: Image = new_image(image.mode, image.size, color=fg_color)
|
||||
ol: Image = new_image(image.mode, image.size, color=ol_color)
|
||||
|
||||
bg.paste(fg, (0, 0), mask=image.getchannel(0))
|
||||
bg.paste(ol, (0, 0), mask=image.getchannel(1))
|
||||
|
||||
if image.mode == "RGBA":
|
||||
alpha_bg: Image = bg.copy()
|
||||
alpha_bg.convert("RGBA")
|
||||
alpha_bg.putalpha(0)
|
||||
alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3))
|
||||
bg = alpha_bg
|
||||
|
||||
return bg
|
||||
@@ -1,57 +1,37 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib
|
||||
import inspect
|
||||
import math
|
||||
import pkgutil
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
|
||||
from tagstudio.core.exceptions import NoRendererError
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories, MediaType
|
||||
from tagstudio.core.media_types import MediaTypeGroup, MediaTypes, slugify
|
||||
from tagstudio.core.query_lang.file_groups import SEARCH
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.effects import apply_overlay_color
|
||||
from tagstudio.previews.gradients import four_corner_gradient
|
||||
from tagstudio.previews.renderers.archive import (
|
||||
apple_embedded_thumb,
|
||||
archive_thumb,
|
||||
krita_thumb,
|
||||
open_doc_thumb,
|
||||
powerpoint_thumb,
|
||||
)
|
||||
from tagstudio.previews.renderers.audio import audio_album_thumb, audio_waveform_thumb
|
||||
from tagstudio.previews.renderers.blender import blender_thumb
|
||||
from tagstudio.previews.renderers.clip_studio import clip_studio_thumb
|
||||
from tagstudio.previews.renderers.ebook import epub_thumb
|
||||
from tagstudio.previews.renderers.font import font_full_preview, font_small_thumb
|
||||
from tagstudio.previews.renderers.medibang_paint import medibang_paint_thumb
|
||||
from tagstudio.previews.renderers.paint_dot_net import paint_dot_net_thumb
|
||||
from tagstudio.previews.renderers.pdf import pdf_thumb
|
||||
from tagstudio.previews.renderers.raster_image import (
|
||||
exr_image_thumb,
|
||||
raster_image_thumb,
|
||||
raw_image_thumb,
|
||||
)
|
||||
from tagstudio.previews.renderers.source_engine import vtf_thumb
|
||||
from tagstudio.previews.renderers.text import text_thumb
|
||||
from tagstudio.previews.renderers.vector_image import vector_image_thumb
|
||||
from tagstudio.previews.renderers.video import video_thumb
|
||||
from tagstudio.qt.app_settings import (
|
||||
DEFAULT_CACHED_THUMB_RES,
|
||||
MAX_CACHED_THUMB_RES,
|
||||
MIN_CACHED_THUMB_RES,
|
||||
AppSettings,
|
||||
Theme,
|
||||
)
|
||||
from tagstudio.qt.cache_manager import CacheManager
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.views.styles.palette import UI_COLORS, ColorType, UiColor
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
@@ -60,11 +40,55 @@ Image.MAX_IMAGE_PIXELS = None
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Allow user-created preview renderers from an external directory.
|
||||
def _get_preview_renderers() -> list[type[BasePreview]]:
|
||||
"""Discover all BasePreview subclasses in src/tagstudio/previews/renderers.
|
||||
|
||||
Classes are sorted by their priority (descending), falling back to alphabetical order.
|
||||
"""
|
||||
found: list[type[BasePreview]] = []
|
||||
from tagstudio.previews import renderers # pyright: ignore
|
||||
|
||||
for module_info in sorted(pkgutil.iter_modules(renderers.__path__), key=lambda m: m.name):
|
||||
module = importlib.import_module(f"{renderers.__name__}.{module_info.name}")
|
||||
for _, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if (
|
||||
issubclass(obj, BasePreview)
|
||||
and obj is not BasePreview
|
||||
and obj.__module__ == module.__name__
|
||||
):
|
||||
obj.register_types()
|
||||
found.append(obj)
|
||||
break
|
||||
|
||||
found.sort(key=lambda cls: cls.priority, reverse=True)
|
||||
return found
|
||||
|
||||
|
||||
class FileRenderer:
|
||||
"""A class for rendering image previews and thumbnails from files."""
|
||||
|
||||
rm: ResourceManager = ResourceManager()
|
||||
cached_img_ext: str = ".webp"
|
||||
_rm: ResourceManager = ResourceManager()
|
||||
_cached_img_ext: str = ".webp"
|
||||
_preview_renderers: list[type[BasePreview]] = _get_preview_renderers()
|
||||
|
||||
# Map of media group keys to preview renderer priorities.
|
||||
_media_group_priorities: dict[str, int] = {}
|
||||
for pr in _preview_renderers:
|
||||
_media_group_priorities[pr.media_type_name] = pr.priority
|
||||
|
||||
# Map of media group name keys to preferred icons declared in preview renderers.
|
||||
_media_group_icons: dict[str, str] = {}
|
||||
for pr in _preview_renderers:
|
||||
_media_group_icons[pr.media_type_name] = pr.icon_name()
|
||||
|
||||
for pr in _preview_renderers:
|
||||
logger.info(
|
||||
"[FileRenderer] Loaded Preview Renderer",
|
||||
name=pr.__name__,
|
||||
media_type=pr.media_type_name,
|
||||
priority=pr.priority,
|
||||
)
|
||||
|
||||
def __init__(self, library: Library, settings: AppSettings) -> None:
|
||||
super().__init__()
|
||||
@@ -89,26 +113,18 @@ class FileRenderer:
|
||||
url (Path): The file url to assess. "$LOADING" will return the loading graphic.
|
||||
"""
|
||||
ext = url.suffix.lower()
|
||||
types: set[MediaType] = MediaCategories.get_types(ext, mime_fallback=True)
|
||||
|
||||
# Manual icon overrides.
|
||||
if ext in {".gif", ".vtf"}:
|
||||
return MediaType.IMAGE
|
||||
elif ext in {".dll", ".pyc", ".o", ".dylib"}:
|
||||
return MediaType.PROGRAM
|
||||
elif ext in {".mscz"}: # noqa: SIM114
|
||||
return MediaType.TEXT
|
||||
|
||||
# Loop though the specific (non-IANA) categories and return the string
|
||||
# name of the first matching category found.
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if not cat.is_iana and cat.media_type in types:
|
||||
return cat.media_type.value
|
||||
|
||||
# If the type is broader (IANA registered) then search those types.
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if cat.is_iana and cat.media_type in types:
|
||||
return cat.media_type.value
|
||||
groups = MediaTypes.find(ext, SEARCH) # Fallback icons use the SEARCH context
|
||||
groups.sort( # Sort by priority and most specific dot-separated subgroup.
|
||||
key=lambda g: (
|
||||
g.key.count("."),
|
||||
self._media_group_priorities.get(g.key, BasePreview.priority),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
for group in groups:
|
||||
slug = slugify(self._media_group_icons.get(group.key, group.key))
|
||||
if self._rm.get(slug, silent_fail=True):
|
||||
return slug
|
||||
|
||||
return "file_generic"
|
||||
|
||||
@@ -354,10 +370,10 @@ class FileRenderer:
|
||||
fg: Image.Image = Image.new("RGB", size=size, color="#00FF00")
|
||||
|
||||
# Get icon by name
|
||||
icon = self.rm.get(name)
|
||||
icon = self._rm.get(name)
|
||||
assert isinstance(icon, Image.Image) or icon is None
|
||||
if not icon:
|
||||
icon = self.rm.file_generic
|
||||
icon = self._rm.file_generic
|
||||
|
||||
# Resize icon to fit icon_ratio
|
||||
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
|
||||
@@ -373,7 +389,7 @@ class FileRenderer:
|
||||
)
|
||||
|
||||
# Apply color overlay
|
||||
im = self._apply_overlay_color(im, color, theme)
|
||||
im = apply_overlay_color(im, color, theme)
|
||||
|
||||
return im
|
||||
|
||||
@@ -420,7 +436,7 @@ class FileRenderer:
|
||||
color="#000000",
|
||||
)
|
||||
# Apply color overlay
|
||||
bg = self._apply_overlay_color(im, color, theme)
|
||||
bg = apply_overlay_color(im, color, theme)
|
||||
|
||||
# Paste background color with rounded rectangle mask onto blank image
|
||||
im.paste(
|
||||
@@ -440,10 +456,10 @@ class FileRenderer:
|
||||
fg: Image.Image = Image.new("RGB", size=size, color=primary_color)
|
||||
|
||||
# Get icon by name
|
||||
icon = self.rm.get(name)
|
||||
icon = self._rm.get(name)
|
||||
assert isinstance(icon, Image.Image)
|
||||
if not icon:
|
||||
icon = self.rm.file_generic
|
||||
icon = self._rm.file_generic
|
||||
|
||||
# Resize icon to fit icon_ratio
|
||||
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
|
||||
@@ -457,48 +473,6 @@ class FileRenderer:
|
||||
|
||||
return im
|
||||
|
||||
def _apply_overlay_color(self, image: Image.Image, color: UiColor, theme: Theme) -> Image.Image:
|
||||
"""Apply a color overlay effect to an image based on its color channel data.
|
||||
|
||||
Red channel for foreground, green channel for outline, none for background.
|
||||
|
||||
Args:
|
||||
image (Image.Image): The image to apply an overlay to.
|
||||
color (UiColor): The name of the ColorType color to use.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
"""
|
||||
bg_color: str = (
|
||||
get_ui_color(ColorType.DARK_ACCENT, color)
|
||||
if theme == Theme.DARK
|
||||
else get_ui_color(ColorType.PRIMARY, color)
|
||||
)
|
||||
fg_color: str = (
|
||||
get_ui_color(ColorType.PRIMARY, color)
|
||||
if theme == Theme.DARK
|
||||
else get_ui_color(ColorType.LIGHT_ACCENT, color)
|
||||
)
|
||||
ol_color: str = (
|
||||
get_ui_color(ColorType.BORDER, color)
|
||||
if theme == Theme.DARK
|
||||
else get_ui_color(ColorType.LIGHT_ACCENT, color)
|
||||
)
|
||||
|
||||
bg: Image.Image = Image.new(image.mode, image.size, color=bg_color)
|
||||
fg: Image.Image = Image.new(image.mode, image.size, color=fg_color)
|
||||
ol: Image.Image = Image.new(image.mode, image.size, color=ol_color)
|
||||
|
||||
bg.paste(fg, (0, 0), mask=image.getchannel(0))
|
||||
bg.paste(ol, (0, 0), mask=image.getchannel(1))
|
||||
|
||||
if image.mode == "RGBA":
|
||||
alpha_bg: Image.Image = bg.copy()
|
||||
alpha_bg.convert("RGBA")
|
||||
alpha_bg.putalpha(0)
|
||||
alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3))
|
||||
bg = alpha_bg
|
||||
|
||||
return bg
|
||||
|
||||
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
|
||||
def _apply_edge(
|
||||
self,
|
||||
@@ -596,7 +570,7 @@ class FileRenderer:
|
||||
padding_factor = 18
|
||||
|
||||
im_ = im
|
||||
icon: Image.Image = self.rm.ignored
|
||||
icon: Image.Image = self._rm.ignored
|
||||
icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio)))
|
||||
im_.paste(
|
||||
im=icon.resize(
|
||||
@@ -634,7 +608,7 @@ class FileRenderer:
|
||||
mod_time = str(filepath.stat().st_mtime_ns)
|
||||
hashable_str: str = f"{str(filepath)}{mod_time}"
|
||||
hash_value = hashlib.shake_128(hashable_str.encode("utf-8")).hexdigest(8)
|
||||
file_name = Path(f"{hash_value}{FileRenderer.cached_img_ext}")
|
||||
file_name = Path(f"{hash_value}{FileRenderer._cached_img_ext}")
|
||||
image = fetch_cached_image(file_name)
|
||||
|
||||
if not image and self.settings.generate_thumbs:
|
||||
@@ -654,7 +628,7 @@ class FileRenderer:
|
||||
size=(thumb_res, thumb_res),
|
||||
dpi_scale=1,
|
||||
theme=theme,
|
||||
is_thumb=is_thumb,
|
||||
is_small=is_thumb,
|
||||
cache_filename=file_name,
|
||||
)
|
||||
|
||||
@@ -735,159 +709,56 @@ class FileRenderer:
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
theme: Theme = Theme.DARK,
|
||||
is_thumb: bool = False,
|
||||
is_small: bool = False,
|
||||
cache_filename: Path | None = None,
|
||||
) -> Image.Image | None:
|
||||
"""Render a thumbnail or preview image.
|
||||
|
||||
Args:
|
||||
cache (CacheManager | None): A cache manager instance.
|
||||
timestamp (float): The timestamp for which this job was dispatched.
|
||||
filepath (str | Path): The path of the file to render a thumbnail for.
|
||||
size (tuple[int, int]): The unmodified base size of the thumbnail.
|
||||
dpi_scale (float): The screen pixel ratio.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants.
|
||||
is_small (bool): Is this specifically a thumbnail? Use for specifying small variants.
|
||||
cache_filename (Path | None): An optional filename to use to save to the cache.
|
||||
|
||||
"""
|
||||
filepath = Path(filepath) if isinstance(filepath, str) else filepath
|
||||
scaled_size = math.ceil(max(size[0], size[1]) * dpi_scale)
|
||||
image: Image.Image | None = None
|
||||
filepath_: Path = Path(filepath)
|
||||
is_savable_type: bool = True
|
||||
|
||||
if filepath_ and filepath_.is_file():
|
||||
if filepath and filepath.is_file():
|
||||
try:
|
||||
ext: str = filepath_.suffix.lower() if filepath_.suffix else filepath_.stem.lower()
|
||||
# eBooks ===========================================================================
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.EBOOK_TYPES, mime_fallback=True
|
||||
):
|
||||
image = epub_thumb(filepath_, ext)
|
||||
# Krita ============================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.KRITA_TYPES, mime_fallback=True
|
||||
):
|
||||
image = krita_thumb(filepath_)
|
||||
# Clip Studio Paint ================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.CLIP_STUDIO_PAINT_TYPES
|
||||
):
|
||||
image = clip_studio_thumb(filepath_)
|
||||
# VTF ==============================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True
|
||||
):
|
||||
image = vtf_thumb(filepath_)
|
||||
# Images ===========================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_TYPES, mime_fallback=True
|
||||
):
|
||||
# Raw Images -------------------------------------------------------------------
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
|
||||
):
|
||||
image = raw_image_thumb(filepath_)
|
||||
# Vector Images ----------------------------------------------------------------
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True
|
||||
):
|
||||
image = vector_image_thumb(filepath_, scaled_size)
|
||||
# EXR Images -------------------------------------------------------------------
|
||||
elif ext in [".exr"]:
|
||||
image = exr_image_thumb(filepath_)
|
||||
# Normal Images ----------------------------------------------------------------
|
||||
else:
|
||||
image = raster_image_thumb(filepath_)
|
||||
# Videos ===========================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
|
||||
):
|
||||
image = video_thumb(filepath_)
|
||||
# PowerPoint =======================================================================
|
||||
elif ext in {".pptx"}:
|
||||
image = powerpoint_thumb(filepath_)
|
||||
# OpenDocument/OpenOffice ==========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.OPEN_DOCUMENT_TYPES, mime_fallback=True
|
||||
):
|
||||
image = open_doc_thumb(filepath_)
|
||||
# Apple iWork + Creator Studio =====================================================
|
||||
elif (
|
||||
MediaCategories.is_ext_in_category(ext, MediaCategories.IWORK_TYPES)
|
||||
or ext == ".pxd"
|
||||
):
|
||||
image = apple_embedded_thumb(filepath_)
|
||||
# Plain Text =======================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True
|
||||
):
|
||||
image = text_thumb(filepath_)
|
||||
# Fonts ============================================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.FONT_TYPES, mime_fallback=True
|
||||
):
|
||||
if is_thumb:
|
||||
# Short (Aa) Preview
|
||||
image = font_small_thumb(filepath_, scaled_size)
|
||||
if image is not None:
|
||||
image = self._apply_overlay_color(image, UiColor.BLUE, theme)
|
||||
else:
|
||||
# Large (Full Alphabet) Preview
|
||||
image = font_full_preview(filepath_, scaled_size)
|
||||
# Audio ========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
|
||||
):
|
||||
image = audio_album_thumb(filepath_, ext)
|
||||
if image is None:
|
||||
image = audio_waveform_thumb(filepath_, ext, scaled_size, dpi_scale)
|
||||
is_savable_type = False
|
||||
if image is not None:
|
||||
image = self._apply_overlay_color(image, UiColor.GREEN, theme)
|
||||
# Blender ======================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.BLENDER_TYPES, mime_fallback=True
|
||||
):
|
||||
image = blender_thumb(filepath_)
|
||||
# PDF ==========================================================
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.PDF_TYPES, mime_fallback=True
|
||||
):
|
||||
image = pdf_thumb(filepath_, scaled_size, ext)
|
||||
# Archives =====================================================
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.ARCHIVE_TYPES):
|
||||
image = archive_thumb(filepath_, ext=ext)
|
||||
# MDIPACK ======================================================
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.MDIPACK_TYPES):
|
||||
image = medibang_paint_thumb(filepath_)
|
||||
# Paint.NET ====================================================
|
||||
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PAINT_DOT_NET_TYPES):
|
||||
image = paint_dot_net_thumb(filepath_)
|
||||
# No Rendered Thumbnail ========================================
|
||||
if not image:
|
||||
raise NoRendererError
|
||||
ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower()
|
||||
for preview in FileRenderer._preview_renderers:
|
||||
media_type: MediaTypeGroup | None = getattr(
|
||||
MediaTypes, preview.media_type_name, None
|
||||
)
|
||||
if media_type is None:
|
||||
logger.error(
|
||||
f"[FileRenderer] "
|
||||
f"Attribute '{preview.media_type_name}' not registered with MediaTypes",
|
||||
)
|
||||
break
|
||||
|
||||
if media_type.contains(ext, RENDER):
|
||||
image = preview.render(
|
||||
filepath=filepath,
|
||||
is_small=is_small,
|
||||
theme=theme,
|
||||
size=(scaled_size, scaled_size),
|
||||
dpi_scale=dpi_scale,
|
||||
)
|
||||
break
|
||||
|
||||
if image:
|
||||
image = self._resize_image(image, (scaled_size, scaled_size))
|
||||
|
||||
if cache_filename and is_savable_type and image and cache:
|
||||
cache.save_image(image, cache_filename, mode="RGBA")
|
||||
|
||||
except (
|
||||
AssertionError,
|
||||
ChildProcessError,
|
||||
DecompressionBombError,
|
||||
UnidentifiedImageError,
|
||||
ValueError,
|
||||
) as e:
|
||||
logger.error(
|
||||
"[FileRenderer] Couldn't render thumbnail",
|
||||
filepath=filepath,
|
||||
error=type(e).__name__,
|
||||
)
|
||||
image = None
|
||||
except NoRendererError:
|
||||
except Exception as e:
|
||||
logger.error("[FileRenderer] Couldn't render thumbnail", filepath=filepath, error=e)
|
||||
image = None
|
||||
|
||||
return image
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.archive import archive_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleEmbeddedPreview(BasePreview):
|
||||
media_type_name = "apple.embedded"
|
||||
|
||||
image_names: list[str] = [
|
||||
"preview.jpg",
|
||||
"QuickLook/Preview.heic",
|
||||
"QuickLook/Thumbnail.jpg",
|
||||
"QuickLook/Thumbnail.heic",
|
||||
"QuickLook/Thumbnail.webp",
|
||||
"QuickLook/Icon.webp",
|
||||
]
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("apple.embedded", ".pxd", RENDER)
|
||||
MediaTypes.register("apple.embedded", ".pages", RENDER)
|
||||
MediaTypes.register("apple.embedded", ".numbers", RENDER)
|
||||
MediaTypes.register("apple.embedded", ".key", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return cls.apple_embedded_thumb(filepath)
|
||||
|
||||
@classmethod
|
||||
def apple_embedded_thumb(cls, filepath: Path) -> Image | None:
|
||||
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
|
||||
return archive_thumb(filepath, cls.image_names)
|
||||
@@ -6,16 +6,18 @@ import tarfile
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Literal, override
|
||||
|
||||
import py7zr
|
||||
import py7zr.io
|
||||
import rarfile
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.raster_image import image_from_bytes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -23,6 +25,34 @@ logger = structlog.get_logger(__name__)
|
||||
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
|
||||
|
||||
|
||||
class ArchivePreview(BasePreview):
|
||||
media_type_name = "archive"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
# NOTE: Filetype equivalents (i.e. ".tar.gz" == ".tgz") are already declared internally.
|
||||
MediaTypes.register("archive", ".7z", RENDER)
|
||||
MediaTypes.register("archive", ".gz", RENDER)
|
||||
MediaTypes.register("archive", ".rar", RENDER)
|
||||
MediaTypes.register("archive", ".s7z", RENDER)
|
||||
MediaTypes.register("archive", ".tar", RENDER)
|
||||
MediaTypes.register("archive", ".zip", RENDER)
|
||||
MediaTypes.register("archive", ".tar.gz", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return archive_thumb(filepath)
|
||||
|
||||
|
||||
class SevenZipFile(py7zr.SevenZipFile):
|
||||
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
|
||||
|
||||
@@ -60,7 +90,7 @@ class TarFile:
|
||||
self.tar.__exit__(*args)
|
||||
|
||||
|
||||
def open_archive(filepath: Path, ext: str = "") -> Archive:
|
||||
def open_archive(filepath: Path) -> Archive:
|
||||
"""Open an archive with its corresponding archiver.
|
||||
|
||||
Args:
|
||||
@@ -70,6 +100,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive:
|
||||
Returns:
|
||||
Archive: The opened archive.
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
archiver: type[Archive] = zipfile.ZipFile
|
||||
if ext in {".7z", ".cb7", ".s7z"}:
|
||||
archiver = SevenZipFile
|
||||
@@ -80,7 +111,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive:
|
||||
return archiver(filepath, "r")
|
||||
|
||||
|
||||
def first_image_in_archive(archive: Archive) -> Image.Image | None:
|
||||
def first_image_in_archive(archive: Archive) -> Image | None:
|
||||
"""Find and extract the first renderable image in the archive.
|
||||
|
||||
Args:
|
||||
@@ -91,7 +122,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None:
|
||||
"""
|
||||
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
|
||||
ext = Path(file_name).suffix
|
||||
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
|
||||
if MediaTypes.image_raster.contains(ext, RENDER):
|
||||
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
|
||||
return image_from_bytes(BytesIO(image_data))
|
||||
|
||||
@@ -101,8 +132,7 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None:
|
||||
def archive_thumb(
|
||||
filepath: Path,
|
||||
image_names: list[Path] | list[str] | None = None,
|
||||
ext: str = "",
|
||||
) -> Image.Image | None:
|
||||
) -> Image | None:
|
||||
"""Extract an embedded preview image from an archive.
|
||||
|
||||
Args:
|
||||
@@ -114,7 +144,7 @@ def archive_thumb(
|
||||
Image: The first image found in the archive.
|
||||
"""
|
||||
try:
|
||||
with open_archive(filepath, ext) as archive:
|
||||
with open_archive(filepath) 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)
|
||||
@@ -131,34 +161,3 @@ def archive_thumb(
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,149 +1,196 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import math
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
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 PIL import ImageDraw
|
||||
from PIL.Image import Image, Resampling
|
||||
from PIL.Image import new as new_image
|
||||
from PIL.Image import open as open_image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.effects import apply_overlay_color
|
||||
from tagstudio.previews.vendored.pydub.audio_segment import (
|
||||
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from tagstudio.qt.views.styles.palette import UiColor
|
||||
|
||||
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.
|
||||
class AudioPreview(BasePreview):
|
||||
media_type_name = "audio"
|
||||
priority = 70
|
||||
|
||||
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
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
# NOTE: Filetype equivalents (i.e. ".aif" == ".aif") are already declared internally.
|
||||
MediaTypes.register("audio", ".aac", RENDER)
|
||||
MediaTypes.register("audio", ".aif", RENDER)
|
||||
MediaTypes.register("audio", ".aifc", RENDER)
|
||||
MediaTypes.register("audio", ".caf", RENDER)
|
||||
MediaTypes.register("audio", ".flac", RENDER)
|
||||
MediaTypes.register("audio", ".m4a", RENDER)
|
||||
MediaTypes.register("audio", ".m4p", RENDER)
|
||||
MediaTypes.register("audio", ".m4r", RENDER)
|
||||
MediaTypes.register("audio", ".mp3", RENDER)
|
||||
MediaTypes.register("audio", ".ogg", RENDER)
|
||||
MediaTypes.register("audio", ".wav", RENDER)
|
||||
MediaTypes.register("audio", ".wma", RENDER)
|
||||
|
||||
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,
|
||||
mp4.MP4MetadataError,
|
||||
mp4.MP4StreamInfoError,
|
||||
MutagenError,
|
||||
) as e:
|
||||
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
|
||||
return image
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
|
||||
return cls.audio_album_thumb(filepath) or cls.audio_waveform_thumb(
|
||||
filepath, theme, size, dpi_scale
|
||||
)
|
||||
|
||||
def audio_waveform_thumb(
|
||||
filepath: Path, ext: str, size: int, pixel_ratio: float
|
||||
) -> Image.Image | None:
|
||||
"""Render a waveform image from an audio file.
|
||||
@staticmethod
|
||||
def audio_album_thumb(filepath: Path) -> 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 ".").
|
||||
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
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
image: Image | None = None
|
||||
ext = filepath.suffix.lower()
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError
|
||||
|
||||
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)
|
||||
artwork = None
|
||||
if ext in {".mp3", ".aif", ".aiff"}:
|
||||
id3_tags: id3.ID3 = id3.ID3(filepath)
|
||||
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
|
||||
if id3_covers:
|
||||
artwork = open_image(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 = open_image(BytesIO(flac_covers[0].data))
|
||||
elif ext in {".mp4", ".m4a", ".aac", ".alac"}:
|
||||
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
|
||||
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
|
||||
if mp4_covers:
|
||||
artwork = open_image(BytesIO(mp4_covers[0]))
|
||||
if artwork:
|
||||
image = artwork
|
||||
except (
|
||||
FileNotFoundError,
|
||||
id3.ID3NoHeaderError,
|
||||
mp4.MP4MetadataError,
|
||||
mp4.MP4StreamInfoError,
|
||||
MutagenError,
|
||||
) as e:
|
||||
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
|
||||
return image
|
||||
|
||||
count: int = 0
|
||||
maximum_item: int = 0
|
||||
max_array: list[int] = []
|
||||
highest_line: int = 0
|
||||
@staticmethod
|
||||
def audio_waveform_thumb(
|
||||
filepath: Path, theme: Theme, size: tuple[int, int], dpi_scale: float
|
||||
) -> Image | None:
|
||||
"""Render a waveform image from an audio file.
|
||||
|
||||
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)
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
theme (Theme): The system color theme.
|
||||
size (int): The size of the thumbnail.
|
||||
dpi_scale (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[0] * base_scale # TODO: Allow for non-square sizes
|
||||
allow_small_min: bool = False
|
||||
im: Image | None = None
|
||||
|
||||
if maximum_item > highest_line:
|
||||
highest_line = maximum_item
|
||||
try:
|
||||
bar_count: int = min(math.floor((size[0] // dpi_scale) / 5), 64)
|
||||
audio = AudioSegment.from_file(filepath, filepath.suffix.lower()[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)
|
||||
|
||||
maximum_item = 0
|
||||
count = 1
|
||||
count: int = 0
|
||||
maximum_item: int = 0
|
||||
max_array: list[int] = []
|
||||
highest_line: int = 0
|
||||
|
||||
line_ratio = max(highest_line / bar_height, 1)
|
||||
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)
|
||||
|
||||
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
|
||||
draw = ImageDraw.Draw(im)
|
||||
if maximum_item > highest_line:
|
||||
highest_line = maximum_item
|
||||
|
||||
current_x = bar_margin
|
||||
for item in max_array:
|
||||
item_height = item / line_ratio
|
||||
maximum_item = 0
|
||||
count = 1
|
||||
|
||||
# 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)
|
||||
line_ratio = max(highest_line / bar_height, 1)
|
||||
|
||||
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
|
||||
im = new_image("RGB", (size_scaled, size_scaled), color="#000000")
|
||||
draw = ImageDraw.Draw(im)
|
||||
|
||||
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 = bar_margin
|
||||
for item in max_array:
|
||||
item_height = item / line_ratio
|
||||
|
||||
current_x = current_x + line_width + bar_margin
|
||||
# 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)
|
||||
|
||||
im.resize((size, size), Image.Resampling.BILINEAR)
|
||||
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
|
||||
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),
|
||||
)
|
||||
|
||||
return im
|
||||
current_x = current_x + line_width + bar_margin
|
||||
|
||||
im.resize(size, Resampling.BILINEAR)
|
||||
im = apply_overlay_color(im, UiColor.GREEN, theme)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
|
||||
@@ -1,34 +1,58 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PIL.Image import Image
|
||||
from PIL.Image import new as new_image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.vendored.blender_thumbnailer import blend_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def blender_thumb(filepath: Path) -> Image.Image | None:
|
||||
class BlenderPreview(BasePreview):
|
||||
media_type_name = "blender"
|
||||
priority = 40
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
# NOTE: Filetype equivalents (i.e. ".blend1" == ".blend32") are already declared internally.
|
||||
MediaTypes.register("blender", ".blend", RENDER)
|
||||
MediaTypes.register("blender", ".blend1", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return _blender_thumb(filepath, theme)
|
||||
|
||||
|
||||
def _blender_thumb(filepath: Path, theme: Theme) -> Image | None:
|
||||
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
theme (Theme): The system color theme.
|
||||
"""
|
||||
bg_color: str = (
|
||||
"#1e1e1e"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#FFFFFF"
|
||||
)
|
||||
im: Image.Image | None = None
|
||||
bg_color: str = "#1e1e1e" if theme == Theme.DARK else "#FFFFFF"
|
||||
im: 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 = new_image("RGB", blend_image.size, color=bg_color)
|
||||
bg.paste(blend_image, mask=blend_image.getchannel(3))
|
||||
im = bg
|
||||
else:
|
||||
|
||||
@@ -5,14 +5,41 @@
|
||||
import sqlite3
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image
|
||||
from PIL.Image import open as open_image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
|
||||
class ClipStudioPaintPreview(BasePreview):
|
||||
media_type_name = "clip_studio_paint"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("clip_studio_paint", ".clip", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return clip_studio_thumb(filepath)
|
||||
|
||||
|
||||
def clip_studio_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
|
||||
|
||||
Args:
|
||||
@@ -21,7 +48,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None:
|
||||
Returns:
|
||||
Image: The embedded thumbnail, if extractable.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
blob = f.read()
|
||||
@@ -33,7 +60,7 @@ def clip_studio_thumb(filepath: Path) -> Image.Image | None:
|
||||
conn.deserialize(blob[sqlite_index:])
|
||||
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
|
||||
if thumbnail:
|
||||
im = Image.open(BytesIO(thumbnail[0]))
|
||||
im = open_image(BytesIO(thumbnail[0]))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL.Image import Image
|
||||
from pygments.style import Style
|
||||
from pygments.token import (
|
||||
Comment,
|
||||
Error,
|
||||
Generic,
|
||||
Keyword,
|
||||
Literal,
|
||||
Name,
|
||||
Number,
|
||||
Operator,
|
||||
Other,
|
||||
Punctuation,
|
||||
String,
|
||||
Text,
|
||||
Whitespace,
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.text import text_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CodePreview(BasePreview):
|
||||
media_type_name = "code"
|
||||
priority = 60
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
# NOTE: Filetype equivalents (i.e. ".ini" == ".inf") are already declared internally.
|
||||
# CSS
|
||||
MediaTypes.register("code", ".css", RENDER)
|
||||
MediaTypes.register("code", ".less", RENDER)
|
||||
MediaTypes.register("code", ".qss", RENDER)
|
||||
MediaTypes.register("code", ".sass", RENDER)
|
||||
MediaTypes.register("code", ".scss", RENDER)
|
||||
MediaTypes.register("code", ".styl", RENDER)
|
||||
|
||||
# C
|
||||
MediaTypes.register("code", ".c", RENDER)
|
||||
MediaTypes.register("code", ".h", RENDER)
|
||||
|
||||
# C++
|
||||
MediaTypes.register("code", ".cpp", RENDER)
|
||||
MediaTypes.register("code", ".hpp", RENDER)
|
||||
|
||||
# C#
|
||||
MediaTypes.register("code", ".cs", RENDER)
|
||||
|
||||
# D
|
||||
MediaTypes.register("code", ".d", RENDER)
|
||||
|
||||
# HTML
|
||||
MediaTypes.register("code", ".html", RENDER)
|
||||
|
||||
# JavaScript
|
||||
MediaTypes.register("code", ".cjs", RENDER)
|
||||
MediaTypes.register("code", ".js", RENDER)
|
||||
MediaTypes.register("code", ".jsx", RENDER)
|
||||
MediaTypes.register("code", ".mjs", RENDER)
|
||||
|
||||
# JSON
|
||||
MediaTypes.register("code", ".json", RENDER)
|
||||
|
||||
# Lua
|
||||
MediaTypes.register("code", ".lua", RENDER)
|
||||
|
||||
# Markdown
|
||||
MediaTypes.register("code", ".md", RENDER)
|
||||
|
||||
# Nix
|
||||
MediaTypes.register("code", ".nix", RENDER)
|
||||
|
||||
# PHP
|
||||
MediaTypes.register("code", ".php", RENDER)
|
||||
|
||||
# Qt
|
||||
MediaTypes.register("code", ".qml", RENDER)
|
||||
MediaTypes.register("code", ".qrc", RENDER)
|
||||
|
||||
# Rust
|
||||
MediaTypes.register("code", ".rs", RENDER)
|
||||
|
||||
# TCL
|
||||
MediaTypes.register("code", ".tcl", RENDER)
|
||||
|
||||
# Python
|
||||
MediaTypes.register("code", ".ipynb", RENDER)
|
||||
MediaTypes.register("code", ".py", RENDER)
|
||||
MediaTypes.register("code", ".pyi", RENDER)
|
||||
MediaTypes.register("code", ".sip", RENDER)
|
||||
|
||||
# Shaders
|
||||
MediaTypes.register("code", ".effect", RENDER)
|
||||
MediaTypes.register("code", ".frag", RENDER)
|
||||
MediaTypes.register("code", ".fsh", RENDER)
|
||||
MediaTypes.register("code", ".glsl", RENDER)
|
||||
MediaTypes.register("code", ".shader", RENDER)
|
||||
MediaTypes.register("code", ".vert", RENDER)
|
||||
MediaTypes.register("code", ".vsh", RENDER)
|
||||
|
||||
# Shell Script
|
||||
MediaTypes.register("code", ".bat", RENDER)
|
||||
MediaTypes.register("code", ".csh", RENDER)
|
||||
MediaTypes.register("code", ".fish", RENDER)
|
||||
MediaTypes.register("code", ".nu", RENDER)
|
||||
MediaTypes.register("code", ".ps1", RENDER)
|
||||
MediaTypes.register("code", ".sh", RENDER)
|
||||
MediaTypes.register("code", "activate", RENDER)
|
||||
|
||||
# Shortcuts
|
||||
MediaTypes.register("code", ".desktop", RENDER)
|
||||
MediaTypes.register("code", ".lnk", RENDER)
|
||||
MediaTypes.register("code", ".url", RENDER)
|
||||
|
||||
# TOML
|
||||
MediaTypes.register("code", ".ini", RENDER)
|
||||
MediaTypes.register("code", ".toml", RENDER)
|
||||
|
||||
# TypeScript
|
||||
MediaTypes.register("code", ".cts", RENDER)
|
||||
MediaTypes.register("code", ".ts", RENDER)
|
||||
MediaTypes.register("code", ".mts", RENDER)
|
||||
MediaTypes.register("code", ".tsx", RENDER)
|
||||
|
||||
# Valve Source Engine
|
||||
MediaTypes.register("code", ".fgd", RENDER)
|
||||
MediaTypes.register("code", ".gi", RENDER)
|
||||
MediaTypes.register("code", ".kv3", RENDER)
|
||||
MediaTypes.register("code", ".nut", RENDER)
|
||||
MediaTypes.register("code", ".vcfg", RENDER)
|
||||
MediaTypes.register("code", ".vdf", RENDER)
|
||||
MediaTypes.register("code", ".vqlayout", RENDER)
|
||||
MediaTypes.register("code", ".vsc", RENDER)
|
||||
MediaTypes.register("code", ".vsnd_template", RENDER)
|
||||
|
||||
# XML
|
||||
MediaTypes.register("code", ".xml", RENDER)
|
||||
|
||||
# YAML
|
||||
MediaTypes.register("code", ".yaml", RENDER)
|
||||
|
||||
# Misc
|
||||
MediaTypes.register("code", ".cfg", RENDER)
|
||||
MediaTypes.register("code", ".conf", RENDER)
|
||||
MediaTypes.register("code", ".config", RENDER)
|
||||
MediaTypes.register("code", ".csv", RENDER)
|
||||
MediaTypes.register("code", ".gitignore", RENDER)
|
||||
MediaTypes.register("code", ".lock", RENDER)
|
||||
MediaTypes.register("code", ".log", RENDER)
|
||||
MediaTypes.register("code", ".meta", RENDER)
|
||||
MediaTypes.register("code", ".patch", RENDER)
|
||||
MediaTypes.register("code", ".pkginfo", RENDER)
|
||||
MediaTypes.register("code", ".plist", RENDER)
|
||||
MediaTypes.register("code", ".prefs", RENDER)
|
||||
MediaTypes.register("code", ".spec", RENDER)
|
||||
MediaTypes.register("code", ".tex", RENDER)
|
||||
MediaTypes.register("code", ".theme", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return text_thumb(filepath, size, CodeStyle)
|
||||
|
||||
|
||||
class CodeStyle(Style):
|
||||
# TODO: Use different syntax highlighting for different filetypes.
|
||||
background = "#111111"
|
||||
foreground = "#f8f8f2"
|
||||
selection = "#44475a"
|
||||
comment = "#6272a4"
|
||||
cyan = "#8be9fd"
|
||||
green = "#50fa7b"
|
||||
orange = "#ffb86c"
|
||||
pink = "#ff79c6"
|
||||
purple = "#bd93f9"
|
||||
red = "#ff5555"
|
||||
yellow = "#f1fa8c"
|
||||
deletion = "#8b080b"
|
||||
|
||||
background_color = background
|
||||
highlight_color = selection
|
||||
line_number_color = yellow
|
||||
line_number_background_color = selection
|
||||
line_number_special_color = green
|
||||
line_number_special_background_color = comment
|
||||
|
||||
styles = {
|
||||
Whitespace: foreground,
|
||||
Comment: comment,
|
||||
Comment.Preproc: pink,
|
||||
Generic: foreground,
|
||||
Generic.Deleted: deletion,
|
||||
Generic.Emph: "underline",
|
||||
Generic.Heading: "bold",
|
||||
Generic.Inserted: "bold",
|
||||
Generic.Output: selection,
|
||||
Generic.EmphStrong: "underline",
|
||||
Generic.Subheading: "bold",
|
||||
Error: foreground,
|
||||
Keyword: pink,
|
||||
Keyword.Constant: pink,
|
||||
Keyword.Declaration: cyan + " italic",
|
||||
Keyword.Type: cyan,
|
||||
Literal: foreground,
|
||||
Name: foreground,
|
||||
Name.Attribute: green,
|
||||
Name.Builtin: cyan + " italic",
|
||||
Name.Builtin.Pseudo: foreground,
|
||||
Name.Class: green,
|
||||
Name.Function: green,
|
||||
Name.Label: cyan + " italic",
|
||||
Name.Tag: pink,
|
||||
Name.Variable: cyan + " italic",
|
||||
Number: orange,
|
||||
Operator: pink,
|
||||
Other: foreground,
|
||||
Punctuation: foreground,
|
||||
String: purple,
|
||||
Text: foreground,
|
||||
}
|
||||
@@ -5,20 +5,59 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.archive import Archive, first_image_in_archive, open_archive
|
||||
from tagstudio.previews.renderers.raster_image import image_from_bytes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
|
||||
class EbookPreview(BasePreview):
|
||||
media_type_name = "ebook"
|
||||
priority = 40
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("ebook", ".azw", RENDER)
|
||||
MediaTypes.register("ebook", ".azw3", RENDER)
|
||||
MediaTypes.register("ebook", ".cb7", RENDER)
|
||||
MediaTypes.register("ebook", ".cba", RENDER)
|
||||
MediaTypes.register("ebook", ".cbr", RENDER)
|
||||
MediaTypes.register("ebook", ".cbt", RENDER)
|
||||
MediaTypes.register("ebook", ".cbz", RENDER)
|
||||
MediaTypes.register("ebook", ".djvu", RENDER)
|
||||
MediaTypes.register("ebook", ".epub", RENDER)
|
||||
MediaTypes.register("ebook", ".fb2", RENDER)
|
||||
MediaTypes.register("ebook", ".ibook", RENDER)
|
||||
MediaTypes.register("ebook", ".kfx", RENDER)
|
||||
MediaTypes.register("ebook", ".lit", RENDER)
|
||||
MediaTypes.register("ebook", ".mobi", RENDER)
|
||||
MediaTypes.register("ebook", ".prc", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return epub_thumb(filepath)
|
||||
|
||||
|
||||
def epub_thumb(filepath: Path) -> Image | None:
|
||||
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
|
||||
|
||||
Args:
|
||||
@@ -29,14 +68,14 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
|
||||
Image: The cover specified in ComicInfo.xml,
|
||||
the first image found in the ePub file, or None by default.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
try:
|
||||
with open_archive(filepath, ext) as archive:
|
||||
with open_archive(filepath) 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")
|
||||
im = cover_from_comic_info(archive, comic_info, "FrontCover")
|
||||
if not im:
|
||||
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
|
||||
im = cover_from_comic_info(archive, comic_info, "InnerCover")
|
||||
|
||||
if not im:
|
||||
im = first_image_in_archive(archive)
|
||||
@@ -46,9 +85,7 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
|
||||
return im
|
||||
|
||||
|
||||
def _cover_from_comic_info(
|
||||
archive: Archive, comic_info: Element, cover_type: str
|
||||
) -> Image.Image | None:
|
||||
def cover_from_comic_info(archive: Archive, comic_info: Element, cover_type: str) -> Image | None:
|
||||
"""Extract the cover specified in ComicInfo.xml.
|
||||
|
||||
Args:
|
||||
@@ -59,14 +96,14 @@ def _cover_from_comic_info(
|
||||
Returns:
|
||||
Image: The cover specified in ComicInfo.xml.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: 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):
|
||||
if MediaTypes.image_raster.contains(ext, RENDER):
|
||||
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
|
||||
im = image_from_bytes(BytesIO(image_data))
|
||||
|
||||
|
||||
@@ -4,37 +4,73 @@
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from typing import cast, override
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import ImageDraw, ImageFont
|
||||
from PIL.Image import Image, Resampling, fromarray
|
||||
from PIL.Image import new as new_image
|
||||
|
||||
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.effects import apply_overlay_color
|
||||
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
|
||||
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.views.styles.palette import UiColor
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
|
||||
class FontPreview(BasePreview):
|
||||
media_type_name = "font"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("font", ".otf", RENDER)
|
||||
MediaTypes.register("font", ".ttc", RENDER)
|
||||
MediaTypes.register("font", ".ttf", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return (
|
||||
font_small_thumb(filepath, theme, size)
|
||||
if is_small
|
||||
else font_full_preview(filepath, size)
|
||||
)
|
||||
|
||||
|
||||
def font_small_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> Image | None:
|
||||
"""Render a small font preview ("Aa") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
theme (Theme): The system color theme.
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
# TODO: Support for non-square images
|
||||
im: Image | None = None
|
||||
try:
|
||||
bg = Image.new("RGB", (size, size), color="#000000")
|
||||
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
|
||||
bg = new_image("RGB", size, color="#000000")
|
||||
raw = new_image("RGB", (size[0] * 3, size[1] * 3), color="#000000")
|
||||
draw = ImageDraw.Draw(raw)
|
||||
font = ImageFont.truetype(filepath, size=size)
|
||||
font = ImageFont.truetype(filepath, size=size[0])
|
||||
# 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),
|
||||
(size[0] // 8, size[1] // 8),
|
||||
"Aa",
|
||||
font=font,
|
||||
fill="#FF0000",
|
||||
@@ -51,34 +87,37 @@ def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
|
||||
row.argmax() : m - row[::-1].argmax(),
|
||||
col.argmax() : n - col[::-1].argmax(),
|
||||
]
|
||||
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
|
||||
cropped_im: Image = fromarray(cropped_data, "RGB")
|
||||
|
||||
margin: int = math.ceil(size // 16)
|
||||
margin: int = math.ceil(size[0] // 16)
|
||||
|
||||
orig_x, orig_y = cropped_im.size
|
||||
new_x, new_y = (size, size)
|
||||
new_x, new_y = size
|
||||
if orig_x > orig_y:
|
||||
new_x = size
|
||||
new_y = math.ceil(size * (orig_y / orig_x))
|
||||
new_x = size[0]
|
||||
new_y = math.ceil(size[1] * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = size
|
||||
new_x = math.ceil(size * (orig_x / orig_y))
|
||||
new_y = size[1]
|
||||
new_x = math.ceil(size[0] * (orig_x / orig_y))
|
||||
|
||||
cropped_im = cropped_im.resize(
|
||||
size=(new_x - (margin * 2), new_y - (margin * 2)),
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
resample=Resampling.BILINEAR,
|
||||
)
|
||||
bg.paste(
|
||||
cropped_im,
|
||||
box=(margin, margin + ((size - new_y) // 2)),
|
||||
box=(margin, margin + ((size[1] - new_y) // 2)),
|
||||
)
|
||||
im = bg
|
||||
im = apply_overlay_color(im, UiColor.BLUE, theme)
|
||||
|
||||
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:
|
||||
def font_full_preview(filepath: Path, size: tuple[int, int]) -> Image | None:
|
||||
"""Render a large font preview ("Alphabet") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
@@ -87,21 +126,25 @@ def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
|
||||
"""
|
||||
# Scale the sample font sizes to the preview image
|
||||
# resolution,assuming the sizes are tuned for 256px.
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
# TODO: Support for non-square images
|
||||
try:
|
||||
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
|
||||
bg = Image.new("RGBA", (size, size), color="#00000000")
|
||||
scaled_sizes: list[int] = [math.floor(x * (size[0] / 256)) for x in FONT_SAMPLE_SIZES]
|
||||
bg = new_image("RGBA", 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)
|
||||
text_wrapped: str = wrap_full_text(
|
||||
FONT_SAMPLE_TEXT, font=font, width=size[0], 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]
|
||||
# TODO: Separate from any Qt stuff
|
||||
im = auto_theme_overlay(bg, use_alpha=False)
|
||||
except OSError as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.archive import archive_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class KritaPreview(BasePreview):
|
||||
media_type_name = "krita"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("krita", ".kra", RENDER)
|
||||
MediaTypes.register("krita", ".krz", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return krita_thumb(filepath)
|
||||
|
||||
|
||||
def krita_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract and render a thumbnail for a Krita file."""
|
||||
image_names = ["preview.png"]
|
||||
return archive_thumb(filepath, image_names)
|
||||
@@ -7,16 +7,41 @@ import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image, frombytes
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
|
||||
class MediBangPaintPreview(BasePreview):
|
||||
media_type_name = "medibang_paint"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("medibang_paint", ".mdp", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return medibang_paint_thumb(filepath)
|
||||
|
||||
|
||||
def medibang_paint_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract the thumbnail from a .mdp file.
|
||||
|
||||
Args:
|
||||
@@ -25,7 +50,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
|
||||
Returns:
|
||||
Image: The embedded thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
magic = struct.unpack("<7sx", f.read(8))[0]
|
||||
@@ -50,7 +75,7 @@ def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
|
||||
if pac_header[2] == 1:
|
||||
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
|
||||
|
||||
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
|
||||
im = frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.archive import archive_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OpenDocumentPreview(BasePreview):
|
||||
media_type_name = "open_document"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("open_document", ".fodg", RENDER)
|
||||
MediaTypes.register("open_document", ".fodp", RENDER)
|
||||
MediaTypes.register("open_document", ".fods", RENDER)
|
||||
MediaTypes.register("open_document", ".fodt", RENDER)
|
||||
MediaTypes.register("open_document", ".mscz", RENDER)
|
||||
MediaTypes.register("open_document", ".odf", RENDER)
|
||||
MediaTypes.register("open_document", ".odg", RENDER)
|
||||
MediaTypes.register("open_document", ".odp", RENDER)
|
||||
MediaTypes.register("open_document", ".ods", RENDER)
|
||||
MediaTypes.register("open_document", ".odt", RENDER)
|
||||
MediaTypes.register("open_document", ".ora", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return open_doc_thumb(filepath)
|
||||
|
||||
|
||||
def open_doc_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract and render a thumbnail for an OpenDocument file."""
|
||||
image_names = ["Thumbnails/thumbnail.png"]
|
||||
return archive_thumb(filepath, image_names)
|
||||
@@ -7,14 +7,42 @@ import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image
|
||||
from PIL.Image import new as new_image
|
||||
from PIL.Image import open as open_image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
|
||||
class PaintDotNetPreview(BasePreview):
|
||||
media_type_name = "paint_dot_net"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("paint_dot_net", ".pdn", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return paint_dot_net_thumb(filepath)
|
||||
|
||||
|
||||
def paint_dot_net_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract the base64-encoded thumbnail from a .pdn file header.
|
||||
|
||||
Args:
|
||||
@@ -23,7 +51,7 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
|
||||
Returns:
|
||||
Image: the decoded PNG thumbnail or None by default.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
with open(filepath, "rb") as f:
|
||||
try:
|
||||
# First 4 bytes are the magic number
|
||||
@@ -39,9 +67,9 @@ def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
|
||||
encoded_png = thumb_element.get("png")
|
||||
if encoded_png:
|
||||
decoded_png = base64.b64decode(encoded_png)
|
||||
im = Image.open(BytesIO(decoded_png))
|
||||
im = open_image(BytesIO(decoded_png))
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg = new_image("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,26 +7,53 @@
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image
|
||||
from PIL.Image import open as open_image
|
||||
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.qt.views.styles.image_effects import replace_transparent_pixels
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
|
||||
class PdfPreview(BasePreview):
|
||||
media_type_name = "pdf"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("pdf", ".pdf", RENDER)
|
||||
MediaTypes.register("pdf", ".ai", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return pdf_thumb(filepath, size)
|
||||
|
||||
|
||||
def pdf_thumb(filepath: Path, size: tuple[int, int]) -> 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
|
||||
im: Image | None = None
|
||||
|
||||
file: QFile = QFile(filepath)
|
||||
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
|
||||
@@ -39,12 +66,13 @@ def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
|
||||
# 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()
|
||||
# TODO: Make compatible with non-square images
|
||||
if ratio_hw >= 1:
|
||||
page_size *= size / page_size.height()
|
||||
page_size *= size[0] / page_size.height()
|
||||
else:
|
||||
page_size *= size / page_size.width()
|
||||
page_size *= size[0] / 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()
|
||||
@@ -59,7 +87,7 @@ def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
|
||||
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
|
||||
try:
|
||||
q_image.save(buffer, "PNG") # pyright: ignore
|
||||
im = Image.open(BytesIO(buffer.buffer().data()))
|
||||
im = open_image(BytesIO(buffer.buffer().data()))
|
||||
finally:
|
||||
buffer.close()
|
||||
# Replace transparent pixels with white (otherwise Background defaults to transparent)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.renderers.archive import archive_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PowerPointPreview(BasePreview):
|
||||
_fallback_icon = "presentation"
|
||||
media_type_name = "microsoft.office.powerpoint"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("microsoft.office.powerpoint", ".pptx", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return powerpoint_thumb(filepath)
|
||||
|
||||
|
||||
def powerpoint_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
|
||||
image_names = ["docProps/thumbnail.jpeg"]
|
||||
return archive_thumb(filepath, image_names)
|
||||
@@ -1,24 +1,25 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PIL import ImageOps, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError, Image, fromarray
|
||||
from PIL.Image import new as new_image
|
||||
from PIL.Image import open as open_image
|
||||
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
|
||||
from rawpy import (
|
||||
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
|
||||
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -31,33 +32,73 @@ register_heif_opener()
|
||||
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||
|
||||
|
||||
def raster_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
class RasterImagePreview(BasePreview):
|
||||
media_type_name = "image.raster"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
# NOTE: Filetype equivalents (i.e. ".jpg" == ".jpeg") are already declared internally.
|
||||
MediaTypes.register("image.raster", ".apng", RENDER)
|
||||
MediaTypes.register("image.raster", ".avif", RENDER)
|
||||
MediaTypes.register("image.raster", ".bmp", RENDER)
|
||||
MediaTypes.register("image.raster", ".png", RENDER)
|
||||
MediaTypes.register("image.raster", ".exr", RENDER)
|
||||
MediaTypes.register("image.raster", ".gif", RENDER)
|
||||
MediaTypes.register("image.raster", ".jxl", RENDER)
|
||||
MediaTypes.register("image.raster", ".psd", RENDER)
|
||||
MediaTypes.register("image.raster", ".webp", RENDER)
|
||||
MediaTypes.register("image.raster", ".heif", RENDER)
|
||||
MediaTypes.register("image.raster", ".jpg2", RENDER)
|
||||
MediaTypes.register("image.raster", ".jpeg", RENDER)
|
||||
MediaTypes.register("image.raster", ".tiff", RENDER)
|
||||
MediaTypes.register("image.raster", ".icns", RENDER)
|
||||
MediaTypes.register("image.raster", ".ico", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return raster_image_thumb(filepath)
|
||||
|
||||
|
||||
def raster_image_thumb(filepath: Path) -> Image | None:
|
||||
"""Render a thumbnail for a standard image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
try:
|
||||
if filepath.suffix.lower() == ".exr":
|
||||
return exr_image_thumb(filepath)
|
||||
|
||||
with filepath.open("rb") as file:
|
||||
im = image_from_bytes(BytesIO(file.read()))
|
||||
except (
|
||||
FileNotFoundError,
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
FileNotFoundError,
|
||||
NotImplementedError,
|
||||
OSError,
|
||||
UnidentifiedImageError,
|
||||
) 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:
|
||||
def exr_image_thumb(filepath: Path) -> Image | None:
|
||||
"""Render a thumbnail for a EXR image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: 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)
|
||||
@@ -69,11 +110,11 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
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")
|
||||
im = fromarray(array, mode="RGBA")
|
||||
|
||||
# Paste solid background
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg = new_image("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
|
||||
@@ -82,32 +123,7 @@ def exr_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
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:
|
||||
def image_from_bytes(image_data: BytesIO) -> Image:
|
||||
"""Load a raster image and add a background if it's transparent.
|
||||
|
||||
Args:
|
||||
@@ -116,11 +132,11 @@ def image_from_bytes(image_data: BytesIO) -> Image.Image:
|
||||
Returns:
|
||||
Image.Image: The loaded raster image, with a background if needed.
|
||||
"""
|
||||
im: Image.Image = Image.open(image_data)
|
||||
im: Image = open_image(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 = new_image("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
return unwrap(ImageOps.exif_transpose(im))
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL.Image import DecompressionBombError, Image, frombytes
|
||||
from rawpy import (
|
||||
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
|
||||
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RawImagePreview(BasePreview):
|
||||
media_type_name = "image.raster.raw"
|
||||
priority = 60
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("image.raster.raw", ".arw", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".cr2", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".cr3", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".crw", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".dng", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".nef", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".nrw", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".orf", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".r3d", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".raf", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".raw", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".rw2", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".srf", RENDER)
|
||||
MediaTypes.register("image.raster.raw", ".srf2", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return raw_image_thumb(filepath)
|
||||
|
||||
|
||||
def raw_image_thumb(filepath: Path) -> Image | None:
|
||||
"""Render a thumbnail for a RAW image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image | None = None
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess(use_camera_wb=True)
|
||||
im = frombytes(
|
||||
"RGB",
|
||||
(rgb.shape[1], rgb.shape[0]),
|
||||
rgb,
|
||||
decoder_name="raw",
|
||||
)
|
||||
except (
|
||||
DecompressionBombError,
|
||||
LibRawFileUnsupportedError,
|
||||
LibRawIOError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -1,17 +1,45 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# TODO: Remove this file from the project, turning it into an external plugin.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import srctools
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PIL.Image import Image
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def vtf_thumb(filepath: Path) -> Image.Image | None:
|
||||
class SourceEnginePreview(BasePreview):
|
||||
media_type_name = "source_engine"
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("source_engine", ".vtf", RENDER)
|
||||
MediaTypes.register("code", ".vmt", RENDER) # Fallback
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return vtf_thumb(filepath)
|
||||
|
||||
|
||||
def vtf_thumb(filepath: Path) -> Image | None:
|
||||
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
|
||||
|
||||
Uses the srctools library for reading VTF files.
|
||||
@@ -19,7 +47,7 @@ def vtf_thumb(filepath: Path) -> Image.Image | None:
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
vtf = srctools.VTF.read(f)
|
||||
|
||||
@@ -1,55 +1,170 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import io
|
||||
import textwrap
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
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 PIL import ImageFont, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError, Image, Resampling
|
||||
from PIL.Image import new as new_image
|
||||
from PIL.Image import open as open_image
|
||||
from pygments import highlight
|
||||
from pygments.formatters import ImageFormatter
|
||||
from pygments.lexers import PythonLexer # pyright: ignore[reportUnknownVariableType]
|
||||
from pygments.style import Style
|
||||
from pygments.token import (
|
||||
Comment,
|
||||
Error,
|
||||
Generic,
|
||||
Keyword,
|
||||
Literal,
|
||||
Name,
|
||||
Number,
|
||||
Operator,
|
||||
Other,
|
||||
Punctuation,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.utils.encoding import detect_char_encoding
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def text_thumb(filepath: Path) -> Image.Image | None:
|
||||
class TextPreview(BasePreview):
|
||||
media_type_name = "plaintext"
|
||||
font = ImageFont.load_default(20)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("plaintext", ".i3u", RENDER)
|
||||
MediaTypes.register("plaintext", "contributing", RENDER)
|
||||
MediaTypes.register("plaintext", "license", RENDER)
|
||||
MediaTypes.register("plaintext", "readme", RENDER)
|
||||
MediaTypes.register("plaintext", [".txt", ".text"], RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return text_thumb(
|
||||
filepath=filepath,
|
||||
size=size,
|
||||
style=TextDarkStyle if theme == Theme.DARK else TextLightStyle,
|
||||
)
|
||||
|
||||
|
||||
class TextLightStyle(Style):
|
||||
background = "#FFFFFF"
|
||||
foreground = "#000000"
|
||||
|
||||
background_color = background
|
||||
styles = {
|
||||
Generic: foreground + " bold",
|
||||
Text: foreground + " bold",
|
||||
Literal: foreground + " bold",
|
||||
String: foreground + " bold",
|
||||
}
|
||||
|
||||
|
||||
class TextDarkStyle(Style):
|
||||
background = "#111111"
|
||||
foreground = "#FFFFFF"
|
||||
|
||||
background_color = background
|
||||
styles = {
|
||||
Generic: foreground,
|
||||
Text: foreground,
|
||||
Literal: foreground,
|
||||
String: foreground,
|
||||
Comment: foreground,
|
||||
Error: foreground,
|
||||
Keyword: foreground,
|
||||
Name: foreground,
|
||||
Number: foreground,
|
||||
Operator: foreground,
|
||||
Other: foreground,
|
||||
Punctuation: foreground,
|
||||
}
|
||||
|
||||
|
||||
def text_thumb(
|
||||
filepath: Path,
|
||||
size: tuple[int, int],
|
||||
style: type[Style],
|
||||
) -> Image | None:
|
||||
"""Render a thumbnail for a plaintext file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
size (str): The final size for the image.
|
||||
style (str): The pygments style class to use.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
im: Image | None = None
|
||||
|
||||
try:
|
||||
encoding = detect_char_encoding(filepath)
|
||||
encoding: str = detect_char_encoding(filepath) or "utf-8"
|
||||
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
|
||||
text = text_file.read(1024)
|
||||
|
||||
wrapped_text = "\n".join(
|
||||
"\n".join(textwrap.wrap(line, width=40)) for line in text.splitlines()
|
||||
)
|
||||
|
||||
# TODO: Get this path from the ResourceManager, when that can handle fonts.
|
||||
font_path = str(
|
||||
Path(__file__).parents[2] / "resources/fonts/JetBrainsMono/JetBrainsMono.ttf"
|
||||
)
|
||||
# logger.info(font_path)
|
||||
|
||||
image_bytes = highlight(
|
||||
wrapped_text,
|
||||
PythonLexer(),
|
||||
ImageFormatter(
|
||||
encoding=encoding,
|
||||
font_name=font_path,
|
||||
font_size=32,
|
||||
line_numbers=False,
|
||||
style=style,
|
||||
image_pad=48,
|
||||
),
|
||||
)
|
||||
im_text = open_image(io.BytesIO(image_bytes))
|
||||
|
||||
ratio_w = size[0] / im_text.width
|
||||
im_text = Image.resize(
|
||||
im_text,
|
||||
(ceil(im_text.width * ratio_w), ceil(im_text.height * ratio_w)),
|
||||
Resampling.BILINEAR,
|
||||
)
|
||||
|
||||
bg = new_image("RGB", size, color=style.background_color)
|
||||
Image.paste(bg, im_text, (0, 0))
|
||||
|
||||
return bg
|
||||
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
UnicodeDecodeError,
|
||||
OSError,
|
||||
FileNotFoundError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=e)
|
||||
return im
|
||||
|
||||
@@ -6,29 +6,56 @@
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import (
|
||||
Image,
|
||||
UnidentifiedImageError,
|
||||
)
|
||||
from PIL import UnidentifiedImageError
|
||||
from PIL.Image import Image
|
||||
from PIL.Image import new as new_image
|
||||
from PIL.Image import open as open_image
|
||||
from PySide6.QtCore import QBuffer, Qt
|
||||
from PySide6.QtGui import QImage, QPainter
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
class VectorImagePreview(BasePreview):
|
||||
media_type_name = "image.vector"
|
||||
priority = 70
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("image.vector", ".svg", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return vector_image_thumb(filepath, size)
|
||||
|
||||
|
||||
def vector_image_thumb(filepath: Path, size: tuple[int, int]) -> 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
|
||||
im: 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: QImage = QImage(size[0], size[1], QImage.Format.Format_ARGB32)
|
||||
q_image.fill("#1e1e1e")
|
||||
|
||||
# Create an svg renderer, then render to the painter
|
||||
@@ -48,8 +75,8 @@ def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
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 = new_image("RGB", size, color="#1e1e1e")
|
||||
im.paste(open_image(BytesIO(buffer.data().data())))
|
||||
im = im.convert(mode="RGB")
|
||||
|
||||
buffer.close()
|
||||
|
||||
@@ -1,28 +1,65 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from cv2.typing import MatLike
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PIL import UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError, Image, fromarray
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.previews.base_preview import RENDER, BasePreview
|
||||
from tagstudio.previews.video_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def video_thumb(filepath: Path) -> Image.Image | None:
|
||||
class VideoPreview(BasePreview):
|
||||
media_type_name = "video"
|
||||
priority = 70
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def register_types(cls) -> None:
|
||||
MediaTypes.register("video", ".3gp", RENDER)
|
||||
MediaTypes.register("video", ".avi", RENDER)
|
||||
MediaTypes.register("video", ".flv", RENDER)
|
||||
MediaTypes.register("video", ".gifv", RENDER)
|
||||
MediaTypes.register("video", ".hevc", RENDER)
|
||||
MediaTypes.register("video", ".m4p", RENDER)
|
||||
MediaTypes.register("video", ".m4v", RENDER)
|
||||
MediaTypes.register("video", ".mkv", RENDER)
|
||||
MediaTypes.register("video", ".mov", RENDER)
|
||||
MediaTypes.register("video", ".mp4", RENDER)
|
||||
MediaTypes.register("video", ".webm", RENDER)
|
||||
MediaTypes.register("video", ".wmv", RENDER)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def render(
|
||||
cls,
|
||||
filepath: Path,
|
||||
is_small: bool,
|
||||
theme: Theme,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
) -> Image | None:
|
||||
return video_thumb(filepath)
|
||||
|
||||
|
||||
def video_thumb(filepath: Path) -> Image | None:
|
||||
"""Render a thumbnail for a video file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
im: Image | None = None
|
||||
frame: MatLike | None = None
|
||||
try:
|
||||
if is_readable_video(filepath):
|
||||
@@ -49,7 +86,12 @@ def video_thumb(filepath: Path) -> Image.Image | None:
|
||||
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:
|
||||
im = fromarray(frame)
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
OSError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
@@ -82,7 +82,10 @@ def classproperty(func):
|
||||
|
||||
AUDIO_FILE_EXT_ALIASES = {
|
||||
"m4a": "mp4",
|
||||
"m4r": "mp4",
|
||||
"wave": "wav",
|
||||
"aif": "aiff",
|
||||
"aifc": "aiff",
|
||||
}
|
||||
|
||||
WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"])
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from enum import Enum, IntEnum, StrEnum
|
||||
from enum import Enum, StrEnum
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
@@ -12,7 +12,7 @@ import structlog
|
||||
import toml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption
|
||||
from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption, Theme
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -32,13 +32,6 @@ MAX_CACHED_THUMB_RES = 1024 # Pixels
|
||||
DEFAULT_CACHED_THUMB_RES = 256 # Pixels
|
||||
|
||||
|
||||
class Theme(IntEnum):
|
||||
DARK = 0
|
||||
LIGHT = 1
|
||||
SYSTEM = 2
|
||||
DEFAULT = SYSTEM
|
||||
|
||||
|
||||
class Splash(StrEnum):
|
||||
DEFAULT = "default"
|
||||
RANDOM = "random"
|
||||
|
||||
@@ -12,13 +12,11 @@ import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QSize
|
||||
from rawpy import (
|
||||
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
|
||||
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
|
||||
)
|
||||
from rawpy import LibRawFileUnsupportedError, LibRawIOError # pyright: ignore
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.query_lang.file_groups import SEARCH
|
||||
from tagstudio.previews.video_tester import is_readable_video
|
||||
from tagstudio.qt.mixed.file_attributes import FileAttributeData
|
||||
from tagstudio.qt.utils.file_opener import open_file
|
||||
@@ -45,7 +43,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
if filepath.is_dir():
|
||||
pass
|
||||
elif MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True):
|
||||
elif MediaTypes.contains("image.raster.raw", ext, SEARCH):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
@@ -58,7 +56,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
FileNotFoundError,
|
||||
):
|
||||
pass
|
||||
elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True):
|
||||
elif MediaTypes.contains("image.raster", ext, SEARCH):
|
||||
try:
|
||||
image = Image.open(str(filepath))
|
||||
stats.width = image.width
|
||||
@@ -70,7 +68,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
UnidentifiedImageError,
|
||||
) as e:
|
||||
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
|
||||
elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True):
|
||||
elif MediaTypes.contains("image.vector", ext, SEARCH):
|
||||
pass # TODO
|
||||
|
||||
return stats
|
||||
@@ -118,9 +116,7 @@ class PreviewThumb(PreviewThumbView):
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# Video
|
||||
if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video(
|
||||
filepath
|
||||
):
|
||||
if MediaTypes.contains("video", ext, SEARCH) and is_readable_video(filepath):
|
||||
size: QSize | None = None
|
||||
try:
|
||||
success, size = self.__get_video_res(str(filepath))
|
||||
@@ -131,10 +127,10 @@ class PreviewThumb(PreviewThumbView):
|
||||
|
||||
return self._display_video(filepath, size)
|
||||
# Audio
|
||||
elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True):
|
||||
elif MediaTypes.contains("audio", ext, SEARCH):
|
||||
return self._display_audio(filepath)
|
||||
# Animated Images
|
||||
elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True):
|
||||
elif MediaTypes.contains("image.animated", ext, SEARCH):
|
||||
if (ret := self.__get_gif_data(filepath)) and (
|
||||
stats := self._display_gif(ret[0], ret[1])
|
||||
) is not None:
|
||||
|
||||
@@ -64,8 +64,8 @@ class ProgressWidget(QWidget):
|
||||
|
||||
self.show()
|
||||
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(
|
||||
runnable = CustomRunnable(lambda: iterator.run())
|
||||
runnable.done.connect(
|
||||
lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks])
|
||||
)
|
||||
QThreadPool.globalInstance().start(r)
|
||||
QThreadPool.globalInstance().start(runnable)
|
||||
|
||||
@@ -18,7 +18,8 @@ from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.query_lang.file_groups import SEARCH
|
||||
from tagstudio.core.utils.str_formatting import format_duration
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.translations import Translations
|
||||
@@ -178,9 +179,7 @@ class FileAttributes(QWidget):
|
||||
try:
|
||||
file_size = format_size(filepath.stat().st_size)
|
||||
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.FONT_TYPES, mime_fallback=True
|
||||
):
|
||||
if MediaTypes.contains("font", ext, SEARCH):
|
||||
font = ImageFont.truetype(filepath)
|
||||
font_family = f"{font.getname()[0]} ({font.getname()[1]}) "
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
|
||||
@@ -15,7 +15,8 @@ from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxL
|
||||
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
|
||||
from tagstudio.core.library.alchemy.enums import ItemType
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories, MediaType
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.query_lang.file_groups import SEARCH
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.platform_strings import open_file_str, trash_term
|
||||
from tagstudio.i18n.translations import Translations
|
||||
@@ -361,12 +362,11 @@ class ItemThumb(FlowWidget):
|
||||
ext = filename.suffix.lower()
|
||||
if ext and ext.startswith(".") is False:
|
||||
ext = "." + ext
|
||||
media_types: set[MediaType] = MediaCategories.get_types(ext)
|
||||
if (
|
||||
not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES)
|
||||
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES)
|
||||
or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES)
|
||||
or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES)
|
||||
not MediaTypes.contains("image.raster", ext, SEARCH)
|
||||
or MediaTypes.contains("image.raster.raw", ext, SEARCH)
|
||||
or MediaTypes.contains("image.vector", ext, SEARCH)
|
||||
or MediaTypes.contains("adobe.photoshop", ext, SEARCH)
|
||||
or ext
|
||||
in [
|
||||
".apng",
|
||||
@@ -380,7 +380,9 @@ class ItemThumb(FlowWidget):
|
||||
if ext or filename.stem:
|
||||
self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper())
|
||||
show_ext_badge = True
|
||||
if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types:
|
||||
if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains(
|
||||
"audio", ext, SEARCH
|
||||
):
|
||||
show_count_badge = True
|
||||
|
||||
self.ext_badge.setHidden(not show_ext_badge)
|
||||
|
||||
@@ -22,7 +22,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.enums import ThemePalette
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
@@ -56,9 +56,9 @@ class TagColorManager(QWidget):
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
self.title_label = QLabel()
|
||||
|
||||
@@ -48,7 +48,8 @@ from tagstudio.core.library.alchemy.library import Library, LibraryStatus
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.library.refresh import RefreshTracker
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
from tagstudio.core.query_lang.file_groups import SEARCH
|
||||
from tagstudio.core.query_lang.util import ParsingError
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
@@ -1371,29 +1372,18 @@ class QtDriver(DriverMixin, QObject):
|
||||
map(lambda x: prefix + "path:" + x, self.lib.get_paths(limit=100))
|
||||
)
|
||||
elif query_type == "mediatype":
|
||||
single_word_completions = map(
|
||||
lambda x: prefix + "mediatype:" + x.name,
|
||||
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
|
||||
)
|
||||
single_word_completions_quoted = map(
|
||||
lambda x: prefix + 'mediatype:"' + x.name + '"',
|
||||
filter(lambda y: " " not in y.name, MediaCategories.ALL_CATEGORIES),
|
||||
)
|
||||
multi_word_completions = map(
|
||||
lambda x: prefix + 'mediatype:"' + x.name + '"',
|
||||
filter(lambda y: " " in y.name, MediaCategories.ALL_CATEGORIES),
|
||||
)
|
||||
|
||||
all_completions = [
|
||||
single_word_completions,
|
||||
single_word_completions_quoted,
|
||||
multi_word_completions,
|
||||
]
|
||||
completion_list = [j for i in all_completions for j in i]
|
||||
completion_list = []
|
||||
for group in MediaTypes.all_groups:
|
||||
for alias in group.name_aliases:
|
||||
if " " in alias:
|
||||
completion_list.append(f'{prefix}mediatype:"{alias}"')
|
||||
else:
|
||||
completion_list.append(f"{prefix}mediatype:{alias}")
|
||||
completion_list.append(f'{prefix}mediatype:"{alias}"')
|
||||
elif query_type == "filetype":
|
||||
extensions_list: set[str] = set()
|
||||
for media_cat in MediaCategories.ALL_CATEGORIES:
|
||||
extensions_list = extensions_list | media_cat.extensions
|
||||
for group in MediaTypes.all_groups:
|
||||
extensions_list |= group.context_sets.get(SEARCH, set())
|
||||
completion_list = list(
|
||||
map(lambda x: prefix + "filetype:" + x.replace(".", ""), extensions_list)
|
||||
)
|
||||
|
||||
@@ -58,11 +58,12 @@ class ResourceManager:
|
||||
|
||||
return RESOURCE_FOLDER / "resources" / resource_path
|
||||
|
||||
def get(self, id: str):
|
||||
def get(self, id: str, silent_fail: bool = False):
|
||||
"""Get a resource from the ResourceManager.
|
||||
|
||||
Args:
|
||||
id (str): The name of the resource.
|
||||
silent_fail (bool): Don't log if the resource can not be found.
|
||||
|
||||
Returns:
|
||||
bytes: When the data is in byte format.
|
||||
@@ -85,7 +86,8 @@ class ResourceManager:
|
||||
if resource_path is None:
|
||||
raise FileNotFoundError
|
||||
except (FileNotFoundError, AttributeError) as e:
|
||||
logger.error("[ResourceManager]: Could not find resource", id=id, error=e)
|
||||
if not silent_fail:
|
||||
logger.error("[ResourceManager]: Could not find resource", id=id, error=e)
|
||||
return None
|
||||
|
||||
file_path = RESOURCE_FOLDER / "resources" / resource_path
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
"mode": "pil",
|
||||
"path": "qt/images/file_icons/spreadsheet.png"
|
||||
},
|
||||
"text": {
|
||||
"plaintext": {
|
||||
"mode": "pil",
|
||||
"path": "qt/images/file_icons/text.png"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import math
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
@@ -13,7 +14,6 @@ from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaType
|
||||
from tagstudio.i18n.platform_strings import open_file_str, trash_term
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.mixed.file_attributes import FileAttributeData
|
||||
@@ -30,6 +30,16 @@ logger = structlog.get_logger(__name__)
|
||||
THUMB_SIZE_FACTOR = 2
|
||||
|
||||
|
||||
class PreviewType(Enum):
|
||||
"""Enum for which of the Inspector's stacked pages should be shown for a file."""
|
||||
|
||||
ANIMATED = auto()
|
||||
AUDIO = auto()
|
||||
IMAGE = auto()
|
||||
TEXT = auto()
|
||||
VIDEO = auto()
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class PreviewThumbView(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
@@ -200,8 +210,8 @@ class PreviewThumbView(QWidget):
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
|
||||
def __switch_preview(self, preview: MediaType | None) -> None:
|
||||
if preview in [MediaType.AUDIO, MediaType.VIDEO]:
|
||||
def __switch_preview(self, preview: PreviewType | None) -> None:
|
||||
if preview in [PreviewType.AUDIO, PreviewType.VIDEO]:
|
||||
self.__media_player.show()
|
||||
self.__image_layout.setCurrentWidget(self.__media_player_page)
|
||||
self.check_ffmpeg.emit(True) # noqa: FBT003
|
||||
@@ -210,15 +220,17 @@ class PreviewThumbView(QWidget):
|
||||
self.__media_player.hide()
|
||||
self.check_ffmpeg.emit(False) # noqa: FBT003
|
||||
|
||||
if preview in [MediaType.IMAGE, MediaType.AUDIO]:
|
||||
if preview in [PreviewType.IMAGE, PreviewType.AUDIO]:
|
||||
self.__button_wrapper.show()
|
||||
self.__image_layout.setCurrentWidget(
|
||||
self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page
|
||||
self.__preview_img_page
|
||||
if preview == PreviewType.IMAGE
|
||||
else self.__media_player_page
|
||||
)
|
||||
else:
|
||||
self.__button_wrapper.hide()
|
||||
|
||||
if preview == MediaType.IMAGE_ANIMATED:
|
||||
if preview == PreviewType.ANIMATED:
|
||||
self.__preview_gif.show()
|
||||
self.__image_layout.setCurrentWidget(self.__preview_gif_page)
|
||||
else:
|
||||
@@ -251,7 +263,7 @@ class PreviewThumbView(QWidget):
|
||||
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
|
||||
self.__should_render_on_resize = False
|
||||
|
||||
self.__switch_preview(MediaType.VIDEO)
|
||||
self.__switch_preview(PreviewType.VIDEO)
|
||||
self.__update_media_player(filepath)
|
||||
stats = FileAttributeData()
|
||||
|
||||
@@ -270,7 +282,7 @@ class PreviewThumbView(QWidget):
|
||||
return stats
|
||||
|
||||
def _display_audio(self, filepath: Path) -> FileAttributeData:
|
||||
self.__switch_preview(MediaType.AUDIO)
|
||||
self.__switch_preview(PreviewType.AUDIO)
|
||||
self.__render_thumb(filepath)
|
||||
self.__update_media_player(filepath)
|
||||
return FileAttributeData()
|
||||
@@ -300,7 +312,7 @@ class PreviewThumbView(QWidget):
|
||||
return None
|
||||
|
||||
# The animation has more than 1 frame, continue displaying it as an animation
|
||||
self.__switch_preview(MediaType.IMAGE_ANIMATED)
|
||||
self.__switch_preview(PreviewType.ANIMATED)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(stats.width, stats.height),
|
||||
@@ -315,7 +327,7 @@ class PreviewThumbView(QWidget):
|
||||
|
||||
def _display_image(self, filepath: Path):
|
||||
"""Renders the given file as an image, no matter its media type."""
|
||||
self.__switch_preview(MediaType.IMAGE)
|
||||
self.__switch_preview(PreviewType.IMAGE)
|
||||
self.__render_thumb(filepath)
|
||||
|
||||
def hide_preview(self) -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QGuiApplication
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.enums import ThemePalette
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.views.styles.palette import (
|
||||
@@ -55,14 +55,14 @@ def button_style() -> str:
|
||||
"""Style used for common QPushButtons."""
|
||||
return f"""
|
||||
QPushButton{{
|
||||
background-color: {Theme.COLOR_BG.value};
|
||||
background-color: {ThemePalette.COLOR_BG.value};
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
padding: 0px 12px;
|
||||
}}
|
||||
QPushButton::hover{{
|
||||
background-color: {Theme.COLOR_HOVER.value};
|
||||
background-color: {ThemePalette.COLOR_HOVER.value};
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)};
|
||||
@@ -84,7 +84,7 @@ def button_style() -> str:
|
||||
padding: 0px 8px;
|
||||
}}
|
||||
QPushButton::disabled{{
|
||||
background-color: {Theme.COLOR_DISABLED_BG.value};
|
||||
background-color: {ThemePalette.COLOR_DISABLED_BG.value};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -92,9 +92,9 @@ def button_style() -> str:
|
||||
def line_edit_style_main() -> str:
|
||||
"""Style used for common QLineEdits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -118,7 +118,7 @@ def line_edit_style_main() -> str:
|
||||
padding: 0px 2px;
|
||||
}}
|
||||
QLineEdit::disabled{{
|
||||
background-color: {Theme.COLOR_DISABLED_BG.value};
|
||||
background-color: {ThemePalette.COLOR_DISABLED_BG.value};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -259,10 +259,10 @@ def container_style() -> str:
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QWidget#fieldContainer::hover{{
|
||||
background-color: {Theme.COLOR_HOVER.value};
|
||||
background-color: {ThemePalette.COLOR_HOVER.value};
|
||||
}}
|
||||
QWidget#fieldContainer::pressed{{
|
||||
background-color: {Theme.COLOR_PRESSED.value};
|
||||
background-color: {ThemePalette.COLOR_PRESSED.value};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -271,9 +271,9 @@ def form_content_style() -> str:
|
||||
return f"""
|
||||
QLabel{{
|
||||
background-color: {
|
||||
Theme.COLOR_BG.value
|
||||
ThemePalette.COLOR_BG.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
};
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
@@ -340,9 +340,9 @@ def list_button_style(
|
||||
def properties_style() -> str:
|
||||
"""Style used for small labels such as file properties."""
|
||||
label_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_DARK_LABEL.value
|
||||
else ThemePalette.COLOR_DARK_LABEL.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -447,9 +447,9 @@ def title_line_edit_style() -> str:
|
||||
def inset_container_style(object_name: str = "") -> str:
|
||||
"""Used for darkened inset areas."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -464,9 +464,9 @@ def inset_container_style(object_name: str = "") -> str:
|
||||
def autofill_scroll_top_style(object_name: str = "") -> str:
|
||||
"""Used autofill lists positioned on top of line edits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -482,9 +482,9 @@ def autofill_scroll_top_style(object_name: str = "") -> str:
|
||||
def autofill_scroll_top_focus_style(object_name: str = "") -> str:
|
||||
"""Used autofill lists positioned on top of line edits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -502,9 +502,9 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str:
|
||||
def autofill_line_edit_style() -> str:
|
||||
"""Used for QLineEdits."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
@@ -525,9 +525,9 @@ def autofill_line_edit_style() -> str:
|
||||
def autofill_line_edit_top_style() -> str:
|
||||
"""Used for QLineEdits when there's a top autofill section present."""
|
||||
bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
ThemePalette.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
else ThemePalette.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
return f"""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
SPDX-FileCopyrightText: 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)
|
||||
SPDX-License-Identifier: OFL-1.1
|
||||
@@ -31,7 +31,7 @@
|
||||
"drop_import.description": "Les fichiers suivants correspondent à des chemins de fichiers déjà existant dans la bibliothèque",
|
||||
"drop_import.duplicates_choice.plural": "Les chemins d'accès des {count} fichiers suivants existent déjà dans la Bibliothèque.",
|
||||
"drop_import.duplicates_choice.singular": "Le fichier suivant correspond a un chemin d'accès déjà existant dans la bibliothèque.",
|
||||
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers...",
|
||||
"drop_import.progress.label.initial": "Importer des Nouveaux Fichiers…",
|
||||
"drop_import.progress.label.plural": "Importation des Nouveaux Fichiers...\n{count} Fichiers Importés.{suffix}",
|
||||
"drop_import.progress.label.singular": "Importation des Nouveaux Fichiers...\n1 Fichier Importé.{suffix}",
|
||||
"drop_import.progress.window_title": "Importer des Fichiers",
|
||||
@@ -41,26 +41,26 @@
|
||||
"edit.paste_fields": "Coller les Champs",
|
||||
"edit.tag_manager": "Gérer les Tags",
|
||||
"entries.duplicate.merge": "Fusion des entrées dupliquées",
|
||||
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées...",
|
||||
"entries.duplicate.merge.label": "Fusionner les entrées dupliquées…",
|
||||
"entries.duplicate.refresh": "Rafraichir les Entrées en Doublon",
|
||||
"entries.duplicates.description": "Les entrées dupliquées sont définies comme des entrées multiple qui pointent vers le même fichier sur le disque. Les fusionner va combiner les tags et metadatas de tous les duplicatas vers une seule entrée consolidée. Elles ne doivent pas être confondues avec les \"fichiers en doublon\", qui sont des doublons de vos fichiers en dehors de TagStudio.",
|
||||
"entries.generic.refresh_alt": "&Recharger",
|
||||
"entries.generic.remove.removing": "Suppression des Entrées",
|
||||
"entries.generic.remove.removing_count": "Suppression de {count} entrées...",
|
||||
"entries.generic.remove.removing_count": "Suppression de {count} entrées…",
|
||||
"entries.ignored.description": "Les entrées de fichier sont considérées comme « ignorées » si elles ont été ajoutées à la bibliothèque avant que les règles d'ignorance de l'utilisateur (via le fichier « .ts_ignore ») aient été mises à jour pour les exclure. Les fichiers ignorés sont conservés dans la bibliothèque par défaut afin d'éviter toute perte accidentelle de données lors de la mise à jour des règles d'ignorance.",
|
||||
"entries.ignored.ignored_count": "Entrées Ignorées : {count}",
|
||||
"entries.ignored.remove": "Supprimer les entrées ignorées",
|
||||
"entries.ignored.remove_alt": "Supprim&er les entrées ignorées",
|
||||
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque...",
|
||||
"entries.ignored.scanning": "Recherche des entrées ignorées dans la bibliothèque…",
|
||||
"entries.ignored.title": "Corriger les entrées ignorées",
|
||||
"entries.mirror": "&Répliquer",
|
||||
"entries.mirror.confirmation": "Êtes-vous sûr de vouloir répliquer les {count} Entrées suivantes ?",
|
||||
"entries.mirror.label": "Réplication de {idx}/{total} Entrées...",
|
||||
"entries.mirror.label": "Réplication de {idx}/{total} Entrées…",
|
||||
"entries.mirror.title": "Réplication des Entrées",
|
||||
"entries.mirror.window_title": "Entrées Répliqués",
|
||||
"entries.remove.plural.confirm": "Êtes-vous sûr de vouloir supprimer les <b>{count}</b> entrées suivantes ? Aucun fichier sur votre disque ne sera supprimée.",
|
||||
"entries.remove.singular.confirm": "Êtes-vous sûr de vouloir supprimer cette entrée de votre bibliothèque ? Aucun fichier sur le disque ne sera supprimé.",
|
||||
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier...",
|
||||
"entries.running.dialog.new_entries": "Ajout de {total} Nouvelles entrées de fichier…",
|
||||
"entries.running.dialog.title": "Ajout de Nouvelles entrées de fichier",
|
||||
"entries.tags": "Tags",
|
||||
"entries.unlinked.description": "Chaque entrée dans la bibliothèque est liée à un fichier dans l'un de vos dossiers. Si un fichier lié à une entrée est déplacé ou supprimé en dehors de TagStudio, il est alors considéré non lié. <br><br>Les entrées non liées peuvent être automatiquement reliées via la recherche dans vos dossiers, reliées manuellement par l'utilisateur, ou supprimées si désiré.",
|
||||
@@ -69,7 +69,7 @@
|
||||
"entries.unlinked.relink.title": "Reliage des Entrées",
|
||||
"entries.unlinked.remove": "Supprimer les entrées non liées",
|
||||
"entries.unlinked.remove_alt": "Supprim&er les entrées non liées",
|
||||
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées...",
|
||||
"entries.unlinked.scanning": "Balayage de la Bibliothèque pour trouver des Entrées non Liées…",
|
||||
"entries.unlinked.search_and_relink": "&Rechercher && Relier",
|
||||
"entries.unlinked.title": "Réparation des Entrées non Liées",
|
||||
"entries.unlinked.unlinked_count": "Entrées non Liées : {count}",
|
||||
@@ -161,11 +161,14 @@
|
||||
"generic.skip_alt": "&Passer",
|
||||
"generic.yes": "Oui",
|
||||
"home.search": "Rechercher",
|
||||
"home.search.how_to_exit": "(Esc pour Fermer)",
|
||||
"home.search.view_limit": "Limite d'affichage :",
|
||||
"home.search_entries": "Recherche",
|
||||
"home.search_field_templates": "Rechercher un format de champs",
|
||||
"home.search_field_templates": "Rechercher un format de champs…",
|
||||
"home.search_library": "Rechercher dans la Bibliothèque",
|
||||
"home.search_tags": "Recherche de Tags",
|
||||
"home.search_or_create_fields": "Rechercher ou Créer des Champs…",
|
||||
"home.search_or_create_tags": "Rechercher ou Créer des Tags…",
|
||||
"home.search_tags": "Recherche des Tags…",
|
||||
"home.show_hidden_entries": "Afficher les entrées cachées",
|
||||
"home.thumbnail_size": "Taille de la miniature",
|
||||
"home.thumbnail_size.extra_large": "Très Grandes Miniatures",
|
||||
@@ -174,8 +177,8 @@
|
||||
"home.thumbnail_size.mini": "Mini Miniatures",
|
||||
"home.thumbnail_size.small": "Petites Miniatures",
|
||||
"ignore.open_file": "Afficher le fichier \"{ts_ignore}\" sur le Disque",
|
||||
"json_migration.checking_for_parity": "Vérification de la Parité...",
|
||||
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL...",
|
||||
"json_migration.checking_for_parity": "Vérification de la Parité…",
|
||||
"json_migration.creating_database_tables": "Création des Tables de Base de Données SQL…",
|
||||
"json_migration.description": "<br>Démarrez et prévisualisez les résultats du processus de migration de la bibliothèque. La bibliothèque convertie ne sera utilisée <i>que</i> si vous cliquez sur \"Terminer la migration\". <br><br>Les données de la bibliothèque doivent soit avoir des valeurs correspondantes, soit comporter un label \"Matched\". Les valeurs qui ne correspondent pas seront affichées en rouge et comporteront un symbole \"<b>(!)</b>\" à côté d'elles.<br><center><i>Ce processus peut prendre jusqu'à plusieurs minutes pour les bibliothèques plus volumineuses.</i></center>",
|
||||
"json_migration.discrepancies_found": "Divergences Détectées dans la Bibliothèque",
|
||||
"json_migration.discrepancies_found.description": "Des divergences ont été détectées entre le format d'origine et le format converti de la bibliothèque. Veuillez les examiner et choisir de poursuivre la migration ou de l'annuler.",
|
||||
@@ -232,7 +235,7 @@
|
||||
"library.name": "Bibliothèque",
|
||||
"library.refresh.scanning.plural": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichiers Trouvées, {found_count} Nouveaux Fichiers",
|
||||
"library.refresh.scanning.singular": "Analyse du Répertoire pour de Nouveaux Fichiers...\n{searched_count} Fichier Trouvé, {found_count} Nouveaux Fichiers",
|
||||
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation...",
|
||||
"library.refresh.scanning_preparing": "Recherche de Nouveaux Fichiers dans les Dossiers...\nPréparation…",
|
||||
"library.refresh.title": "Rafraîchissement des Dossiers",
|
||||
"library.scan_library.title": "Balayage de la Bibliothèque",
|
||||
"library_info.cleanup": "Nettoyage",
|
||||
@@ -254,7 +257,7 @@
|
||||
"library_object.name_required": "Nom (Requis)",
|
||||
"library_object.slug": "Identifiant unique",
|
||||
"library_object.slug_required": "ID Slug (Requis)",
|
||||
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers...",
|
||||
"macros.running.dialog.new_entries": "Exécution des Macros Configurées sur {count}/{total} Nouvelles Entrées de Fichiers…",
|
||||
"macros.running.dialog.title": "Exécution des Macros sur les Nouvelles Entrées",
|
||||
"media_player.autoplay": "Lecture automatique",
|
||||
"media_player.loop": "Lire en boucle",
|
||||
@@ -284,7 +287,7 @@
|
||||
"menu.macros": "&Macros",
|
||||
"menu.macros.folders_to_tags": "Répertoires à Tags",
|
||||
"menu.select": "Sélectionner",
|
||||
"menu.settings": "Paramètres...",
|
||||
"menu.settings": "Paramètres…",
|
||||
"menu.tools": "&Outils",
|
||||
"menu.tools.fix_duplicate_files": "Réparer les entrées de &fichiers en double",
|
||||
"menu.tools.fix_ignored_entries": "Corriger les entrées &Ignorer",
|
||||
@@ -316,6 +319,8 @@
|
||||
"settings.dateformat.international": "International",
|
||||
"settings.dateformat.label": "Format de Date",
|
||||
"settings.dateformat.system": "Système",
|
||||
"settings.edit_field_on_add": "Modifier Après avoir Ajouté un Champ",
|
||||
"settings.edit_tag_on_create": "Modifier Après avoir Créé un Nouveau Tag",
|
||||
"settings.filepath.label": "Visibilités du Chemin de Fichier",
|
||||
"settings.filepath.option.full": "Afficher le Chemin Complet",
|
||||
"settings.filepath.option.name": "Afficher Seulement le Nom du Fichier",
|
||||
@@ -355,18 +360,18 @@
|
||||
"sorting.direction.ascending": "Croissant",
|
||||
"sorting.direction.descending": "Décroissant",
|
||||
"sorting.mode.random": "Aléatoire",
|
||||
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"...",
|
||||
"splash.opening_library": "Ouverture de la Bibliothèque \"{library_path}\"…",
|
||||
"status.deleted_file_plural": "Suppression de {count} fichiers!",
|
||||
"status.deleted_file_singular": "Suppression de 1 fichier!",
|
||||
"status.deleted_none": "Aucun fichiers supprimer.",
|
||||
"status.deleted_partial_warning": "Seulement {count} fichier(s) on été supprimé! Vérifier si les fichiers restant ne serais pas manquant ou en cours d'utilisation.",
|
||||
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"...",
|
||||
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque...",
|
||||
"status.deleting_file": "Suppression de fichier(s) [{i}/{count}]: \"{path}\"…",
|
||||
"status.library_backup_in_progress": "Création d'une Sauvegarde de la Bibliothèque…",
|
||||
"status.library_backup_success": "Bibliothèque sauvegardée au chemin: \"{path}\" ({time_span})",
|
||||
"status.library_closed": "Bibliothèque fermée ({time_span})",
|
||||
"status.library_closing": "Fermeture de la Bibliothèque...",
|
||||
"status.library_closing": "Fermeture de la Bibliothèque…",
|
||||
"status.library_save_success": "Bibliothèque Sauvegardée et Fermée!",
|
||||
"status.library_search_query": "Rechercher dans la Bibliothèque...",
|
||||
"status.library_search_query": "Rechercher dans la Bibliothèque…",
|
||||
"status.library_version_expected": "Exceptée:",
|
||||
"status.library_version_found": "Trouvée:",
|
||||
"status.library_version_mismatch": "La version de la library ne correspond pas!",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"about.config_path": "Konfigurációs fájl",
|
||||
"about.description": "A TagStudio egy fénykép- és fájlkezelő program, mely címkék segítségével nyújt felhasználói szabadságot és rugalmasságot. A TagStudio nem használ jogvédett formátumokat, társfájlokat és nem fordítja a feje tetejére a már létező fájlrendszert.",
|
||||
"about.documentation": "Dokumentáció",
|
||||
"about.library_version": "Könyvtárformátum",
|
||||
"about.module.found": "Telepítve",
|
||||
"about.modules.title": "Nemkötelező modulok",
|
||||
"about.title": "A TagStudio névjegye",
|
||||
@@ -161,7 +162,7 @@
|
||||
"generic.skip_alt": "&Kihagyás",
|
||||
"generic.yes": "Igen",
|
||||
"home.search": "Keresés",
|
||||
"home.search.how_to_exit": "(Kilépés az Esc billentyűvel)",
|
||||
"home.search.how_to_exit": "(Kilépés az Enter billentyűvel)",
|
||||
"home.search.view_limit": "Megtekintési korlát:",
|
||||
"home.search_entries": "Tételek keresése",
|
||||
"home.search_field_templates": "Keresés a mezőminták között…",
|
||||
@@ -329,6 +330,7 @@
|
||||
"settings.global": "Globális beállítások",
|
||||
"settings.hourformat.label": "24-órás idő",
|
||||
"settings.infinite_scroll": "Végtelen görgetés",
|
||||
"settings.keep_suggest_boxes_open": "Keresőmezők nyitva tartása elemek létrehozása után",
|
||||
"settings.language": "&Nyelv",
|
||||
"settings.library": "Könyvtárbeállítások",
|
||||
"settings.localization": "Nyelv és formátumok",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"about.app_cache_path": "Applicatie Cache Pad",
|
||||
"about.config_path": "Configuratie Pad",
|
||||
"about.description": "TagStudio is een applicatie om foto's en bestanden te organiseren, met een onderliggend systeem gebaseerd op tags, dat zich focust op vrijheid en flexibiliteit bieden aan de gebruiker. Geen gepatenteerde programma's of bestandsformaten, geen zeëen aan sidecarbestanden, en je bestaande bestandsstructuur wordt niet volledig overhoop gegooid.",
|
||||
"about.documentation": "Documentatie",
|
||||
"about.module.found": "Gevonden",
|
||||
"about.modules.title": "Optionele Modules",
|
||||
"about.title": "Over TagSudio",
|
||||
"about.version": "Versie",
|
||||
"about.version.latest": "{built_version} (Laatste Uitgave: {latest_version})",
|
||||
"about.website": "Website",
|
||||
"app.git": "Git Commit",
|
||||
"app.pre_release": "Pre-Release",
|
||||
@@ -43,6 +47,7 @@
|
||||
"field.mixed_data": "Gemixte Data",
|
||||
"field.paste": "Veld Plakken",
|
||||
"field.remove": "Veld Weghalen",
|
||||
"field_type.text": "Text",
|
||||
"file.date_added": "Datum Toegevoegd",
|
||||
"file.date_created": "Datum Aangemaakt",
|
||||
"file.date_modified": "Datum Aangepast",
|
||||
@@ -80,14 +85,16 @@
|
||||
"generic.save": "Opslaan",
|
||||
"generic.skip": "Overslaan",
|
||||
"generic.skip_alt": "&Overslaan",
|
||||
"generic.yes": "Ja",
|
||||
"home.search": "Zoeken",
|
||||
"home.search_tags": "Labels Zoeken",
|
||||
"home.search_tags": "Labels Zoeken…",
|
||||
"home.thumbnail_size": "Miniatuur Grootte",
|
||||
"home.thumbnail_size.extra_large": "Extra Grote Miniaturen",
|
||||
"home.thumbnail_size.large": "Grote Miniaturen",
|
||||
"home.thumbnail_size.medium": "Gemiddelde Minituren",
|
||||
"home.thumbnail_size.mini": "Mini Miniaturen",
|
||||
"home.thumbnail_size.small": "Kleine Miniaturen",
|
||||
"json_migration.creating_database_tables": "SQL databasetabellen worden aangemaakt…",
|
||||
"json_migration.finish_migration": "Migratie Afronden",
|
||||
"json_migration.heading.aliases": "Aliassen:",
|
||||
"json_migration.heading.colors": "Kleuren:",
|
||||
@@ -96,9 +103,16 @@
|
||||
"json_migration.heading.shorthands": "Afkortingen:",
|
||||
"json_migration.migration_complete": "Migratie Afgerond!",
|
||||
"json_migration.title": "Migratie Formaat Opslaan: \"{path}\"",
|
||||
"library.refresh.scanning.plural": "Mappen scannen voor nieuwe bestanden...\n{searched_count} bestanden doorgezocht, {found_count} nieuwe bestanden gevonden",
|
||||
"library.refresh.scanning_preparing": "Mappen scannen voor nieuwe bestanden...\nVoorbereiden...",
|
||||
"library.refresh.title": "Mappen Verversen",
|
||||
"library_info.stats": "Statistieken",
|
||||
"library_info.stats.fields": "Velden:",
|
||||
"library_info.stats.macros": "Macros:",
|
||||
"library_info.stats.tags": "Labels:",
|
||||
"library_object.name": "Naam",
|
||||
"library_object.name_required": "Naam (vereist)",
|
||||
"media_player.loop": "Herhalen",
|
||||
"menu.delete_selected_files_ambiguous": "Bestand(en) verplaatsen naar {trash_term}",
|
||||
"menu.delete_selected_files_plural": "Bestanden verplaatsen naar {trash_term}",
|
||||
"menu.delete_selected_files_singular": "Bestand verplaatsen naar {trash_term}",
|
||||
@@ -107,10 +121,15 @@
|
||||
"menu.edit.manage_tags": "Labels Beheren",
|
||||
"menu.edit.new_tag": "Nieuw &Label",
|
||||
"menu.file": "&Bestand",
|
||||
"menu.file.missing_library.title": "Missende bibliotheek",
|
||||
"menu.help": "&Help",
|
||||
"menu.help.about": "Over",
|
||||
"menu.macros": "&Macros",
|
||||
"menu.macros.folders_to_tags": "Mappen naar Labels",
|
||||
"menu.select": "Selecteren",
|
||||
"menu.settings": "Instellingen…",
|
||||
"menu.window": "Venster",
|
||||
"preview.ignored": "Genegeerd",
|
||||
"select.all": "Alles Selecteren",
|
||||
"select.inverse": "Selectie omkeren",
|
||||
"sorting.direction.ascending": "Oplopend",
|
||||
|
||||
+19
-1
@@ -1,6 +1,9 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable, Generator
|
||||
@@ -13,6 +16,7 @@ from PySide6.QtWidgets import QScrollArea
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import TextField
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
# this needs to be above `src` imports
|
||||
@@ -148,11 +152,25 @@ def entry_full(library: Library):
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_qtbot(qtbot: QtBot): # pyright: ignore[reportUnusedFunction]
|
||||
def _init_qtbot(qtbot: QtBot):
|
||||
"""Ensures that a QtBot is initialized for all subsequent tests, regardless of order."""
|
||||
return qtbot
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_media_types():
|
||||
"""Snapshot the MediaTypes state before each test, then restore it after."""
|
||||
pre_snapshop = MediaTypes._snapshot()
|
||||
|
||||
yield
|
||||
|
||||
post_snapshop = MediaTypes._snapshot()
|
||||
|
||||
if pre_snapshop != post_snapshop:
|
||||
MediaTypes._restore(pre_snapshop)
|
||||
assert pre_snapshop == MediaTypes._snapshot(), "The MediaTypes state was not restored!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qt_driver(library: Library, library_dir: Path):
|
||||
class Args:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pytestqt.exceptions import pytest
|
||||
|
||||
from tagstudio.core.media_types import MediaTypes
|
||||
|
||||
|
||||
def test_register_and_contains():
|
||||
MediaTypes.register("zzztest.basic", ".zzzfoo", "SEARCH")
|
||||
|
||||
assert MediaTypes.contains("zzztest.basic", ".zzzfoo", "SEARCH")
|
||||
assert not MediaTypes.contains("zzztest.basic", ".zzzfoo", "RENDER")
|
||||
|
||||
|
||||
def test_additive_register():
|
||||
MediaTypes.register("zzztest.additive", ".zzzfoo", "SEARCH")
|
||||
MediaTypes.register("zzztest.additive", ".zzzbar", "SEARCH")
|
||||
|
||||
assert MediaTypes.contains("zzztest.additive", ".zzzfoo", "SEARCH")
|
||||
assert MediaTypes.contains("zzztest.additive", ".zzzbar", "SEARCH")
|
||||
|
||||
|
||||
def test_contains_missing_group_raises_error():
|
||||
with pytest.raises(AttributeError, match=r"is not registered"):
|
||||
MediaTypes.contains("zzztest.does_not_exist", ".zzzfoo", "SEARCH")
|
||||
|
||||
|
||||
def test_dot_notation_chains_to_parents():
|
||||
MediaTypes.register("zzztest.chain.parent.child", ".zzzchild", "SEARCH")
|
||||
|
||||
assert MediaTypes.contains("zzztest.chain.parent.child", ".zzzchild", "SEARCH")
|
||||
assert MediaTypes.contains("zzztest.chain.parent", ".zzzchild", "SEARCH")
|
||||
assert MediaTypes.contains("zzztest.chain", ".zzzchild", "SEARCH")
|
||||
|
||||
|
||||
def test_explicit_chain_group():
|
||||
MediaTypes.chain_group("zzztest.composite", ["zzztest.composite_child"])
|
||||
MediaTypes.register("zzztest.composite_child", ".zzzcomposite", "SEARCH")
|
||||
|
||||
assert MediaTypes.contains("zzztest.composite", ".zzzcomposite", "SEARCH")
|
||||
|
||||
|
||||
def test_equivalent_extensions():
|
||||
MediaTypes.register("zzztest.equiv", [".zzzone", ".zzztwo"], "SEARCH")
|
||||
|
||||
assert MediaTypes.get_equivalent_exts(".zzzone") == {".zzzone", ".zzztwo"}
|
||||
assert MediaTypes.get_equivalent_exts(".zzztwo") == {".zzzone", ".zzztwo"}
|
||||
assert MediaTypes.contains("zzztest.equiv", ".zzzone", "SEARCH")
|
||||
assert MediaTypes.contains("zzztest.equiv", ".zzztwo", "SEARCH")
|
||||
|
||||
|
||||
def test_get_equivalent_exts_defaults_to_itself():
|
||||
assert MediaTypes.get_equivalent_exts(".zzzunregistered") == {".zzzunregistered"}
|
||||
|
||||
|
||||
def test_find():
|
||||
MediaTypes.register("zzztest.find_a", ".zzzfind", "SEARCH")
|
||||
MediaTypes.register("zzztest.find_b", ".zzzfind", "RENDER")
|
||||
|
||||
search_keys = {group.key for group in MediaTypes.find(".zzzfind", "SEARCH")}
|
||||
render_keys = {group.key for group in MediaTypes.find(".zzzfind", "RENDER")}
|
||||
|
||||
assert "zzztest.find_a" in search_keys
|
||||
assert "zzztest.find_a" not in render_keys
|
||||
assert "zzztest.find_b" in render_keys
|
||||
assert "zzztest.find_b" not in search_keys
|
||||
|
||||
|
||||
def test_add_name_aliases_and_lookup():
|
||||
MediaTypes.register("zzztest.alias_target", ".zzzalias", "SEARCH")
|
||||
MediaTypes.add_name_aliases("zzztest.alias_target", ["ZZZ Test Group", "zzztest"])
|
||||
|
||||
assert MediaTypes.get_group_key_from_name("ZZZ Test Group") == "zzztest.alias_target"
|
||||
assert MediaTypes.get_group_key_from_name("zzz test group", case_sensitive=False) == (
|
||||
"zzztest.alias_target"
|
||||
)
|
||||
assert (
|
||||
MediaTypes.get_group_key_from_name(
|
||||
"zzztestGroup", case_sensitive=False, ignore_whitespace=True
|
||||
)
|
||||
== "zzztest.alias_target"
|
||||
)
|
||||
assert MediaTypes.get_group_key_from_name("Not A Real Alias") is None
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from tagstudio.qt.app_settings import Theme
|
||||
from tagstudio.core.enums import Theme
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
Reference in New Issue
Block a user