Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27bd1622b7 | |||
| e2b54ef3ff | |||
| 70f1a3d975 | |||
| f978ae1ea1 | |||
| 33199f5b26 | |||
| 8275d3e74b | |||
| 4a3a767baf | |||
| f5a30489d7 | |||
| 112b27f059 | |||
| c32f500f3c | |||
| f89f0a6c7f | |||
| c33455b7a0 | |||
| 12fd4933be | |||
| 7e7def5a52 | |||
| 17cf87a4fa | |||
| 3fe7922642 | |||
| 555dae50d4 | |||
| e6d67c26dd | |||
| 435f5ff2b6 | |||
| 3cebb211b3 | |||
| 512bf623c2 | |||
| 30c81257fa | |||
| fcd4d67711 | |||
| b44ab8f161 | |||
| 56a0b73cbd | |||
| 3ce65552f7 | |||
| 49e320618c | |||
| 102cfdf4a2 | |||
| 47b123569e | |||
| 09a54d5dfc | |||
| 7782d2438f | |||
| 92e29635c7 | |||
| 6e288afd8c | |||
| 354c0a0afc | |||
| dd00f4d4a0 | |||
| 1bfea60d7a | |||
| c015b1d4f2 | |||
| 4b200a674e | |||
| 507823bfcd | |||
| 3def6c5239 | |||
| f04421fa93 | |||
| b29e612e7f | |||
| 5852c59f1c | |||
| 27fd1a4518 | |||
| 8722c5f83e | |||
| f52e0fa568 | |||
| 4de9571d0b | |||
| ed0332b0e2 | |||
| 13104381a4 | |||
| 7d2135c1f7 | |||
| 6177befebb | |||
| ff43c4a4bd | |||
| ac4c28687e | |||
| 5557fe4f7c | |||
| 2fdd1e4cb3 | |||
| 0d0c54e09f | |||
| f24653e030 | |||
| c30dee799a | |||
| 15230cc369 | |||
| c3b6bec2ff | |||
| 58496a7d2d | |||
| dc4251ff55 | |||
| 174262b9b3 | |||
| 27d761731c | |||
| 51a9c16f50 | |||
| 6aa0cf74f9 | |||
| 49b450c3a4 | |||
| a1dfa62e4a | |||
| aa2d9d4815 | |||
| 16cfa8d2ff | |||
| 9d5200b2f2 | |||
| f252a86fd5 | |||
| a0fb679729 | |||
| b182b2ff7e | |||
| 308b36b31e |
@@ -18,7 +18,7 @@ runs:
|
||||
if: inputs.skip-setup != 'true'
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Setup uv install
|
||||
if: inputs.skip-setup != 'true'
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
- .github/actions/setup-python/action.yml
|
||||
- .github/workflows/build_docs.yml
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
- properdocs.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -50,7 +50,5 @@ jobs:
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- name: Run mkdocs
|
||||
env:
|
||||
DISABLE_MKDOCS_2_WARNING: 'true'
|
||||
run: mkdocs gh-deploy --force
|
||||
- name: Run properdocs
|
||||
run: properdocs gh-deploy --force
|
||||
|
||||
@@ -8,26 +8,29 @@ on:
|
||||
paths: &on_paths
|
||||
- .github/actions/setup-python/action.yml
|
||||
- .github/workflows/checks_python.yml
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
- .editorconfig
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- 'src/tagstudio/resources/**'
|
||||
- '**.pyi?'
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
paths: *on_paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
run-conditions:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
name: Run Conditions
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -36,14 +39,25 @@ jobs:
|
||||
ruff: ${{ steps.run-conditions.outputs.ruff }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Largest positive number; infinite depth.
|
||||
# Using 0 would grab all branches.
|
||||
# See: https://github.com/actions/checkout/issues/520
|
||||
# See: https://stackoverflow.com/questions/6802145/how-to-convert-a-git-shallow-clone-to-a-full-clone/6802238#6802238
|
||||
# `git fetch --unshallow` as suggested in later answers would be an extra operation.
|
||||
fetch-depth: '2147483647'
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v47.0.6
|
||||
with:
|
||||
fail_on_initial_diff_error: 'true'
|
||||
fail_on_submodule_diff_error: 'true'
|
||||
skip_initial_fetch: 'true'
|
||||
|
||||
# WARNING: Does not support `?` glob operand!
|
||||
files_yaml: |
|
||||
generic:
|
||||
- .github/workflows/checks_python.yml
|
||||
@@ -55,20 +69,51 @@ jobs:
|
||||
- .github/actions/setup-python/action.yml
|
||||
pytest:
|
||||
- .github/actions/setup-python/action.yml
|
||||
- 'src/tagstudio/resources/**'
|
||||
- src/**/resources/**
|
||||
- src/**/*.json
|
||||
- src/**/*.qrc
|
||||
- tests/**
|
||||
ruff:
|
||||
- .editorconfig
|
||||
|
||||
- name: Set run conditions
|
||||
id: run-conditions
|
||||
env:
|
||||
CHANGED_GENERIC: ${{ steps.changed-files.outputs.generic_any_changed }}
|
||||
CHANGED_PYRIGHT: ${{ steps.changed-files.outputs.pyright_any_changed }}
|
||||
CHANGED_PYTEST: ${{ steps.changed-files.outputs.pytest_any_changed }}
|
||||
CHANGED_RUFF: ${{ steps.changed-files.outputs.ruff_any_changed }}
|
||||
run: |
|
||||
pyright=false
|
||||
pytest=false
|
||||
ruff=false
|
||||
if [ "${CHANGED_GENERIC}" = true ]; then
|
||||
pyright=true
|
||||
pytest=true
|
||||
ruff=true
|
||||
else
|
||||
if [ "${CHANGED_PYRIGHT}" = true ]; then
|
||||
pyright=true
|
||||
fi
|
||||
if [ "${CHANGED_PYTEST}" = true ]; then
|
||||
pytest=true
|
||||
fi
|
||||
if [ "${CHANGED_RUFF}" = true ]; then
|
||||
ruff=true
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF >>"${GITHUB_OUTPUT}"
|
||||
pyright=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pyright_any_changed }}
|
||||
pytest=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pytest_any_changed }}
|
||||
ruff=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.ruff_any_changed }}
|
||||
pyright=${pyright}
|
||||
pytest=${pytest}
|
||||
ruff=${ruff}
|
||||
EOF
|
||||
|
||||
check-pyright:
|
||||
concurrency:
|
||||
group: pyright-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Pyright
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.pyright == 'true'
|
||||
@@ -89,6 +134,9 @@ jobs:
|
||||
run: pyright
|
||||
|
||||
check-pytest:
|
||||
concurrency:
|
||||
group: ${{ matrix.os }}-pytest-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -151,6 +199,10 @@ jobs:
|
||||
run: pytest
|
||||
|
||||
check-ruff:
|
||||
concurrency:
|
||||
group: ruff-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
name: Ruff
|
||||
needs: run-conditions
|
||||
if: needs.run-conditions.outputs.ruff == 'true'
|
||||
@@ -158,11 +210,18 @@ jobs:
|
||||
steps:
|
||||
- *checkout
|
||||
|
||||
- name: Setup Ruff
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
with:
|
||||
# No-op operation, since executing Ruff cannot be disabled for the action.
|
||||
# Note that `--version` has different behavior than `version`, as the
|
||||
# latter will fail if passed any extra args, even if that arg is empty.
|
||||
args: --version
|
||||
src: ''
|
||||
|
||||
- parallel:
|
||||
- name: Run Ruff linter
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
run: ruff check
|
||||
|
||||
- name: Run Ruff formatter
|
||||
uses: astral-sh/ruff-action@v4.0.0
|
||||
with:
|
||||
args: format --check
|
||||
run: ruff format --check
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
---
|
||||
name: REUSE Compliance Check
|
||||
|
||||
on: [pull_request, push]
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- translations
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: reuse
|
||||
name: reuse
|
||||
entry: reuse lint-file
|
||||
language: system
|
||||
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: pyright
|
||||
|
||||
@@ -8,7 +8,7 @@ path = [
|
||||
"docs/CNAME",
|
||||
"docs/assets/**",
|
||||
"src/tagstudio/qt/resources.json",
|
||||
"src/tagstudio/resources/icon.*",
|
||||
"src/tagstudio/resources/icon*.*",
|
||||
"src/tagstudio/resources/tagstudio.desktop",
|
||||
"src/tagstudio/resources/templates/ts_ignore_template.txt",
|
||||
"src/tagstudio/resources/templates/ts_ignore_template_blank.txt",
|
||||
@@ -45,6 +45,13 @@ SPDX-FileCopyrightText = "(c) 2026 Boxicons"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
[[annotations]]
|
||||
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
|
||||
path = [
|
||||
"src/tagstudio/resources/qt/images/dupe_file_stat.png",
|
||||
"src/tagstudio/resources/qt/images/hint_field_add.png",
|
||||
"src/tagstudio/resources/qt/images/hint_field_create.png",
|
||||
"src/tagstudio/resources/qt/images/hint_tag_add.png",
|
||||
"src/tagstudio/resources/qt/images/hint_tag_added.png",
|
||||
"src/tagstudio/resources/qt/images/hint_tag_create.png",
|
||||
]
|
||||
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
|
||||
SPDX-License-Identifier = "Apache-2.0"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}/src/tagstudio/main.py",
|
||||
"program": "${workspaceRoot}/src/tagstudio/__main__.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": ["-o", "~/Documents/Example"]
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -9,6 +9,96 @@ toc_depth: 2
|
||||
|
||||
# :material-script-text: Changelog
|
||||
|
||||
## 9.6.3 <small>August 15th, 2026</small>
|
||||
|
||||
This update includes some critical library bugfixes along with a handful QoL tweaks and additions to the tag/field search bars. The [documentation](https://docs.tagstud.io/usage/#tagging) on this feature has been updated to include the new improvements.
|
||||
|
||||
### Added
|
||||
|
||||
- feat(ui): show library format version in "About" window by @CyanVoxel in 102cfdf4a2e12635f4b6e47259aa23ea95787421
|
||||
|
||||
#### Tag and Field Bars
|
||||
|
||||
- feat(ui): keep tag/field search bars open by default by @CyanVoxel in #1472
|
||||
- feat(ui): translate vertical scrolling to horizontal in tag/field search bars by @CyanVoxel in #1473
|
||||
- feat(ui): use tab and shift+tab to navigate tag/field search bars by @CyanVoxel in #1474
|
||||
- feat(ui): add hint icons to tag/field search bars by @CyanVoxel in #1475
|
||||
|
||||
### Changed
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- refactor: almost final migrations cleanup by @Computerdores in #1456
|
||||
|
||||
#### Translations
|
||||
|
||||
- **French** updated by @kitsumed
|
||||
- **Hebrew** updated by @Tatrask
|
||||
- **Hungarian** updated by @smileyhead
|
||||
- **Japanese** updated by @coolzdaddy55-art
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix: fix being unable to create new colors by @CyanVoxel in dd00f4d4a02fd74b24d40c51b55ae3cc7db09203
|
||||
- fix(ui): increase spacing between color palettes by @CyanVoxel in 354c0a0afcdaae2e490dd6c1eca5a556e7724aea
|
||||
- fix: fix infinite loading in various refresh operations by @CyanVoxel in 3ce65552f7d459a2edebadb29cf1c17de6a1c742
|
||||
- fix(ui): fix update popup showing after closing main window by @CyanVoxel in 30c81257fa3882376111ec03637bd6f517500908
|
||||
- fix(ui): fix accent color issues on Windows by @CyanVoxel in #1477
|
||||
|
||||
---
|
||||
|
||||
## 9.6.2 <small>August 6th, 2026</small>
|
||||
|
||||
### Added
|
||||
|
||||
- feat(thumbs): render `.ai` (Adobe Illustrator) thumbnails by @purpletennisball in #1453
|
||||
|
||||
### Changed
|
||||
|
||||
#### Tag & Field Search/Create Bars (by @CyanVoxel in #1451)
|
||||
|
||||
The tagging workflow has gotten a major overhaul! Clicking the "Add Tag" button or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> will now activate an inline tag search bar that you can also use to quickly create and apply new or existing tags! Creating new tags in an empty library is now as simple as clicking the "Add Tag" button, typing in the name of a tag you wish to create, and hitting <kbd>Enter</kbd>! The whole workflow was redone with both casual and power users in mind, and is entirely keyboard friendly. And fields have also gotten the same treatment!
|
||||
|
||||
{ width=45% } { width=45% }
|
||||
{ width=45% } { width=45% }
|
||||
|
||||
You can read the brief feature overview below or read the updated [documentation](https://docs.tagstud.io/usage/#tagging) to see the full changes and features:
|
||||
|
||||
- Open tagging bar clicking "Add Tag" or using <kbd>Ctrl</kbd>+<kbd>T</kbd>
|
||||
- Open field bar clicking "Add Field" or using <kbd>Ctrl</kbd>+<kbd>L</kbd>
|
||||
- When searching, the underlined tag/field template will be added on <kbd>Enter</kbd>
|
||||
- With no search results, a new tag or field template will be created on <kbd>Enter</kbd> and added to the selection
|
||||
- Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will force create a new tag/field template, regardless of the search results
|
||||
- An option to always open an Edit window for new tags after creation is available by right-clicking the search bar or by going to the Settings (**off** by default)
|
||||
- An option to always open an Edit window for field content after applying a template is available by right-clicking the search bar or by going to the Settings (**on** by default)
|
||||
- Field templates are _always_ edited after being created
|
||||
- The classic tag search panel can be accessed from **File -> Add Tag to Selected** / <kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>T</kbd>
|
||||
|
||||
#### Internal Changes
|
||||
|
||||
- refactor: split out sql migrations from library by @Computerdores in #1432
|
||||
- refactor: remove dead folders table by @Computerdores in #1444
|
||||
- ci(docs): migrate from mkdocs to properdocs by @CyanVoxel in #1459
|
||||
- refactor(ui): refactor PanelModal and PanelWidget into MVC Modal, ModalView, and ModalContent classes by @CyanVoxel in #1454
|
||||
- refactor(ui): apply new MVC guidelines to search panels by @CyanVoxel in #1461
|
||||
- refactor: split ThumbRenderer class by @CyanVoxel in #1462
|
||||
|
||||
#### Translations
|
||||
|
||||
- **French** updated by @kitsumed
|
||||
- **Hungarian** updated by @smileyhead
|
||||
- **Chinese (Simplified Han Script)** updated by ???
|
||||
- **Spanish** updated by @2004milenadiaz-source, @JulArr22
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix: thumbnail rendering for older pxd files by @purpletennisball in #1441
|
||||
- fix: correctly display duration statistic in preview panel by @ludvig-sandh in #1421
|
||||
- fix: fix new aliases not saving on new tags by @CyanVoxel in #1455
|
||||
- fix: don't update media playback when adding tags from menu modal by @CyanVoxel in f52e0fa56816ae43c4faf01b61d46ee8f49b206f
|
||||
|
||||
---
|
||||
|
||||
## 9.6.1 <small>July 9th, 2026</small>
|
||||
|
||||
### Added
|
||||
|
||||
@@ -117,7 +117,7 @@ Licensing is now accomplished using the [REUSE](https://reuse.software/spec-3.3/
|
||||
!!! tip "PR Scope"
|
||||
If you're unsure where to stop the scope of your PR, ask yourself: _"If I broke this up, could any parts of it still be used by the project in the meantime?"_
|
||||
|
||||
### Workflow Checks
|
||||
### :material-check-circle: Workflow Checks
|
||||
|
||||
When pushing your code, several automated [workflows](https://github.com/TagStudioDev/TagStudio/tree/main/.github/workflows) will check it against predefined tests and style checks. It's _highly recommended_ that you run these checks locally beforehand to avoid having to fight back-and-forth with the workflow checks inside your pull requests. These checks currently include:
|
||||
|
||||
@@ -126,7 +126,7 @@ When pushing your code, several automated [workflows](https://github.com/TagStud
|
||||
- [Pytest](developing.md#pytest) tests
|
||||
- REUSE [license compliance](#licenses)
|
||||
|
||||
### Runtime Requirements
|
||||
## :material-timer-play: Runtime Requirements
|
||||
|
||||
Code must function on all of the supported operating systems and versions:
|
||||
|
||||
@@ -134,18 +134,8 @@ Code must function on all of the supported operating systems and versions:
|
||||
- macOS 14.0+
|
||||
- Common Linux distributions and versions
|
||||
|
||||
## :material-file-document: Documentation Guidelines
|
||||
Final submitted code must **_NOT:_**
|
||||
|
||||
Documentation contributions include anything inside of the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/).
|
||||
|
||||
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
|
||||
- Follow the folder structure pattern
|
||||
- Don't add images or other media with excessively large file sizes
|
||||
- Provide alt text for embedded media
|
||||
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
|
||||
|
||||
## :material-translate: Translation Guidelines
|
||||
|
||||
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
|
||||
|
||||
_Translation guidelines coming soon._
|
||||
- Contain superfluous or unnecessary logging statements
|
||||
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
|
||||
- Cause undesirable visual glitches or artifacts on screen
|
||||
|
||||
@@ -16,7 +16,7 @@ If you wish to develop for TagStudio, you'll need to create a development enviro
|
||||
|
||||
## Installing Python
|
||||
|
||||
Python [3.12](https://www.python.org/downloads) is required to develop for TagStudio. Any version matching "Python 3.12.x" should work, with "x" being any number. Alternatively you can use a tool such as [pyenv](https://github.com/pyenv/pyenv) to install this version of Python without affecting any existing Python installations on your system. Tools such as [uv](#installing-with-uv) can also install Python versions.
|
||||
Python [3.14](https://www.python.org/downloads) is required to develop for TagStudio. Any version matching "Python 3.14.x" should work, with "x" being any number. Alternatively you can use a tool such as [pyenv](https://github.com/pyenv/pyenv) to install this version of Python without affecting any existing Python installations on your system. Tools such as [uv](#installing-with-uv) can also install Python versions.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Python Aliases"
|
||||
@@ -35,8 +35,8 @@ python --version
|
||||
If you choose to install Python using pyenv, please refer to the following instructions:
|
||||
|
||||
1. Follow pyenv's [install instructions](https://github.com/pyenv/pyenv/?tab=readme-ov-file#installation) for your system.
|
||||
2. Install the appropriate Python version with pyenv by running `pyenv install 3.12` (This will **not** mess with your existing Python installation).
|
||||
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.12`. You could alternatively use `pyenv shell 3.12` or `pyenv global 3.12` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
|
||||
2. Install the appropriate Python version with pyenv by running `pyenv install 3.14` (This will **not** mess with your existing Python installation).
|
||||
3. Navigate to the repository root folder in your terminal and run `pyenv local 3.14`. You could alternatively use `pyenv shell 3.14` or `pyenv global 3.14` instead to set the Python version for the current terminal session or the entire system respectively, however using `local` is recommended.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +118,7 @@ If you choose to manually set up a virtual environment and install dependencies
|
||||
!!! Warning "Linux Library Dependencies"
|
||||
If developing TagStudio on Linux, certain libraries are required that may not be included with your distribution. A full list of these can be found [here](install.md#linux).
|
||||
|
||||
## Nix(OS)
|
||||
## Nix & NixOS
|
||||
|
||||
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
|
||||
|
||||
@@ -132,7 +132,7 @@ A reference `.envrc` is provided for use with [direnv](#direnv), see [`contrib/.
|
||||
|
||||
### Editor Integration
|
||||
|
||||
The entry point for TagStudio is `src/tagstudio/main.py`. You can target this file from your IDE to run or connect a debug session. The example(s) below show off example launch scripts for different IDEs. Here you can also take advantage of [launch arguments](./usage.md/#launch-arguments) to pass your own test [libraries](libraries.md) to use while developing. You can find more editor configurations in [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
|
||||
The entry point for TagStudio is `src/tagstudio/__main__.py`. You can target this file from your IDE to run or connect a debug session. The example(s) below show off example launch scripts for different IDEs. Here you can also take advantage of [launch arguments](./usage.md/#launch-arguments) to pass your own test [libraries](libraries.md) to use while developing. You can find more editor configurations in [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
=== "VS Code"
|
||||
@@ -144,7 +144,7 @@ The entry point for TagStudio is `src/tagstudio/main.py`. You can target this fi
|
||||
"name": "TagStudio",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceRoot}/src/tagstudio/main.py",
|
||||
"program": "${workspaceRoot}/src/tagstudio/__main__.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"args": ["-o", "~/Documents/Example"]
|
||||
@@ -214,7 +214,7 @@ From there, Git will automatically run through the hooks during commit actions!
|
||||
|
||||
You can automatically enter this development shell, and keep your user shell, with a tool like [direnv](https://direnv.net/). Some reference `.envrc` files are provided in the repository at [`contrib`](https://github.com/TagStudioDev/TagStudio/tree/main/contrib).
|
||||
|
||||
Two currently available are for [Nix](#nixos) and [uv](#installing-with-uv), to use one:
|
||||
Two currently available are for [Nix](#nix-nixos) and [uv](#installing-with-uv), to use one:
|
||||
|
||||
```sh
|
||||
ln -s .envrc-$variant .envrc
|
||||
|
||||
@@ -16,11 +16,11 @@ File entry data is stored within the `ts_library.sqlite` file inside each librar
|
||||
|
||||
## Appearance
|
||||
|
||||
File entries appear as thumbnails inside the grid display. The preview panel shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
|
||||
File entries appear as thumbnails inside the grid display. The inspector shows a more detailed preview of the file, along with extra file stats and all attached TagStudio tags and fields.
|
||||
|
||||
## Unlinked Entries
|
||||
|
||||
If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the preview panel.
|
||||
If the file that an entry is referencing has been moved, renamed, or deleted on disk, then TagStudio will display its unlinked status with a red chain-link icon instead of its thumbnail image. Certain uncached stats such as the file size and image dimensions will also be unavailable to see in the inspector.
|
||||
|
||||
To fix file entries that have become unlinked, select the "Fix Unlinked Entries" option from the Tools menu. From there, refresh the unlinked entry count and choose whether to search and relink you files, and/or delete the file entries from your library. This will NOT delete or modify any files on disk.
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ icon: material/text-box
|
||||
|
||||
Fields are extra pieces of information you can add to [file entries](./entries.md), similar to how [tags](tags.md) are added to entries. Fields are useful for storing information that doesn't nessisarily need to be a tag, such as titles, comments, notes, specific dates or times, etc.
|
||||
|
||||
To add a field to an entry, click the "Add Field" button in the preview panel. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
|
||||
Unlike tags, fields are based on [templates](#templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
|
||||
|
||||
To add a field to an entry, click the "Add Field" button at the bottom of the inspector. From there you can search and/or select a [field template](#field-templates) to choose from, or create a new one from the search bar. Alternatively you can create new field templates from **Edit -> Manage Field Templates**.
|
||||
|
||||
<figure markdown="span">
|
||||

|
||||
|
||||
@@ -27,12 +27,12 @@ hide:
|
||||
|
||||

|
||||
|
||||
**TagStudio** is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
|
||||
<span style="font-family: Bai Jamjuree, Roboto, sans-serif; font-size: 1.1rem; letter-spacing: -0.05rem;"><span style="font-weight: 900;">Tag</span><span style="font-weight: 500;"><i>Studio</i></span></span> is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
|
||||
|
||||
</div>
|
||||
|
||||
<figure markdown="span">
|
||||
[:material-download: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
|
||||
[:material-github: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
|
||||
</figure>
|
||||
|
||||
## :material-star: Core Features
|
||||
|
||||
@@ -36,7 +36,7 @@ TagStudio has builds for :fontawesome-brands-windows: **Windows**, :fontawesome-
|
||||
|
||||
### :fontawesome-brands-python: Installing with PIP
|
||||
|
||||
TagStudio is installable via [PIP](https://pip.pypa.io/). Note that since we don't currently distribute on PyPI, the repository needs to be cloned and installed locally. Make sure you have Python 3.12 and PIP installed if you choose to install using this method.
|
||||
TagStudio is installable via [PIP](https://pip.pypa.io/). Note that since we don't currently distribute on PyPI, the repository needs to be cloned and installed locally. Make sure you have Python 3.14 and PIP installed if you choose to install using this method.
|
||||
|
||||
The repository can be cloned/downloaded via `git` in your terminal, or by downloading the zip file from the "Code" button on the [repository page](https://github.com/TagStudioDev/TagStudio).
|
||||
|
||||
@@ -95,9 +95,9 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
|
||||
Aborted (core dumped)
|
||||
```
|
||||
|
||||
### :material-nix: Nix(OS)
|
||||
### :material-nix: Nix & NixOS
|
||||
|
||||
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
|
||||
For [Nix](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
|
||||
|
||||
Two packages are provided: `tagstudio` and `tagstudio-jxl`. The distinction was made because `tagstudio-jxl` has an extra compilation step for [JPEG-XL](https://jpeg.org/jpegxl) image support. To give either of them a test run, you can execute `nix run github:TagStudioDev/TagStudio#tagstudio`. If you are in a cloned repository and wish to run a package with the context of the repository, you can simply use `nix run` with no arguments.
|
||||
|
||||
@@ -256,4 +256,4 @@ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extracto
|
||||
|
||||
### ripgrep
|
||||
|
||||
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
|
||||
A recommended tool to improve the performance of directory scanning is [ripgrep](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
|
||||
|
||||
@@ -201,3 +201,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
|
||||
|
||||
#### Version 300
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
| ---------------------------------------- |-------------------------------------------------------------------------| ------ |
|
||||
| 51a9c16f50ca785d810911d2d0c83fa33eb1c0ae | [v9.6.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.2) | SQLite |
|
||||
|
||||
- Drops `folder` columns from the `entries` table.
|
||||
- Drops the unused `folders` table.
|
||||
|
||||
#### Version 400
|
||||
|
||||
| Added in Commit | Introduced in Release | Format |
|
||||
|-----------------|-----------------------| ------ |
|
||||
| TBD | TBD | SQLite |
|
||||
|
||||
- Adds the `category_exclusion` table.
|
||||
|
||||
@@ -98,7 +98,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
|
||||
- [x] Translations
|
||||
- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Improved Tag Autocomplete :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Tags appear as widgets in search bar :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Tags appear as widgets in search bar _(similar to new tag search/create bar)_ :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [x] Unified Media Player
|
||||
- [x] Auto-Hiding Player Controls
|
||||
- [x] Play/Pause
|
||||
@@ -116,7 +116,7 @@ A detailed specification written for the TagStudio tag and/or library format. In
|
||||
- [x] Basic Support
|
||||
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Top Tags
|
||||
- [ ] Recent Tags
|
||||
- [ ] Tag Search
|
||||
@@ -203,8 +203,8 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
- [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Tint Icons with Text Color :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [x] [Category Property](tags.md#is-category) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the preview panel under a title
|
||||
- [ ] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Property available for tags that allow the tag and any inheriting from it to be displayed separately in the inspector under a title
|
||||
- [x] Fine-tuned exclusion from categories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Hidden Property **[[v9.5.7](changelog.md#957-may-5th-2026)]**
|
||||
- [x] Built-in "Archived" tag has this property by default **[[v9.5.7](changelog.md#957-may-5th-2026)]**
|
||||
- [x] Checkbox near search bar to show hidden tags in search **[[v9.5.7](changelog.md#957-may-5th-2026)]**
|
||||
|
||||
@@ -6,116 +6,208 @@ icon: material/sign-text
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! abstract "Prerequisite Reading"
|
||||
This guide assumes you've read the [Developing](developing.md) and [Contributing](contributing.md) pages first.
|
||||
|
||||
# :material-sign-text: Style Guide
|
||||
|
||||
## Formatting
|
||||
## :material-script-text: General Principles
|
||||
|
||||
Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._
|
||||
- Write **clear**, **concise**, and **modular** code.
|
||||
- If the purpose of a peice of code is not obvious, a **short comment** should help explain it.
|
||||
- Remember to follow the rest of the [contribution guidelines](contributing.md)!
|
||||
|
||||
- Do your best to write clear, concise, and modular code.
|
||||
- This should include making methods private by default (e.g. `__method()`)
|
||||
- Methods should only be protected (e.g. `_method()`) or public (e.g. `method()`) when needed and warranted
|
||||
- Keep a maximum column width of no more than **100** characters.
|
||||
- Code comments should be used to help describe sections of code that can't speak for themselves.
|
||||
- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add.
|
||||
- If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;)
|
||||
- Imports should be ordered alphabetically.
|
||||
- Lists of values should be ordered using their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order).
|
||||
- Some files have their methods ordered alphabetically as well (i.e. [`thumb_renderer`](https://github.com/TagStudioDev/TagStudio/blob/main/src/tagstudio/qt/widgets/thumb_renderer.py)). If you're working in a file and notice this, please try and keep to the pattern.
|
||||
- When writing text for window titles or form titles, use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" capitalization. Your IDE may have a command to format this for you automatically, although some may incorrectly capitalize short prepositions. In a pinch you can use a website such as [capitalizemytitle.com](https://capitalizemytitle.com/) to check.
|
||||
- If it wasn't mentioned above, then stick to [**PEP-8**](https://peps.python.org/pep-0008/)!
|
||||
---
|
||||
|
||||
### Modules & Implementations
|
||||
## :material-text-box-check: Formatting
|
||||
|
||||
- **Do not** modify legacy library code in the `src/core/library/json/` directory
|
||||
- Avoid direct calls to `os`
|
||||
- Use `Pathlib` library instead of `os.path`
|
||||
- Use `platform.system()` instead of `os.name` and `sys.platform`
|
||||
- Don't prepend local imports with `tagstudio`, stick to `src`
|
||||
- Use the `logger` system instead of `print` statements
|
||||
- Avoid nested f-strings
|
||||
- Use HTML-like tags inside Qt widgets over stylesheets where possible
|
||||
|
||||
Final submitted code must **_NOT:_**
|
||||
|
||||
- Contain superfluous or unnecessary logging statements
|
||||
- Cause unreasonable slowdowns to the program outside of a progress-indicated task
|
||||
- Cause undesirable visual glitches or artifacts on screen
|
||||
|
||||
### Formatter Configs
|
||||
Linting in Python files is mostly taken care of by [ruff](developing.md#ruff) and is based on the rules declared in `pyproject.toml` and `.editorconfig`.
|
||||
|
||||
TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) file ([`.editorconfig`](https://github.com/TagStudioDev/TagStudio/blob/main/.editorconfig)) along with a [Prettier](https://prettier.io/) config file ([`.prettierrc.toml`](https://github.com/TagStudioDev/TagStudio/blob/main/.prettierrc.toml)) for formatting files other than .py files (Markdown, JSON, YAML, HTML, CSS, etc.). If editing these types of files it's recommended that you use a formatter that supports EditorConfig or has its settings matched to the EditorConfig and Prettier configs. Lastly, please pay attention to the `prettier-ignore` flags in present in some files if you are not using Prettier, as formatting these sections will break formatting used elsewhere such as the [MkDocs site](https://docs.tagstud.io/).
|
||||
|
||||
## Qt
|
||||
### :material-code-braces-box: Syntax Guidelines
|
||||
|
||||
As of writing this section, the QT part of the code base is quite unstructured and the View and Controller parts are completely intermixed[^1]. This makes maintenance, fixes and general understanding of the code base quite challenging, because the interesting parts you are looking for are entangled in a bunch of repetitive UI setup code. To address this we are aiming to more strictly separate the view and controller aspects of the QT frontend.
|
||||
- Python files should always follow the [**PEP 8**](https://peps.python.org/pep-0008/) style guide conventions, unless specifically allowed otherwise.
|
||||
- The most notable exception in our project is the line length limit of **100** characters, which is enforced via ruff.
|
||||
- Internal Qt methods also use `camelCase` instead of `snake_case`, so overrides of those are commonly seen in the codebase.
|
||||
- Classes and attributes considered to be "[private](https://docs.python.org/3/tutorial/classes.html#private-variables)" should be prepended with a **single underscore** (e.g. `_internal_method()`).
|
||||
- If _functionally necessary_, an attribute name may be prepended with a double underscore to trigger "[name mangling](https://docs.python.org/3/reference/expressions.html#private-name-mangling)" (e.g. `__mangled_method()`).
|
||||
- Classes and methods should contain [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings _(this style is enforced via ruff)_.
|
||||
- Lists and JSON keys should be ordered by their [natural sort order](https://en.wikipedia.org/wiki/Natural_sort_order) unless otherwise specified or readily indicated.
|
||||
- Some files have some or all of their attributes sorted. Please respect any established patterns like these in files you modify.
|
||||
|
||||
The general structure of the QT code base should look like this:
|
||||
---
|
||||
|
||||
## :material-filter-cog: Modules & Systems
|
||||
|
||||
### :fontawesome-brands-python: Python Modules
|
||||
|
||||
- Use `Pathlib` library instead of `os.path`
|
||||
- Use `platform.system()` instead of `os.name` or `sys.platform`
|
||||
- Avoid nested f-strings
|
||||
|
||||
### :material-tag: TagStudio Systems
|
||||
|
||||
- Translation keys can be accessed via bracket notation (e.g. `Translations["translation_key"]`) or with the `Translations.format()` method when a value needs to be passed to a placeholder in the translation.
|
||||
- Avoid passing around the `QtDriver` class where possible. Instead, pass only the necessary components such as the `Library` and `AppSettings` instances.
|
||||
- Use HTML-like tags inside strings over explicit stylesheets where possible. The `Style` class provides several handy methods for formatting text with these.
|
||||
- Use the `format` method in the stylesheets class to format text headers.
|
||||
|
||||
---
|
||||
|
||||
## :material-folder-file: Project Layout
|
||||
|
||||
### :material-engine: Core <small>Backend</small>
|
||||
|
||||
Code that is integral to the core functionality of TagStudio and is UI-independent belongs under the `core/` directory. It's possible that some code that serves the UI can go here, as long as its purpose is to serve _any_ UI and is independent from Qt (e.g. file preview rendering).
|
||||
|
||||
```yaml title="Core Backend Directory Example"
|
||||
core/
|
||||
│
|
||||
├── library/ # The TagStudio library system
|
||||
│ │
|
||||
│ ├── alchemy/ # Current SQLite backend w/ SQLAlchemy ORM
|
||||
│ │
|
||||
│ ├── json/ # Read-only legacy JSON library system, kept for migrations
|
||||
│ │
|
||||
│ ├── query_lang/ # The query parser
|
||||
│ │
|
||||
│ │ # Library files that do not involve the SQLAlchemy ORM
|
||||
│ │ # NOTE: Future Non-SQLAlchemy library files will be placed here
|
||||
│ ├── refresh.py
|
||||
│ └── ...
|
||||
│
|
||||
└── utils/ # Utility classes and functions for the core
|
||||
```
|
||||
qt
|
||||
├── controllers
|
||||
│ ├── widgets
|
||||
│ │ └── preview_panel_controller.py
|
||||
│ └── main_window_controller.py
|
||||
├── views
|
||||
│ ├── widgets
|
||||
│ │ └── preview_panel_view.py
|
||||
│ └── main_window_view.py
|
||||
├── ts_qt.py
|
||||
└── mixed.py
|
||||
```
|
||||
|
||||
In this structure there are the `views` and `controllers` sub-directories. They have the exact same structure and for every `<component>_view.py` there is a `<component>_controller.py` at the same location in the other subdirectory and vice versa.
|
||||
|
||||
Typically the classes should look like this:
|
||||
|
||||
```py
|
||||
# my_cool_widget_view.py
|
||||
class MyCoolWidgetView(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.__button = QPushButton()
|
||||
self.__color_dropdown = QComboBox()
|
||||
# ...
|
||||
self.__connect_callbacks()
|
||||
|
||||
def __connect_callbacks(self):
|
||||
self.__button.clicked.connect(self._button_click_callback)
|
||||
self.__color_dropdown.currentIndexChanged.connect(
|
||||
lambda idx: self._color_dropdown_callback(self.__color_dropdown.itemData(idx))
|
||||
)
|
||||
|
||||
def _button_click_callback(self):
|
||||
raise NotImplementedError()
|
||||
```
|
||||
|
||||
```py
|
||||
# my_cool_widget_controller.py
|
||||
class MyCoolWidget(MyCoolWidgetView):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def _button_click_callback(self):
|
||||
print("Button was clicked!")
|
||||
|
||||
def _color_dropdown_callback(self, color: Color):
|
||||
print(f"The selected color is now: {color}")
|
||||
```
|
||||
|
||||
Observe the following key aspects of this example:
|
||||
|
||||
- The Controller is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code
|
||||
- The UI elements are in private variables
|
||||
- This enforces that the controller shouldn't directly access UI elements
|
||||
- Instead the view should provide a protected API (e.g. `_get_color()`) for things like setting/getting the value of a dropdown, etc.
|
||||
- Instead of `_get_color()` there could also be a `_color` method marked with `@property`
|
||||
- The callback methods are already defined as protected methods with NotImplementedErrors
|
||||
- Defines the interface the callbacks
|
||||
- Enforces that UI events be handled
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file.
|
||||
!!! danger "Read-Only Legacy Code"
|
||||
**Do not modify** legacy library code in the `src/core/library/json/` directory!
|
||||
|
||||
[^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).
|
||||
---
|
||||
|
||||
### :material-button-cursor: App UI <small>Frontend</small>
|
||||
|
||||
The application UI code is stored in the `qt/` directory, and contains all code specific to the Qt frontend. Qt widgets are built using an [MVC](https://www.geeksforgeeks.org/software-engineering/mvc-framework-introduction/) pattern, which is described in-depth below:
|
||||
|
||||
#### MVC Pattern
|
||||
|
||||
- **Models** are usually just objects from the [library](#core-backend).
|
||||
- The **controller** interacts with these, the _view_ does **not**.
|
||||
- **Views** are Qt layout classes that **_only_** contain the **layout** and **styling** for one or more widgets.
|
||||
- Class names are appended with `View`, and filenames appended with `_view`.
|
||||
- Not to be used standalone, but as the layouts for one or more controllers.
|
||||
- Some logic is acceptable in these classes if it serves to modularize the layout and allows controllers to influence how the layout is initialized.
|
||||
- **Reusable Layouts**
|
||||
- If a layout class is **_not meant_** to act as a view but instead be a generic layout, it belongs in the `views/layouts/` directory and the file should be appended with `_layout`.
|
||||
- **Styling Classes**
|
||||
- If a class is purely a source of reusable styling, it belongs in the `views/styles/` directory.
|
||||
|
||||
- **Controllers** are complete widgets or base classes for complete widgets.
|
||||
- Controller files simply take on the name of the final widget they create.
|
||||
- This also creates naming parity with other widgets that simply extend existing Qt widgets with additional logic.
|
||||
|
||||
```yaml title="Qt Frontend Directory Example"
|
||||
qt/
|
||||
│
|
||||
├── controllers/ # Widgets implementing views or extending other widgets
|
||||
│ ├── tag_suggest_box.py # Extends from `suggest_box.py`
|
||||
│ ├── suggest_box.py # Implements `suggest_box_view.py`
|
||||
│ ├── main_window.py
|
||||
│ └── ...
|
||||
│
|
||||
├── mixed/ # Files yet to be refactored into controllers and views
|
||||
│
|
||||
├── views/ # Everything related to widget layouts and appearances
|
||||
│ │
|
||||
│ ├── layouts/ # Layouts meant to be reused on their own inside other layouts
|
||||
│ │ ├── flow_layout.py
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── styles/ # Classes specific for styling
|
||||
│ │ ├── palette.py
|
||||
│ │ ├── stylesheets.py
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ │ # Views (layouts) that get implemented by controllers (widgets)
|
||||
│ ├── main_window_view.py
|
||||
│ ├── suggest_box_view.py
|
||||
│ └── ...
|
||||
│
|
||||
│ # Frontend classes that aren't related to widgets, like managers
|
||||
├── resource_manager.py
|
||||
├── cache_manager.py
|
||||
├── qt_driver.py
|
||||
└── ...
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning "Pre-MVC UI Code"
|
||||
**Do not add** new files to the `qt/mixed/` directory! These files have yet to to be refactored per the current MVC style guidelines and the directory will be **removed** once those migrations have concluded.
|
||||
|
||||
Observe the following key aspects of the example below:
|
||||
|
||||
- The **view** extends from a Qt layout class, and the **controller** simply extends from QWidget.
|
||||
- The **controller** is just called `MyCoolWidget` instead of `MyCoolWidgetController` as it will be directly used by other code.
|
||||
- The **view's** widgets that are intended to be controlled by the controller are **public**, while the **controller's** methods are largely **private**.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! example "MVC-Separated Widget Example"
|
||||
|
||||
```py title="views/my_cool_widget_view.py"
|
||||
class MyCoolWidgetView(QVBoxLayout):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.button = QPushButton()
|
||||
self.color_dropdown = QComboBox()
|
||||
|
||||
self.addWidget(self.button)
|
||||
self.addWidget(self.color_dropdown)
|
||||
```
|
||||
|
||||
```py title="controllers/my_cool_widget.py"
|
||||
class MyCoolWidget(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setLayout(MyCoolWidgetView())
|
||||
self._connect_callbacks()
|
||||
|
||||
def _connect_callbacks(self):
|
||||
self.layout().button.clicked.connect(self._button_click_callback)
|
||||
self.layout().color_dropdown.currentIndexChanged.connect(
|
||||
lambda idx: self._color_dropdown_callback(self.color_dropdown.itemData(idx))
|
||||
)
|
||||
|
||||
def _button_click_callback(self):
|
||||
print("Button was clicked!")
|
||||
|
||||
def _color_dropdown_callback(self, color: Color):
|
||||
print(f"The selected color is now: {color}")
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Tip for Logic Placement"
|
||||
A good rule of thumb is: If there's **conditional logic** after a widget has been created, it should probably go in a **controller**.
|
||||
|
||||
---
|
||||
|
||||
## :material-file-document: Documentation
|
||||
|
||||
Documentation contributions include anything inside the `docs/` folder as well as the `README.md`. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/). Some files such as the `CHANGELOG.md`, `CONTRIBUTING.md`, and `STYLE.md` are symlinked in the repo root from the `docs/` folder.
|
||||
|
||||
- Use "[dash-case / kebab-case](https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case)" for file and folder names
|
||||
- Follow the folder structure pattern
|
||||
- Don't add images or other media with excessively large file sizes
|
||||
- Provide alt text for embedded media
|
||||
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
|
||||
|
||||
---
|
||||
|
||||
## :material-translate: Translations
|
||||
|
||||
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
|
||||
|
||||
- Do not change text inside placeholders
|
||||
- Do not change the style tags inside translations
|
||||
- Use the glossary for term definitions
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
/* Dark Theme */
|
||||
|
||||
[data-md-color-scheme="slate"] {
|
||||
--md-primary-fg-color: rgb(197, 110, 255);
|
||||
--md-accent-fg-color: rgb(92, 222, 255);
|
||||
--md-default-bg-color: #060617;
|
||||
--md-default-fg-color: #eae1ff;
|
||||
--md-default-fg-color--light: #b898ff;
|
||||
--md-code-fg-color: #eae1ffcc;
|
||||
--md-default-fg-color--light: #c2a5ff;
|
||||
--md-code-fg-color: #d8c7ffcc;
|
||||
--md-code-hl-string-color: rgb(92, 255, 228);
|
||||
--md-code-hl-keyword-color: rgb(61, 155, 255);
|
||||
--md-code-hl-constant-color: rgb(205, 78, 255);
|
||||
@@ -17,6 +20,8 @@
|
||||
|
||||
/* Light Theme */
|
||||
[data-md-color-scheme="default"] {
|
||||
--md-primary-fg-color: #7758ff;
|
||||
--md-accent-fg-color: rgb(22, 166, 255);
|
||||
--md-default-fg-color--light: #090a26;
|
||||
}
|
||||
|
||||
@@ -68,11 +73,20 @@
|
||||
border-color: #ffffff33;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5em 1em 0.5em 1em !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-bottom-width: 2px !important;
|
||||
border-color: #9988ff50 !important;
|
||||
}
|
||||
|
||||
.md-typeset ul li ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.1rem;
|
||||
@@ -119,6 +133,18 @@ h2,
|
||||
margin-right: -0.8rem;
|
||||
}
|
||||
|
||||
.md-code__nav,
|
||||
.md-content__button,
|
||||
.headerlink {
|
||||
border-radius: 0.2rem;
|
||||
background: none;
|
||||
color: #9988ff50 !important;
|
||||
}
|
||||
|
||||
.md-code__nav:hover {
|
||||
background-color: #9988ff50;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
@@ -149,6 +175,51 @@ td code {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.admonition {
|
||||
border-radius: 0.4rem !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.highlight > .filename {
|
||||
border-width: 2px !important;
|
||||
border: solid;
|
||||
border-color: #9988ff10 !important;
|
||||
border-radius: 0.4rem 0.4rem 0 0 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 0.4rem !important;
|
||||
border-width: 2px !important;
|
||||
border: solid;
|
||||
border-color: #9988ff10;
|
||||
}
|
||||
|
||||
:is(span, ul, li, td, p, a) > code {
|
||||
border-width: 1px !important;
|
||||
border-radius: 0.1rem !important;
|
||||
}
|
||||
|
||||
.filename + pre > code {
|
||||
border-top-width: 0 !important;
|
||||
border-radius: 0 0 0.4rem 0.4rem !important;
|
||||
}
|
||||
|
||||
.tabbed-labels.tabbed-labels--linked {
|
||||
padding-left: 0.4rem;
|
||||
padding-right: 0.4rem;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.admonition-title {
|
||||
font-size: 0.7rem;
|
||||
font-family: "Bai Jamjuree", Roboto, sans-serif;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.admonition-title span {
|
||||
margin-top: 0.05rem !important;
|
||||
}
|
||||
|
||||
/* Matches the palette used by mkdocs-material */
|
||||
.priority-high {
|
||||
color: #f1185a;
|
||||
|
||||
@@ -29,3 +29,30 @@ h2 {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.grid.cards > ul > li {
|
||||
border-radius: 0.4rem !important;
|
||||
border-width: 2px !important;
|
||||
border-color: #9988ff20 !important;
|
||||
}
|
||||
|
||||
.md-button--primary {
|
||||
margin: 1rem;
|
||||
padding: 0.3rem 1.2rem !important;
|
||||
border-radius: 0.4rem !important;
|
||||
font-size: 1rem;
|
||||
font-family: "Bai Jamjuree", Roboto, sans-serif;
|
||||
background: linear-gradient(60deg, rgb(205, 78, 255) 0%, rgb(116, 123, 255) 100%);
|
||||
border-style: solid;
|
||||
border-width: 0 0 2px 0 !important;
|
||||
border-color: #ffffff33 !important;
|
||||
}
|
||||
|
||||
.md-button--primary:hover {
|
||||
background: linear-gradient(60deg, rgb(205, 78, 255) 30%, rgb(116, 123, 255) 100%);
|
||||
border-color: #ffffff55 !important;
|
||||
}
|
||||
|
||||
.md-button--primary span {
|
||||
margin-top: 0.1rem !important;
|
||||
}
|
||||
|
||||
@@ -100,12 +100,14 @@ Properties are special attributes of tags that change their behavior in some way
|
||||
|
||||
#### Is Category
|
||||
|
||||
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. Tags inheriting from multiple "category tags" will still show up under any applicable category.
|
||||
The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the inspector. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. Tags inheriting from multiple "category tags" will still show up under any applicable category.
|
||||
|
||||
This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature of multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section.
|
||||
|
||||

|
||||
|
||||
If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.
|
||||
|
||||
### Built-In Tags and Categories
|
||||
|
||||
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
|
||||
@@ -116,12 +118,10 @@ Due to the nature of how tags and Tag Felids operated prior to v9.5, the organiz
|
||||
|
||||
#### Is Hidden
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning ""
|
||||
**_Coming in version 9.6.x_**
|
||||
|
||||
When the "Is Hidden" property is checked, any file entries tagged with this tag will not show up in searches by default. This property comes by default with the built-in "Archived" tag.
|
||||
|
||||
---
|
||||
|
||||
## Tag Search Examples
|
||||
|
||||
The following are examples of how a set of given tags will respond to various search queries.
|
||||
|
||||
@@ -17,47 +17,103 @@ To create or open a [library](libraries.md), go to **File -> Open/Create Library
|
||||
TagStudio automatically scans for new or updated files when opening a library by default. Manually refresh by going to **File -> Refresh Directories** in the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "TagStudio Libraries"
|
||||
!!! abstract "TagStudio Libraries"
|
||||
To learn more about how TagStudio libraries work and how to use them, visit the **[Libraries](libraries.md)** page.
|
||||
|
||||
## Adding Tags to File Entries
|
||||
---
|
||||
|
||||
Access the "Add Tag" search box by either clicking on the "Add Tag" button at the bottom of the right sidebar, accessing the "Add Tags to Selected" option from the File menu, or by pressing <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd>.
|
||||
## :material-tag-text: Tagging
|
||||
|
||||
From here you can search for existing tags or create a new one if the one you're looking for doesn't exist. Click the "+" button next to any tags you want to the currently selected file entries. To quickly add the top result, press the <kbd>Enter</kbd>/<kbd>Return</kbd> key to add the topmost tag and reset the tag search. Press <kbd>Enter</kbd>/<kbd>Return</kbd> once more to close the dialog box. By using this method, you can quickly add various tags in quick succession just by using the keyboard!
|
||||
With one or more file entries selected, you can **search for** or **create a new tag** by clicking the "Add Tag" button at the bottom of the inspector or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>.
|
||||
|
||||
To remove a tag from a file entry, hover over the tag in the preview panel and click on the "-" icon that appears.
|
||||
This will open the the **"Tag Bar"**, a combination search/creation bar that is designed to allow you to quickly create and apply tags that don't exist yet, find and apply existing tags from your library, and verify if certain tags already exist and/or are applied to your selection.
|
||||
|
||||
## Adding Metadata to File Entries
|
||||
Every step is keyboard-friendly, allowing for an efficient tagging "flow state" when it comes to creating and applying tags.
|
||||
|
||||
To add a metadata field to a file entry, start by clicking the "Add Field" button at the bottom of the preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry
|
||||
<figure markdown="span">
|
||||
{ width=80% }
|
||||
<br>
|
||||
{ width=80% }
|
||||
<figcaption>
|
||||
Clicking "Add Tag" or pressing <kbd>Ctrl</kbd>+<kbd>T</kbd> replaces the "Add" buttons with a tag search bar.
|
||||
<br>
|
||||
You can close the search by pressing <kbd>Enter</kbd> or clicking away (with a blank search), or at any time by pressing <kbd>Esc</kbd>.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
## Editing Metadata Fields
|
||||
<!-- prettier-ignore -->
|
||||
!!! note "Classic Tag Search Panel"
|
||||
You can access the classic tag search panel by going to **File -> Add Tag to Selected** in the menu bar, or by pressing <kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>T</kbd>. Note that this panel will change and move in the future, becoming part of a set of dockable side-panels for tagging that can stay open between making selections. See the [roadmap](./roadmap.md#uiux) for more info.
|
||||
|
||||
### Text Line / Text Box
|
||||
### :material-tag-plus: Tag Bar: Creating Tags
|
||||
|
||||
Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup.
|
||||
Type to search for an existing tag, or use that search term to quickly create and apply a new tag to your selection. Pressing <kbd>Enter</kbd> with **no results** will **create** :material-tag-plus-outline: a tag from your search query and apply it to the selected entries.
|
||||
|
||||
## Creating Tags
|
||||
After applying a tag, the "Add Tag" button is now focused, allowing you to press <kbd>Enter</kbd> or <kbd>Space</kbd> to quickly reactivate the bar. Alternatively, you may edit your new tag by right-clicking the tag and selecting **"Edit"**.
|
||||
|
||||
Create a new tag by accessing the "New Tag" option from the Edit menu or by pressing <kbd>Ctrl</kbd>+<kbd>T</kbd>. In the tag creation panel, enter a tag name, optional shorthand name, optional tag aliases, optional parent tags, and an optional color.
|
||||
Tags can be also viewed, created, edited, or deleted from the **Edit -> Manage Tags** window.
|
||||
|
||||
- The tag **name** is the base name of the tag. **_This does NOT have to be unique!_**
|
||||
- The tag **shorthand** is a special type of alias that displays in situations where screen space is more valuable, notably with name disambiguation.
|
||||
- **Aliases** are alternate names for a tag. These let you search for terms other than the exact tag name in order to find the tag again.
|
||||
- **Parent Tags** are tags in which this tag can substitute for in searches. In other words, tags under this section are parents of this tag.
|
||||
- Parent tags with the disambiguation check next to them will be used to help disambiguate tag names that may not be unique.
|
||||
- For example: If you had a tag for "Freddy Fazbear", you might add "Five Nights at Freddy's" as one of the parent tags. If the disambiguation box is checked next to "Five Nights at Freddy's" parent tag, then the tag "Freddy Fazbear" will display as "Freddy Fazbear (Five Nights at Freddy's)". Furthermore, if the "Five Nights at Freddy's" tag has a shorthand like "FNAF", then the "Freddy Fazbear" tag will display as "Freddy Fazbear (FNAF)".
|
||||
- The **color** option lets you select an optional color palette to use for your tag.
|
||||
- The **"Is Category"** property lets you treat this tag as a category under which itself and any child tags inheriting from it will be sorted by inside the preview panel.
|
||||
<figure markdown="span">
|
||||
{ width=80% }
|
||||
<figcaption>A tag named "Cube" does not exist yet, and will be created and applied to the selection when <kbd>Enter</kbd> is pressed.</figcaption>
|
||||
{ width=80% }
|
||||
<figcaption>The new tag "Cube" has been created and applied to our selection.</figcaption>
|
||||
</figure>
|
||||
|
||||
### Tag Manager
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Optionally Always Edit New Tags"
|
||||
If you prefer opening an "Edit" window for tags after creating them and before they get applied to your selection, you can toggle this option on by right-clicking the search bar and clicking **"Edit After Creating a New Tag"**, or by visiting the same setting in the **Settings** window. By default, this option is **off.**
|
||||
|
||||
You can manage your library of tags by opening the "Tag Manager" panel from Edit -> "Manage Tags". From here you can create, search for, edit, and permanently delete any tags you've created in your library.
|
||||
### :material-tag-search: Tag Bar: Searching for Tags
|
||||
|
||||
## Editing Tags
|
||||
Pressing <kbd>Enter</kbd> with **one or more results** will **apply** :material-tag: the underlined tag to your selection (assuming it's not already on your selection).
|
||||
|
||||
To edit a tag, click on it inside the preview panel or right-click the tag and select "Edit Tag" from the context menu.
|
||||
Matching tags that are **already applied** :material-tag-check: to your entries will be greyed out placed at the end of the autofill results. This lets you visually confirm that a tag already exists and has been applied to the selection. Applying them again with <kbd>Enter</kbd> has no effect other than closing the search.
|
||||
|
||||
You can navigate the list of autofill suggestions using the scroll wheel, and change the selected tag by pressing <kbd>Tab</kbd> or <kbd>Shift</kbd>+<kbd>Tab</kbd> to navigate right and left, respectively.
|
||||
|
||||
<figure markdown="span">
|
||||
{ width=80% }
|
||||
<figcaption>On a new selected entry, typing "Cube" will find our existing tag.</figcaption>
|
||||
{ width=80% }
|
||||
<figcaption>If the matched tag is already applied to the selected entries, it will be greyed out and ignored when pressing <kbd>Enter</kbd>.</figcaption>
|
||||
</figure>
|
||||
|
||||
#### :material-tag-arrow-up: Force Create Tag
|
||||
|
||||
Holding <kbd>Shift</kbd> while pressing <kbd>Enter</kbd> will **force create** :material-tag-plus-outline: a tag from the search bar text, regardless of the search results. This is especially useful when you need to create a new tag with a name that partially overlaps with an existing tag's name.
|
||||
|
||||
<figure markdown="span">
|
||||
{ width=80% }
|
||||
<figcaption markdown="span">Pressing <kbd>Enter</kbd> will **apply** :material-tag: the underlined tag, "Cube".</figcaption>
|
||||
{ width=80% }
|
||||
<figcaption markdown="span">
|
||||
<kbd>Shift</kbd>+<kbd>Enter</kbd> will **create** :material-tag-plus-outline: a new tag called "Cub".
|
||||
<br>
|
||||
Note the the greyed-out tag, showing it will not be applied.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
---
|
||||
|
||||
## :material-text-box: Creating and Adding Fields
|
||||
|
||||
[Fields](./fields.md) are extra pieces of information you can add to file entries, such as titles, comments, notes, specific dates or times. Unlike tags, fields are based on [templates](./fields.md#field-templates) that contain pre-filled information such as the field type and title, and that information is _copied_ to fields when adding them to entries. Editing field information on entries **does not** modify the template it was created from.
|
||||
|
||||
Creating and adding fields to entries is extremely similar to how [tagging](#tagging) works, with a few notable differences:
|
||||
|
||||
- Click "Add Field" or press <kbd>Ctrl</kbd>+<kbd>L</kbd> to open the field bar.
|
||||
- The same template can by applied to an entry any number of times.
|
||||
- New field templates are automatically edited after being created, since they have a type that must be chosen before they can be used.
|
||||
- This is separate from the option to automatically edit a field's _contents_ once a field has been added to an entry
|
||||
- Editing a field's contents immediately after it's added to an entry is **enabled** by default, and can be toggled by right-clicking the search bar and clicking **"Edit After Adding a Field"**, or by visiting the same setting in the **Settings** window.
|
||||
|
||||
<figure markdown="span">
|
||||
{ width=80% }
|
||||
<figcaption>Searching for field templates inside the field bar.</figcaption>
|
||||
</figure>
|
||||
|
||||
---
|
||||
|
||||
## Relinking Moved Files
|
||||
|
||||
@@ -71,9 +127,7 @@ Inevitably some of the files inside your library will be renamed, moved, or dele
|
||||
!!! warning
|
||||
If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are high priorities for future versions.
|
||||
|
||||
### Saving the Library
|
||||
|
||||
As of version 9.5, libraries are saved automatically as you go. To save a backup of your library, select File -> Save Library Backup from the menu bar.
|
||||
---
|
||||
|
||||
## Launch Arguments
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1782723713,
|
||||
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
|
||||
"lastModified": 1785571196,
|
||||
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
|
||||
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -29,35 +29,51 @@
|
||||
systems = import inputs.systems;
|
||||
|
||||
perSystem =
|
||||
{ pkgs, ... }:
|
||||
{ pkgs, self', ... }:
|
||||
let
|
||||
python3 = pkgs.python313;
|
||||
python3 = builtins.head python3Versions;
|
||||
python3Versions = with pkgs; [ python314 ];
|
||||
in
|
||||
{
|
||||
packages =
|
||||
let
|
||||
python3Packages = python3.pkgs;
|
||||
pythonDerivations = lib.genAttrs' python3Versions (
|
||||
python3:
|
||||
lib.nameValuePair python3.pythonAttr (
|
||||
let
|
||||
python3Packages = python3.pkgs;
|
||||
|
||||
pillow-jxl-plugin = python3Packages.callPackage ./nix/package/pillow-jxl-plugin.nix {
|
||||
inherit (pkgs) cmake;
|
||||
inherit pyexiv2;
|
||||
};
|
||||
pyexiv2 = python3Packages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; };
|
||||
tagstudio = pkgs.callPackage ./nix/package {
|
||||
inherit python3Packages;
|
||||
|
||||
inherit pillow-jxl-plugin;
|
||||
};
|
||||
|
||||
pillow-jxl-plugin = python3Packages.callPackage ./nix/package/pillow-jxl-plugin.nix {
|
||||
inherit (pkgs) cmake;
|
||||
inherit pyexiv2;
|
||||
};
|
||||
pyexiv2 = python3Packages.callPackage ./nix/package/pyexiv2.nix { inherit (pkgs) exiv2; };
|
||||
in
|
||||
{
|
||||
inherit tagstudio;
|
||||
tagstudio-jxl = tagstudio.override { withJXLSupport = true; };
|
||||
|
||||
inherit pillow-jxl-plugin pyexiv2;
|
||||
}
|
||||
)
|
||||
);
|
||||
in
|
||||
rec {
|
||||
default = tagstudio;
|
||||
tagstudio = pkgs.callPackage ./nix/package {
|
||||
inherit python3Packages;
|
||||
|
||||
inherit pillow-jxl-plugin;
|
||||
};
|
||||
tagstudio-jxl = tagstudio.override { withJXLSupport = true; };
|
||||
|
||||
inherit pillow-jxl-plugin pyexiv2;
|
||||
(lib.concatMapAttrs (
|
||||
pythonAttr: lib.mapAttrs' (name: lib.nameValuePair "${pythonAttr}Packages_${name}")
|
||||
) pythonDerivations)
|
||||
// pythonDerivations.${python3.pythonAttr}
|
||||
// {
|
||||
default = self'.packages.tagstudio;
|
||||
};
|
||||
|
||||
devShells = rec {
|
||||
default = tagstudio;
|
||||
devShells = {
|
||||
default = self'.devShells.tagstudio;
|
||||
tagstudio = import ./nix/shell.nix {
|
||||
inherit
|
||||
inputs
|
||||
|
||||
@@ -96,8 +96,7 @@ python3Packages.buildPythonApplication {
|
||||
dependencies =
|
||||
with python3Packages;
|
||||
[
|
||||
audioop-lts
|
||||
chardet
|
||||
chardet_5
|
||||
ffmpeg-python
|
||||
humanfriendly
|
||||
mutagen
|
||||
@@ -121,6 +120,7 @@ python3Packages.buildPythonApplication {
|
||||
ujson
|
||||
wcmatch
|
||||
]
|
||||
++ lib.optional (pythonAtLeast "3.13") audioop-lts
|
||||
++ lib.optional withJXLSupport pillow-jxl-plugin;
|
||||
|
||||
# These tests require modifications to a library, which does not work
|
||||
@@ -140,10 +140,8 @@ python3Packages.buildPythonApplication {
|
||||
"tests/qt/test_build_tag_panel.py"
|
||||
"tests/qt/test_field_containers.py"
|
||||
"tests/qt/test_file_path_options.py"
|
||||
"tests/qt/test_preview_panel.py"
|
||||
"tests/qt/test_tag_panel.py"
|
||||
"tests/qt/test_tag_search_panel.py"
|
||||
"tests/test_library.py"
|
||||
];
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -27,11 +27,11 @@ let
|
||||
libdrm
|
||||
libpulseaudio
|
||||
libva
|
||||
libx11
|
||||
libxkbcommon
|
||||
libxrandr
|
||||
pipewire
|
||||
qt6.qtwayland
|
||||
xorg.libX11
|
||||
xorg.libXrandr
|
||||
]
|
||||
);
|
||||
|
||||
@@ -73,19 +73,17 @@ let
|
||||
};
|
||||
in
|
||||
pkgs.mkShellNoCC {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
packages = [
|
||||
python3Wrapped
|
||||
]
|
||||
++ (with pkgs; [
|
||||
coreutils
|
||||
ffmpeg-headless
|
||||
ripgrep
|
||||
uv
|
||||
|
||||
pyright
|
||||
ruff
|
||||
];
|
||||
buildInputs = [
|
||||
python3Wrapped
|
||||
]
|
||||
++ (with pkgs; [
|
||||
ffmpeg-headless
|
||||
ripgrep
|
||||
]);
|
||||
|
||||
env = {
|
||||
@@ -120,7 +118,7 @@ pkgs.mkShellNoCC {
|
||||
|
||||
if [ ! -f "''${venv}"/pyproject.toml ] || ! diff --brief pyproject.toml "''${venv}"/pyproject.toml >/dev/null; then
|
||||
printf '%s\n' 'Installing dependencies, pyproject.toml changed...' >&2
|
||||
uv pip install --quiet --editable . --group docs --group extra --group test
|
||||
uv pip install --quiet --editable . --group docs --group extra --group reuse --group test
|
||||
cp pyproject.toml "''${venv}"/pyproject.toml
|
||||
fi
|
||||
|
||||
|
||||
@@ -70,16 +70,16 @@ theme:
|
||||
# Palette toggle for light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: purple
|
||||
accent: purple
|
||||
primary: custom
|
||||
accent: custom
|
||||
toggle:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Dark Mode
|
||||
# Palette toggle for dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: purple
|
||||
accent: purple
|
||||
primary: custom
|
||||
accent: custom
|
||||
toggle:
|
||||
icon: material/lightbulb-night-outline
|
||||
name: Switch to System Preference
|
||||
@@ -9,12 +9,12 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.6.1"
|
||||
version = "9.6.4"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
requires-python = ">=3.14,<3.15"
|
||||
dependencies = [
|
||||
"audioop-lts; python_version >= '3.13'",
|
||||
"audioop-lts~=0.2.2",
|
||||
"chardet~=5.2",
|
||||
"ffmpeg-python~=0.2",
|
||||
"humanfriendly==10.*",
|
||||
@@ -22,12 +22,12 @@ dependencies = [
|
||||
"numpy~=2.2",
|
||||
"opencv_python~=4.11",
|
||||
"Pillow>=10.2,<12",
|
||||
"pillow-heif~=0.22",
|
||||
"pillow-heif~=1.5.0",
|
||||
"pillow-jxl-plugin~=1.3",
|
||||
"py7zr~=1.1.3",
|
||||
"pydantic~=2.10",
|
||||
"pydub~=0.25",
|
||||
"PySide6==6.8.0.*",
|
||||
"PySide6==6.11.2",
|
||||
"rarfile==4.2",
|
||||
"rawpy~=0.27",
|
||||
"Send2Trash>=1.8,<3",
|
||||
@@ -43,7 +43,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.gui-scripts]
|
||||
tagstudio = "tagstudio.main:main"
|
||||
tagstudio = "tagstudio.__main__:main"
|
||||
|
||||
[dependency-groups]
|
||||
all = [
|
||||
@@ -57,10 +57,18 @@ check = [{ include-group = "lint" }, { include-group = "test" }]
|
||||
build = [{ include-group = "pyinstaller" }]
|
||||
docs = [{ include-group = "mkdocs" }]
|
||||
extra = [{ include-group = "pre-commit" }]
|
||||
lint = [{ include-group = "pyright" }, { include-group = "ruff" }]
|
||||
lint = [
|
||||
{ include-group = "pyright" },
|
||||
{ include-group = "reuse" },
|
||||
{ include-group = "ruff" },
|
||||
]
|
||||
test = [{ include-group = "pytest" }]
|
||||
|
||||
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
|
||||
mkdocs = [
|
||||
"mkdocs-material[imaging]>=9.7",
|
||||
"mkdocs-redirects~=1.2",
|
||||
"properdocs",
|
||||
]
|
||||
pre-commit = ["pre-commit~=4.2"]
|
||||
pyinstaller = ["Pyinstaller~=6.21"]
|
||||
pyright = ["pyright~=1.1.409"]
|
||||
@@ -71,6 +79,7 @@ pytest = [
|
||||
"pytest-qt==4.4.0",
|
||||
"syrupy==5.1.0",
|
||||
]
|
||||
reuse = ["reuse==6.*"]
|
||||
ruff = ["ruff==0.15.17"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
@@ -88,7 +97,7 @@ filterwarnings = [
|
||||
ignore = [
|
||||
".venv/**",
|
||||
"src/tagstudio/core/library/json/",
|
||||
"src/tagstudio/qt/previews/vendored/pydub/",
|
||||
"src/tagstudio/previews/vendored/pydub/",
|
||||
]
|
||||
include = ["src/tagstudio", "tests"]
|
||||
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
@@ -117,7 +126,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["D", "E402"]
|
||||
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
|
||||
"src/tagstudio/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
@@ -41,7 +41,7 @@ datafiles = [
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
[Path(project_root, "main.py")],
|
||||
[Path(project_root, "__main__.py")],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=datafiles,
|
||||
|
||||
@@ -1,9 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from tagstudio.main import main
|
||||
"""TagStudio launcher."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.constants import BUILD_TYPE, VERSION
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.qt_driver import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--settings-file",
|
||||
dest="settings_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .toml global settings file to use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--cache-file",
|
||||
dest="cache_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .ini or .plist cache file to use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
dest="debug",
|
||||
action="store_true",
|
||||
help="Reveals additional internal data useful for debugging.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="version",
|
||||
help="Displays TagStudio version information.",
|
||||
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
driver = QtDriver(args)
|
||||
ui_name = "Qt"
|
||||
|
||||
# Run the chosen frontend driver.
|
||||
try:
|
||||
driver.start()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
logger.info(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...")
|
||||
input()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
VERSION: str = version("tagstudio") # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
VERSION: str = version("tagstudio")
|
||||
BUILD_TYPE: str = "" # Usually "", "app.nightly", or "app.pre_release"
|
||||
COPYRIGHT_YEARS: str = "2021-2026"
|
||||
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
|
||||
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
|
||||
|
||||
@@ -10,16 +10,16 @@ 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.qt.global_settings import GlobalSettings
|
||||
from tagstudio.qt.app_settings import AppSettings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Turn into a BaseDriver class instead of a "Mixin".
|
||||
class DriverMixin:
|
||||
cached_values: QSettings
|
||||
# TODO: GlobalSettings has become closely tied to Qt.
|
||||
# Should there be a base Settings class?
|
||||
settings: GlobalSettings
|
||||
# TODO: AppSettings is Qt-specific and should not be in a base driver class.
|
||||
settings: AppSettings
|
||||
|
||||
def evaluate_path(self, open_path: str | None) -> LibraryStatus:
|
||||
"""Check if the path of library is valid."""
|
||||
|
||||
@@ -4,19 +4,24 @@
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
DatetimeFieldTemplate,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
|
||||
SQL_FILENAME: str = "ts_library.sqlite"
|
||||
JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 202
|
||||
DB_VERSION: int = 400
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
SELECT :tag_id AS tag_id
|
||||
UNION
|
||||
SELECT tp.child_id AS tag_id
|
||||
FROM tag_parents tp
|
||||
FROM tag_parents tp
|
||||
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
|
||||
)
|
||||
SELECT * FROM ChildTags;
|
||||
@@ -32,3 +37,15 @@ WITH RECURSIVE ChildTags AS (
|
||||
)
|
||||
SELECT tag_id FROM ChildTags;
|
||||
""")
|
||||
|
||||
|
||||
DEFAULT_FIELD_TEMPLATES = (
|
||||
TextFieldTemplate(name="Title"),
|
||||
TextFieldTemplate(name="Author"),
|
||||
TextFieldTemplate(name="Artist"),
|
||||
TextFieldTemplate(name="URL"),
|
||||
TextFieldTemplate(name="Description", is_multiline=True),
|
||||
TextFieldTemplate(name="Notes", is_multiline=True),
|
||||
TextFieldTemplate(name="Comments", is_multiline=True),
|
||||
DatetimeFieldTemplate(name="Date"),
|
||||
)
|
||||
|
||||
@@ -6,12 +6,9 @@ from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Dialect, Engine, String, TypeDecorator, create_engine, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy import Dialect, String, TypeDecorator
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from tagstudio.core.constants import RESERVED_TAG_END
|
||||
|
||||
logger = structlog.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,34 +31,3 @@ class PathType(TypeDecorator):
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
type_annotation_map = {Path: PathType}
|
||||
|
||||
|
||||
def make_engine(connection_string: str) -> Engine:
|
||||
return create_engine(connection_string)
|
||||
|
||||
|
||||
def make_tables(engine: Engine) -> None:
|
||||
logger.info("[Library] Creating DB tables...")
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# tag IDs < 1000 are reserved
|
||||
# create tag and delete it to bump the autoincrement sequence
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
|
||||
autoincrement_val = result.scalar()
|
||||
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO tags "
|
||||
"(id, name, color_namespace, color_slug, is_category, is_hidden) VALUES "
|
||||
f"({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
|
||||
)
|
||||
)
|
||||
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
|
||||
conn.commit()
|
||||
except OperationalError as e:
|
||||
logger.error("Could not initialize built-in tags", error=e)
|
||||
conn.rollback()
|
||||
|
||||
@@ -57,7 +57,7 @@ class TagColorEnum(enum.IntEnum):
|
||||
OLIVE = 37
|
||||
|
||||
@staticmethod
|
||||
def get_color_from_str(color_name: str) -> "TagColorEnum":
|
||||
def get_color_from_str(color_name: str) -> TagColorEnum:
|
||||
for color in TagColorEnum:
|
||||
if color.name == color_name.upper().replace(" ", "_"):
|
||||
return color
|
||||
@@ -99,17 +99,15 @@ class BrowsingState:
|
||||
return Parser(self.query).parse()
|
||||
|
||||
@classmethod
|
||||
def show_all(cls) -> "BrowsingState":
|
||||
def show_all(cls) -> BrowsingState:
|
||||
return BrowsingState()
|
||||
|
||||
@classmethod
|
||||
def from_search_query(cls, search_query: str) -> "BrowsingState":
|
||||
def from_search_query(cls, search_query: str) -> BrowsingState:
|
||||
return cls(query=search_query)
|
||||
|
||||
@classmethod
|
||||
def from_tag_id(
|
||||
cls, tag_id: int | str, state: "BrowsingState | None" = None
|
||||
) -> "BrowsingState":
|
||||
def from_tag_id(cls, tag_id: int | str, state: BrowsingState | None = None) -> BrowsingState:
|
||||
"""Create and return a BrowsingState object given a tag ID.
|
||||
|
||||
Args:
|
||||
@@ -124,35 +122,35 @@ class BrowsingState:
|
||||
return cls(query=f"tag_id:{str(tag_id)}")
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path | str) -> "BrowsingState":
|
||||
def from_path(cls, path: Path | str) -> BrowsingState:
|
||||
return cls(query=f'path:"{str(path).strip()}"')
|
||||
|
||||
@classmethod
|
||||
def from_mediatype(cls, mediatype: str) -> "BrowsingState":
|
||||
def from_mediatype(cls, mediatype: str) -> BrowsingState:
|
||||
return cls(query=f"mediatype:{mediatype}")
|
||||
|
||||
@classmethod
|
||||
def from_filetype(cls, filetype: str) -> "BrowsingState":
|
||||
def from_filetype(cls, filetype: str) -> BrowsingState:
|
||||
return cls(query=f"filetype:{filetype}")
|
||||
|
||||
@classmethod
|
||||
def from_tag_name(cls, tag_name: str) -> "BrowsingState":
|
||||
def from_tag_name(cls, tag_name: str) -> BrowsingState:
|
||||
return cls(query=f'tag:"{tag_name}"')
|
||||
|
||||
def with_page_index(self, index: int) -> "BrowsingState":
|
||||
def with_page_index(self, index: int) -> BrowsingState:
|
||||
return replace(self, page_index=index)
|
||||
|
||||
def with_sorting_mode(self, mode: SortingModeEnum) -> "BrowsingState":
|
||||
def with_sorting_mode(self, mode: SortingModeEnum) -> BrowsingState:
|
||||
seed = self.random_seed
|
||||
if mode == SortingModeEnum.RANDOM:
|
||||
seed = random.random()
|
||||
return replace(self, sorting_mode=mode, random_seed=seed)
|
||||
|
||||
def with_sorting_direction(self, ascending: bool) -> "BrowsingState":
|
||||
def with_sorting_direction(self, ascending: bool) -> BrowsingState:
|
||||
return replace(self, ascending=ascending)
|
||||
|
||||
def with_search_query(self, search_query: str) -> "BrowsingState":
|
||||
def with_search_query(self, search_query: str) -> BrowsingState:
|
||||
return replace(self, query=search_query)
|
||||
|
||||
def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState":
|
||||
def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState:
|
||||
return replace(self, show_hidden_entries=show_hidden_entries)
|
||||
|
||||
@@ -20,3 +20,10 @@ class TagEntry(Base):
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
|
||||
|
||||
|
||||
class CategoryExclusion(Base):
|
||||
__tablename__ = "category_exclusions"
|
||||
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
# NOTE: This file contains necessary use of deprecated first-party code until that
|
||||
# code is removed in a future version (prefs).
|
||||
# pyright: reportDeprecated=false
|
||||
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
@@ -18,10 +13,7 @@ from datetime import UTC, datetime
|
||||
from os import makedirs
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
from warnings import catch_warnings
|
||||
|
||||
import sqlalchemy
|
||||
import structlog
|
||||
from humanfriendly import format_timespan # pyright: ignore[reportUnknownVariableType]
|
||||
from sqlalchemy import (
|
||||
@@ -45,7 +37,7 @@ from sqlalchemy import (
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects import sqlite
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import (
|
||||
InstanceState,
|
||||
Session,
|
||||
@@ -73,16 +65,13 @@ from tagstudio.core.library.alchemy.constants import (
|
||||
DB_VERSION,
|
||||
DB_VERSION_CURRENT_KEY,
|
||||
DB_VERSION_INITIAL_KEY,
|
||||
DEFAULT_FIELD_TEMPLATES,
|
||||
JSON_FILENAME,
|
||||
SQL_FILENAME,
|
||||
TAG_CHILDREN_QUERY,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.db import make_tables
|
||||
from tagstudio.core.library.alchemy.enums import (
|
||||
MAX_SQL_VARIABLES,
|
||||
BrowsingState,
|
||||
SortingModeEnum,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.db import Base as ModelBase
|
||||
from tagstudio.core.library.alchemy.enums import MAX_SQL_VARIABLES, BrowsingState, SortingModeEnum
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
LEGACY_FIELD_MAP,
|
||||
BaseField,
|
||||
@@ -92,10 +81,10 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
TextField,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent
|
||||
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
|
||||
from tagstudio.core.library.alchemy.models import (
|
||||
Entry,
|
||||
Folder,
|
||||
Namespace,
|
||||
Tag,
|
||||
TagAlias,
|
||||
@@ -106,7 +95,6 @@ from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
|
||||
from tagstudio.core.library.ignore import migrate_ext_list
|
||||
from tagstudio.core.library.json.library import Library as JsonLibrary
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy import Select
|
||||
@@ -172,20 +160,6 @@ def get_default_tags() -> tuple[Tag, ...]:
|
||||
return archive_tag, favorite_tag, meta_tag
|
||||
|
||||
|
||||
def get_default_field_templates() -> tuple[BaseFieldTemplate, ...]:
|
||||
"""Return the default field templates for a new TagStudio library."""
|
||||
title = TextFieldTemplate(name="Title")
|
||||
author = TextFieldTemplate(name="Author")
|
||||
artist = TextFieldTemplate(name="Artist")
|
||||
url = TextFieldTemplate(name="URL")
|
||||
description = TextFieldTemplate(name="Description", is_multiline=True)
|
||||
notes = TextFieldTemplate(name="Notes", is_multiline=True)
|
||||
comments = TextFieldTemplate(name="Comments", is_multiline=True)
|
||||
date = DatetimeFieldTemplate(name="Date")
|
||||
|
||||
return title, author, artist, url, description, notes, comments, date
|
||||
|
||||
|
||||
# The difference in the number of default JSON tags vs default tags in the current version.
|
||||
DEFAULT_TAG_DIFF: int = len(get_default_tags()) - len([TAG_ARCHIVED, TAG_FAVORITE])
|
||||
|
||||
@@ -234,7 +208,6 @@ class Library:
|
||||
|
||||
library_dir: Path | None = None
|
||||
engine: Engine | None = None
|
||||
folder: Folder | None = None
|
||||
included_files: set[Path] = set()
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -259,7 +232,6 @@ class Library:
|
||||
"""Migrate JSON library data to the SQLite database."""
|
||||
logger.info("Starting Library Conversion...")
|
||||
start_time = time.time()
|
||||
folder: Folder = Folder(path=self.library_dir, uuid=str(uuid4()))
|
||||
|
||||
# Tags
|
||||
for tag in json_lib.tags:
|
||||
@@ -312,7 +284,6 @@ class Library:
|
||||
[
|
||||
Entry(
|
||||
path=entry.path / entry.filename,
|
||||
folder=folder,
|
||||
fields=[],
|
||||
id=entry.id + 1, # NOTE: JSON IDs start at 0 instead of 1
|
||||
date_added=datetime.now(),
|
||||
@@ -381,20 +352,19 @@ class Library:
|
||||
return tag.name
|
||||
|
||||
def open_library(self, library_dir: Path, in_memory: bool = False) -> LibraryStatus:
|
||||
"""Wrapper for open_sqlite_library.
|
||||
"""Wrapper for open_sqlite_library and create_sqlite_library.
|
||||
|
||||
Handles in-memory storage and checks whether a JSON-migration is necessary.
|
||||
"""
|
||||
assert isinstance(library_dir, Path)
|
||||
|
||||
if in_memory:
|
||||
return self.open_sqlite_library(library_dir, is_new=True, storage_path=":memory:")
|
||||
|
||||
is_new = True
|
||||
sql_path = library_dir / TS_FOLDER_NAME / SQL_FILENAME
|
||||
if self.verify_ts_folder(library_dir) and (is_new := not sql_path.exists()):
|
||||
json_path = library_dir / TS_FOLDER_NAME / JSON_FILENAME
|
||||
if json_path.exists():
|
||||
json_path = library_dir / TS_FOLDER_NAME / JSON_FILENAME
|
||||
|
||||
is_new = not sql_path.exists()
|
||||
if not in_memory:
|
||||
self.verify_ts_folder(library_dir) # ensure .TagStudio directory exists
|
||||
if is_new and json_path.exists():
|
||||
return LibraryStatus(
|
||||
success=False,
|
||||
library_path=library_dir,
|
||||
@@ -402,14 +372,18 @@ class Library:
|
||||
json_migration_req=True,
|
||||
)
|
||||
|
||||
return self.open_sqlite_library(library_dir, is_new, str(sql_path))
|
||||
if is_new:
|
||||
return self.create_sqlite_library(library_dir, in_memory)
|
||||
|
||||
def open_sqlite_library(
|
||||
self, library_dir: Path, is_new: bool, storage_path: str
|
||||
) -> LibraryStatus:
|
||||
return self.open_sqlite_library(library_dir, in_memory)
|
||||
|
||||
@staticmethod
|
||||
def __get_engine(library_dir: Path, in_memory: bool, sql_filename: str):
|
||||
connection_string = URL.create(
|
||||
drivername="sqlite",
|
||||
database=storage_path,
|
||||
database=(
|
||||
":memory:" if in_memory else str(library_dir / TS_FOLDER_NAME / sql_filename)
|
||||
),
|
||||
)
|
||||
# NOTE: File-based databases should use NullPool to create new DB connection in order to
|
||||
# keep connections on separate threads, which prevents the DB files from being locked
|
||||
@@ -418,169 +392,92 @@ class Library:
|
||||
# More info can be found on the SQLAlchemy docs:
|
||||
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
|
||||
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
|
||||
poolclass = None if storage_path == ":memory:" else NullPool
|
||||
loaded_db_version: int = 0
|
||||
initial_db_version: int = DB_VERSION
|
||||
poolclass = None if in_memory else NullPool
|
||||
|
||||
logger.info(
|
||||
"[Library] Creating SQLAlchemy Engine",
|
||||
connection_string=connection_string,
|
||||
poolclass=poolclass,
|
||||
)
|
||||
return create_engine(
|
||||
connection_string, poolclass=poolclass, connect_args={"autocommit": False}
|
||||
)
|
||||
|
||||
def create_sqlite_library(
|
||||
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
|
||||
) -> LibraryStatus:
|
||||
self.engine = self.__get_engine(library_dir, in_memory, sql_filename)
|
||||
|
||||
logger.info(
|
||||
"[Library] Opening SQLite Library",
|
||||
library_dir=library_dir,
|
||||
connection_string=connection_string,
|
||||
)
|
||||
self.engine = create_engine(connection_string, poolclass=poolclass)
|
||||
with Session(self.engine) as session:
|
||||
# Don't check DB version when creating new library
|
||||
if not is_new:
|
||||
loaded_db_version = self.get_version(DB_VERSION_CURRENT_KEY)
|
||||
initial_db_version = self.get_version(DB_VERSION_INITIAL_KEY)
|
||||
|
||||
# ======================== Library Database Version Checking =======================
|
||||
# DB_VERSION 6 is the first supported SQLite DB version.
|
||||
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
|
||||
# - Dividing by 100 and flooring gives the major (breaking changes) version.
|
||||
# - If a DB has major version higher than the current program, don't load it.
|
||||
# - If only the minor version is higher, it's still allowed to load.
|
||||
if loaded_db_version < 6 or (
|
||||
loaded_db_version >= 100 and loaded_db_version // 100 > DB_VERSION // 100
|
||||
):
|
||||
mismatch_text = Translations["status.library_version_mismatch"]
|
||||
found_text = Translations["status.library_version_found"]
|
||||
expected_text = Translations["status.library_version_expected"]
|
||||
return LibraryStatus(
|
||||
success=False,
|
||||
message=(
|
||||
f"{mismatch_text}\n"
|
||||
f"{found_text} v{loaded_db_version}, "
|
||||
f"{expected_text} v{DB_VERSION}"
|
||||
),
|
||||
logger.info("[Library] Creating DB tables...")
|
||||
with self.engine.connect() as conn:
|
||||
ModelBase.metadata.create_all(conn)
|
||||
conn.commit()
|
||||
|
||||
# TODO - find a better way
|
||||
# is this the better way?
|
||||
# Could we perhaps update the row we are reading from here?
|
||||
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
|
||||
autoincrement_val = result.scalar()
|
||||
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO tags "
|
||||
"(id, name, color_namespace, color_slug, is_category, is_hidden) "
|
||||
f"VALUES ({RESERVED_TAG_END}, 'temp', NULL, NULL, false, false)"
|
||||
)
|
||||
)
|
||||
conn.execute(text(f"DELETE FROM tags WHERE id = {RESERVED_TAG_END}"))
|
||||
conn.commit()
|
||||
except OperationalError as e:
|
||||
logger.error("Could not initialize built-in tags", error=e)
|
||||
conn.rollback()
|
||||
|
||||
logger.info(f"[Library] Library DB version: {loaded_db_version}")
|
||||
make_tables(self.engine)
|
||||
with Session(self.engine) as session:
|
||||
# Add default tag color namespaces.
|
||||
namespaces = default_color_groups.namespaces()
|
||||
|
||||
if is_new:
|
||||
# Add default tag color namespaces.
|
||||
namespaces = default_color_groups.namespaces()
|
||||
try:
|
||||
session.add_all(namespaces)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.error("[Library] Couldn't add default tag color namespaces", error=e)
|
||||
session.rollback()
|
||||
session.add_all(namespaces)
|
||||
session.flush()
|
||||
|
||||
# Add default tag colors.
|
||||
tag_colors: list[TagColorGroup] = default_color_groups.standard()
|
||||
tag_colors += default_color_groups.pastels()
|
||||
tag_colors += default_color_groups.shades()
|
||||
tag_colors += default_color_groups.grayscale()
|
||||
tag_colors += default_color_groups.earth_tones()
|
||||
tag_colors += default_color_groups.neon()
|
||||
if is_new:
|
||||
try:
|
||||
session.add_all(tag_colors)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
logger.error("[Library] Couldn't add default tag colors", error=e)
|
||||
session.rollback()
|
||||
# Add default tag colors.
|
||||
tag_colors: list[TagColorGroup] = default_color_groups.standard()
|
||||
tag_colors += default_color_groups.pastels()
|
||||
tag_colors += default_color_groups.shades()
|
||||
tag_colors += default_color_groups.grayscale()
|
||||
tag_colors += default_color_groups.earth_tones()
|
||||
tag_colors += default_color_groups.neon()
|
||||
|
||||
# Add default tags.
|
||||
tags = get_default_tags()
|
||||
try:
|
||||
session.add_all(tags)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
session.add_all(tag_colors)
|
||||
session.flush()
|
||||
|
||||
# Add default tags.
|
||||
session.add_all(get_default_tags())
|
||||
session.flush()
|
||||
|
||||
# Add default field templates
|
||||
if is_new:
|
||||
for template in get_default_field_templates():
|
||||
try:
|
||||
session.add(template)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
logger.info(
|
||||
"[Library] FieldTemplate already exists", field_template=template
|
||||
)
|
||||
session.rollback()
|
||||
for template in DEFAULT_FIELD_TEMPLATES:
|
||||
session.add(template)
|
||||
session.flush()
|
||||
|
||||
# Ensure version rows are present
|
||||
with catch_warnings(record=True):
|
||||
try:
|
||||
initial = DB_VERSION if is_new else 100
|
||||
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=initial))
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
|
||||
try:
|
||||
session.add(Version(key=DB_VERSION_CURRENT_KEY, value=DB_VERSION))
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
|
||||
# check if folder matching current path exists already
|
||||
self.folder = session.scalar(select(Folder).where(Folder.path == library_dir))
|
||||
if not self.folder:
|
||||
folder = Folder(
|
||||
path=library_dir,
|
||||
uuid=str(uuid4()),
|
||||
)
|
||||
session.add(folder)
|
||||
session.expunge(folder)
|
||||
session.commit()
|
||||
self.folder = folder
|
||||
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=DB_VERSION))
|
||||
session.add(Version(key=DB_VERSION_CURRENT_KEY, value=DB_VERSION))
|
||||
session.flush()
|
||||
|
||||
# Generate default .ts_ignore file
|
||||
if is_new:
|
||||
try:
|
||||
ts_ignore_template = (
|
||||
Path(__file__).parents[3] / "resources/templates/ts_ignore_template.txt"
|
||||
)
|
||||
shutil.copy2(ts_ignore_template, library_dir / TS_FOLDER_NAME / IGNORE_NAME)
|
||||
except Exception as e:
|
||||
logger.error("[ERROR][Library] Could not generate '.ts_ignore' file!", error=e)
|
||||
|
||||
# Apply any post-SQL migration patches.
|
||||
if not is_new:
|
||||
assert loaded_db_version >= 6
|
||||
|
||||
# save backup if patches will be applied
|
||||
if loaded_db_version < DB_VERSION:
|
||||
self.library_dir = library_dir
|
||||
self.save_library_backup_to_disk()
|
||||
self.library_dir = None
|
||||
|
||||
# migrate DB step by step from one version to the next
|
||||
if loaded_db_version < 7:
|
||||
# changes: value_type, tags
|
||||
self.__apply_db7_migration(session)
|
||||
if loaded_db_version < 8:
|
||||
# changes: tag_colors
|
||||
self.__apply_db8_migration(session)
|
||||
if loaded_db_version < 9:
|
||||
# changes: entries
|
||||
self.__apply_db9_migration(session)
|
||||
if loaded_db_version < 100:
|
||||
# changes: tag_parents
|
||||
self.__apply_db100_migration(session)
|
||||
if loaded_db_version < 102:
|
||||
# changes: tag_parents
|
||||
self.__apply_db102_migration(session)
|
||||
if loaded_db_version < 103:
|
||||
# changes: tags
|
||||
self.__apply_db103_migration(session)
|
||||
if loaded_db_version < 104:
|
||||
# changes: deletes preferences
|
||||
self.__apply_db104_migration(session, library_dir)
|
||||
if loaded_db_version < 200:
|
||||
# changes: field tables
|
||||
self.__apply_db200_migration(session)
|
||||
if initial_db_version < 200 and loaded_db_version < 201:
|
||||
# changes: field tables
|
||||
self.__apply_db201_migration(session)
|
||||
if loaded_db_version < 202:
|
||||
# changes: tag_parents
|
||||
self.__apply_db202_migration(session)
|
||||
try:
|
||||
ts_ignore_template = (
|
||||
Path(__file__).parents[3] / "resources/templates/ts_ignore_template.txt"
|
||||
)
|
||||
shutil.copy2(ts_ignore_template, library_dir / TS_FOLDER_NAME / IGNORE_NAME)
|
||||
except Exception as e:
|
||||
logger.error("[ERROR][Library] Could not generate '.ts_ignore' file!", error=e)
|
||||
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
@@ -596,337 +493,33 @@ class Library:
|
||||
)
|
||||
)
|
||||
|
||||
# Update DB_VERSION
|
||||
if loaded_db_version < DB_VERSION:
|
||||
logger.info(f"[Library] Library migrated to DB version {DB_VERSION}")
|
||||
self.set_version(DB_VERSION_CURRENT_KEY, DB_VERSION)
|
||||
session.commit()
|
||||
|
||||
# everything is fine, set the library path
|
||||
self.library_dir = library_dir
|
||||
return LibraryStatus(success=True, library_path=library_dir)
|
||||
|
||||
def __apply_db7_migration(self, session: Session):
|
||||
"""Migrate DB from DB_VERSION 6 to 7."""
|
||||
logger.info("[Library][Migration] Applying patches to DB_VERSION: 6 library...")
|
||||
with session:
|
||||
# Repair tags that may have a disambiguation_id pointing towards a deleted tag.
|
||||
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.commit()
|
||||
def open_sqlite_library(
|
||||
self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME
|
||||
) -> LibraryStatus:
|
||||
logger.info("[Library] Opening SQLite Library", library_dir=library_dir)
|
||||
|
||||
def __apply_db8_migration(self, session: Session):
|
||||
"""Migrate DB from DB_VERSION 7 to 8."""
|
||||
# Add the missing color_border column to the TagColorGroups table.
|
||||
color_border_stmt = text(
|
||||
"ALTER TABLE tag_colors ADD COLUMN color_border BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
)
|
||||
# migrate if necessary
|
||||
try:
|
||||
session.execute(color_border_stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Added color_border column to tag_colors table")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Library][Migration] Could not create color_border column in tag_colors table!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
migrations = DBMigrations(library_dir, sql_filename)
|
||||
|
||||
# collect new default tag colors
|
||||
tag_colors: list[TagColorGroup] = default_color_groups.standard()
|
||||
tag_colors += default_color_groups.pastels()
|
||||
tag_colors += default_color_groups.shades()
|
||||
tag_colors += default_color_groups.grayscale()
|
||||
tag_colors += default_color_groups.earth_tones()
|
||||
# tag_colors += default_color_groups.neon() # NOTE: Neon is handled separately
|
||||
# save backup if patches will be applied
|
||||
if migrations.required:
|
||||
Library.save_library_backup_to_disk(library_dir)
|
||||
|
||||
# Add any new default colors introduced in DB_VERSION 8
|
||||
for color in tag_colors:
|
||||
try:
|
||||
session.add(color)
|
||||
logger.info(
|
||||
"[Library][Migration] Migrated tag color to DB_VERSION 8+",
|
||||
color_name=color.name,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
migrations.run()
|
||||
except MigrationError as e:
|
||||
return LibraryStatus(success=False, message=e.args[0])
|
||||
|
||||
# Update Neon colors to use the the color_border property
|
||||
for color in default_color_groups.neon():
|
||||
try:
|
||||
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.commit()
|
||||
except IntegrityError as e:
|
||||
logger.error(
|
||||
"[Library] Could not migrate Neon colors to DB_VERSION 8+!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
def __apply_db9_migration(self, session: Session):
|
||||
"""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 ''"
|
||||
)
|
||||
try:
|
||||
session.execute(add_filename_column)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Added filename column to entries table")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Library][Migration] Could not create filename column in entries table!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
# Populate the new filename column.
|
||||
for entry in self.all_entries():
|
||||
session.merge(entry).filename = entry.path.name
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Populated filename column in entries table")
|
||||
|
||||
def __apply_db100_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 100."""
|
||||
with session:
|
||||
# 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.commit()
|
||||
logger.info("[Library][Migration] Refactored TagParent table")
|
||||
|
||||
def __apply_db102_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 102."""
|
||||
with session:
|
||||
stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Verified TagParent table data")
|
||||
|
||||
def __apply_db103_migration(self, session: Session):
|
||||
"""Migrate DB from DB_VERSION 102 to 103."""
|
||||
# add the new hidden column for tags
|
||||
add_is_hidden_column = text(
|
||||
"ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0"
|
||||
)
|
||||
try:
|
||||
session.execute(add_is_hidden_column)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Added is_hidden column to tags table")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Library][Migration] Could not create is_hidden column in tags table!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
# mark the "Archived" tag as hidden
|
||||
try:
|
||||
session.query(Tag).filter(Tag.id == TAG_ARCHIVED).update({"is_hidden": True})
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Updated archived tag to be hidden")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[Library][Migration] Could not update archived tag to be hidden!",
|
||||
error=e,
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
def __apply_db104_migration(self, session: Session, library_dir: Path):
|
||||
"""Migrate DB from DB_VERSION 103 to 104."""
|
||||
# Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist
|
||||
self.__migrate_sql_to_ts_ignore(library_dir)
|
||||
session.execute(text("DROP TABLE preferences"))
|
||||
session.commit()
|
||||
|
||||
def __migrate_sql_to_ts_ignore(self, 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():
|
||||
return
|
||||
|
||||
# Load legacy extension data
|
||||
with Session(self.engine) as session:
|
||||
extensions: list[str] = unwrap(
|
||||
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
|
||||
)
|
||||
is_exclude_list: bool = unwrap(
|
||||
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
|
||||
)
|
||||
|
||||
with open(ts_ignore, "w") as f:
|
||||
f.write(migrate_ext_list(extensions, is_exclude_list))
|
||||
|
||||
def __apply_db200_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 200."""
|
||||
with session:
|
||||
# Drop unused 'boolean_fields' and 'value_type' tables
|
||||
logger.info(
|
||||
"[Library][Migration][200] Dropping boolean_fields and value_type tables..."
|
||||
)
|
||||
session.execute(text("DROP TABLE boolean_fields"))
|
||||
session.execute(text("DROP TABLE value_type"))
|
||||
|
||||
# Add 'name' column to text_fields and datetime_fields tables
|
||||
logger.info("[Library][Migration][200] 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)
|
||||
|
||||
# Drop unnecessary 'position' columns
|
||||
logger.info("[Library][Migration][200] 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"))
|
||||
|
||||
# Add 'is_multiline' column to text_fields table
|
||||
logger.info("[Library][Migration][200] 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()
|
||||
|
||||
# Move values from old `type_key` columns into new `name` columns
|
||||
logger.info("[Library][Migration][200] 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()
|
||||
|
||||
# Change `name` values to title case
|
||||
logger.info("[Library][Migration][200] 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("_", " ")
|
||||
logger.info("[Library][Migration][200] Normalizing DatetimeField names...")
|
||||
for datetime_field in session.execute(select(DatetimeField)).scalars():
|
||||
datetime_field.name = datetime_field.name.title().replace("_", " ")
|
||||
session.flush()
|
||||
|
||||
# Add correct `is_multiline` values to text_fields table
|
||||
logger.info("[Library][Migration][200] 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)
|
||||
)
|
||||
session.execute(update_stmt)
|
||||
session.flush()
|
||||
|
||||
# Repair legacy "Description" fields to use is_multiline = True
|
||||
logger.info("[Library][Migration][200] 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("[Library][Migration][200] 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)
|
||||
|
||||
# Add default field templates
|
||||
logger.info("[Library][Migration][200] Adding default field templates...")
|
||||
for template in get_default_field_templates():
|
||||
try:
|
||||
session.add(template)
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
logger.error("[Library] FieldTemplate already exists", field_template=template)
|
||||
session.rollback()
|
||||
|
||||
session.commit()
|
||||
|
||||
def __apply_db201_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 201."""
|
||||
with session:
|
||||
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("[Library][Migration][201] 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"))
|
||||
|
||||
logger.info("[Library][Migration][201] 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.commit()
|
||||
|
||||
def __apply_db202_migration(self, session: Session):
|
||||
"""Migrate DB to DB_VERSION 202."""
|
||||
with session:
|
||||
stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct()))
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
logger.info("[Library][Migration] Verified TagParent table data")
|
||||
# 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)
|
||||
|
||||
@property
|
||||
def field_templates(self) -> Sequence[BaseFieldTemplate]:
|
||||
@@ -1128,11 +721,12 @@ class Library:
|
||||
raise ValueError("Invalid library directory.")
|
||||
|
||||
full_ts_path = library_dir / TS_FOLDER_NAME
|
||||
if not full_ts_path.exists():
|
||||
logger.info("creating library directory", dir=full_ts_path)
|
||||
full_ts_path.mkdir(parents=True, exist_ok=True)
|
||||
return False
|
||||
return True
|
||||
if full_ts_path.exists():
|
||||
return True
|
||||
|
||||
logger.info("creating library directory", dir=full_ts_path)
|
||||
full_ts_path.mkdir(parents=True, exist_ok=True)
|
||||
return False
|
||||
|
||||
def add_entries(self, items: list[Entry]) -> list[int]:
|
||||
"""Add multiple Entry records to the Library."""
|
||||
@@ -1726,6 +1320,7 @@ class Library:
|
||||
tag: Tag,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
aliases: Iterable[TagAlias] | None = None,
|
||||
exclusion_ids: list[int] | set[int] | None = None,
|
||||
) -> Tag | None:
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
@@ -1737,9 +1332,14 @@ class Library:
|
||||
session.flush()
|
||||
|
||||
if aliases is not None:
|
||||
for a in aliases:
|
||||
a.tag_id = tag.id
|
||||
self.update_aliases(tag, aliases, session)
|
||||
session.flush()
|
||||
|
||||
if exclusion_ids is not None:
|
||||
self._update_category_exclusion(tag, exclusion_ids, session)
|
||||
|
||||
session.commit()
|
||||
session.expunge(tag)
|
||||
return tag
|
||||
@@ -1845,16 +1445,17 @@ class Library:
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def save_library_backup_to_disk(self) -> Path:
|
||||
assert isinstance(self.library_dir, Path)
|
||||
makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
|
||||
@staticmethod
|
||||
def save_library_backup_to_disk(library_dir: Path) -> Path:
|
||||
assert isinstance(library_dir, Path)
|
||||
makedirs(str(library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True)
|
||||
|
||||
filename = f"ts_library_backup_{datetime.now(UTC).strftime('%Y_%m_%d_%H%M%S')}.sqlite"
|
||||
|
||||
target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
|
||||
target_path = library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
|
||||
|
||||
shutil.copy2(
|
||||
self.library_dir / TS_FOLDER_NAME / SQL_FILENAME,
|
||||
library_dir / TS_FOLDER_NAME / SQL_FILENAME,
|
||||
target_path,
|
||||
)
|
||||
|
||||
@@ -1868,6 +1469,7 @@ class Library:
|
||||
selectinload(Tag.parent_tags),
|
||||
selectinload(Tag.aliases),
|
||||
joinedload(Tag.color),
|
||||
selectinload(Tag.category_exclusions),
|
||||
)
|
||||
tag = session.scalar(tags_query.where(Tag.id == tag_id))
|
||||
|
||||
@@ -1938,7 +1540,10 @@ class Library:
|
||||
|
||||
statement = select(Tag).where(Tag.id.in_(all_tag_ids))
|
||||
statement = statement.options(
|
||||
noload(Tag.parent_tags), selectinload(Tag.aliases), joinedload(Tag.color)
|
||||
noload(Tag.parent_tags),
|
||||
selectinload(Tag.aliases),
|
||||
selectinload(Tag.category_exclusions),
|
||||
joinedload(Tag.color),
|
||||
)
|
||||
tags = session.scalars(statement).fetchall()
|
||||
for tag in tags:
|
||||
@@ -2017,9 +1622,10 @@ class Library:
|
||||
tag: Tag,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
aliases: Iterable[TagAlias] | None = None,
|
||||
exclusion_ids: list[int] | set[int] | None = None,
|
||||
) -> None:
|
||||
"""Edit a Tag in the Library."""
|
||||
self.add_tag(tag, parent_ids, aliases)
|
||||
self.add_tag(tag, parent_ids, aliases, exclusion_ids)
|
||||
|
||||
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
|
||||
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
|
||||
@@ -2067,8 +1673,10 @@ class Library:
|
||||
)
|
||||
session.execute(update_tags_stmt)
|
||||
session.commit()
|
||||
else:
|
||||
self.add_color(new_color_group)
|
||||
return
|
||||
|
||||
# "if not existing_color", out of the session context
|
||||
self.add_color(new_color_group)
|
||||
|
||||
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias], session: Session) -> bool:
|
||||
"""Update TagAliases for a given Tag."""
|
||||
@@ -2140,6 +1748,23 @@ class Library:
|
||||
)
|
||||
session.add(parent_tag)
|
||||
|
||||
def _update_category_exclusion(
|
||||
self, tag: Tag, exclusion_ids: list[int] | set[int], session: Session
|
||||
):
|
||||
prev_exclusions = session.scalars(
|
||||
select(CategoryExclusion).where(CategoryExclusion.tag_id == tag.id)
|
||||
).all()
|
||||
|
||||
for exclusion in prev_exclusions:
|
||||
if exclusion.category_id not in exclusion_ids:
|
||||
session.delete(exclusion)
|
||||
else:
|
||||
exclusion_ids.remove(exclusion.category_id)
|
||||
|
||||
for exclusion_id in exclusion_ids:
|
||||
exclusion = CategoryExclusion(tag_id=tag.id, category_id=exclusion_id)
|
||||
session.add(exclusion)
|
||||
|
||||
def get_version(self, key: str) -> int:
|
||||
"""Get a version value from the DB.
|
||||
|
||||
@@ -2147,43 +1772,11 @@ class Library:
|
||||
key(str): The key for the name of the version type to set.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
engine = sqlalchemy.inspect(self.engine)
|
||||
try:
|
||||
# "Version" table added in DB_VERSION 101
|
||||
if engine and engine.has_table("versions"):
|
||||
version = session.scalar(select(Version).where(Version.key == key))
|
||||
assert version
|
||||
return version.value
|
||||
# NOTE: The "Preferences" table has been depreciated as of TagStudio 9.5.4
|
||||
# and is set to be removed in a future release.
|
||||
else:
|
||||
return int(
|
||||
unwrap(
|
||||
session.scalar(
|
||||
text("SELECT value FROM preferences WHERE key == 'DB_VERSION'")
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
version = session.scalar(select(Version).where(Version.key == key))
|
||||
if version is None:
|
||||
logger.info(f"[Library] Couldn't get version of type '{key}'")
|
||||
return 0
|
||||
|
||||
def set_version(self, key: str, value: int) -> None:
|
||||
"""Set a version value to the DB.
|
||||
|
||||
Args:
|
||||
key(str): The key for the name of the version type to set.
|
||||
value(int): The version value to set.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
version = session.scalar(select(Version).where(Version.key == key))
|
||||
assert version
|
||||
version.value = value
|
||||
session.add(version)
|
||||
session.commit()
|
||||
except (IntegrityError, AssertionError) as e:
|
||||
logger.error("[Library][ERROR] Couldn't add default tag color namespaces", error=e)
|
||||
session.rollback()
|
||||
return version.value
|
||||
|
||||
def mirror_entry_fields(self, entries: list[Entry]) -> None:
|
||||
"""Mirror fields among multiple Entry items."""
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
import ujson
|
||||
from sqlalchemy import 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.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
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
LoggingMethod = Callable[[str], str]
|
||||
|
||||
|
||||
class MigrationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DBMigration:
|
||||
version: int
|
||||
initial_version: int | None = None
|
||||
|
||||
@classmethod
|
||||
def run(cls, session: Session, library_dir: Path, fmt_log: LoggingMethod) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DBMigrations:
|
||||
def __init__(self, library_dir: Path, sql_filename: str) -> None:
|
||||
self.library_dir = library_dir
|
||||
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)
|
||||
self.initial_db_version = self._get_version(DB_VERSION_INITIAL_KEY)
|
||||
|
||||
# ======================== Library Database Version Checking =======================
|
||||
# DB_VERSION 6 is the first supported SQLite DB version.
|
||||
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
|
||||
# - Dividing by 100 and flooring gives the major (breaking changes) version.
|
||||
# - If a DB has major version higher than the current program, don't load it.
|
||||
# - If only the minor version is higher, it's still allowed to load.
|
||||
if self.loaded_db_version < 6 or (
|
||||
self.loaded_db_version >= 100 and self.loaded_db_version // 100 > DB_VERSION // 100
|
||||
):
|
||||
mismatch_text = Translations["status.library_version_mismatch"]
|
||||
found_text = Translations["status.library_version_found"]
|
||||
expected_text = Translations["status.library_version_expected"]
|
||||
raise MigrationError(
|
||||
f"{mismatch_text}\n"
|
||||
f"{found_text} v{self.loaded_db_version}, "
|
||||
f"{expected_text} v{DB_VERSION}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[Library][Migration] "
|
||||
f"Opening Library with DB Version {self.loaded_db_version}/{DB_VERSION}"
|
||||
)
|
||||
|
||||
@property
|
||||
def required(self) -> bool:
|
||||
return self.loaded_db_version < DB_VERSION
|
||||
|
||||
def run(self):
|
||||
if not self.required:
|
||||
return
|
||||
|
||||
# migrate DB step by step from one version to the next
|
||||
# (migration_method, db_version, initial_db_version)
|
||||
migrations: list[type[DBMigration]] = [
|
||||
MigrationTo7, # changes: value_type, tags
|
||||
MigrationTo8, # changes: tag_colors
|
||||
MigrationTo9, # changes: entries
|
||||
MigrationTo100, # changes: tag_parents
|
||||
MigrationTo101, # changes: versions
|
||||
MigrationTo102, # changes: tag_parents
|
||||
MigrationTo103, # changes: tags
|
||||
MigrationTo104, # changes: deletes preferences
|
||||
MigrationTo200, # changes: field tables
|
||||
MigrationTo201, # changes: field tables
|
||||
MigrationTo202, # changes: tag_parents
|
||||
MigrationTo300, # changes: deletes folders
|
||||
MigrationTo400, # changes: add category_exclusions
|
||||
]
|
||||
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(
|
||||
None, # TODO: remove session param once all Migrations have been updated
|
||||
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,
|
||||
)
|
||||
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:
|
||||
"""Get a version value from the DB.
|
||||
|
||||
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:
|
||||
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
|
||||
self._connection.execute(
|
||||
"INSERT INTO versions (key, value) VALUES (?, ?)"
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
[key, value],
|
||||
)
|
||||
|
||||
|
||||
class MigrationTo7(DBMigration):
|
||||
version = 7
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
|
||||
|
||||
class MigrationTo8(DBMigration):
|
||||
version = 8
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
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"]
|
||||
]
|
||||
|
||||
# Add any new default colors introduced in DB_VERSION 8
|
||||
for color in tag_colors:
|
||||
session.add(color)
|
||||
session.flush()
|
||||
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()
|
||||
|
||||
|
||||
class MigrationTo9(DBMigration):
|
||||
version = 9
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
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()
|
||||
logger.info(fmt_log("Populated filename column in entries table"))
|
||||
|
||||
|
||||
class MigrationTo100(DBMigration):
|
||||
version = 100
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
logger.info(fmt_log("Refactored TagParent table"))
|
||||
|
||||
|
||||
class MigrationTo101(DBMigration):
|
||||
version = 101
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
# Ensure version rows are present
|
||||
session.add(Version(key=DB_VERSION_INITIAL_KEY, value=100))
|
||||
session.flush()
|
||||
logger.info(fmt_log("Created versions table"))
|
||||
|
||||
|
||||
class MigrationTo102(DBMigration):
|
||||
version = 102
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
logger.info(fmt_log("Verified TagParent table data"))
|
||||
|
||||
|
||||
class MigrationTo103(DBMigration):
|
||||
version = 103
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
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()
|
||||
logger.info(fmt_log("Updated archived tag to be hidden"))
|
||||
|
||||
|
||||
class MigrationTo104(DBMigration):
|
||||
version = 104
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
|
||||
@classmethod
|
||||
def __migrate_sql_to_ts_ignore(cls, session: Session, 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():
|
||||
return
|
||||
|
||||
# Load legacy extension data
|
||||
extensions: list[str] = ujson.loads(
|
||||
unwrap(
|
||||
session.scalar(text("SELECT value FROM preferences WHERE key = 'EXTENSION_LIST'"))
|
||||
)
|
||||
)
|
||||
is_exclude_list: bool = unwrap(
|
||||
session.scalar(text("SELECT value FROM preferences WHERE key = 'IS_EXCLUDE_LIST'"))
|
||||
)
|
||||
|
||||
with open(ts_ignore, "w") as f:
|
||||
f.write(migrate_ext_list(extensions, is_exclude_list))
|
||||
|
||||
|
||||
class MigrationTo200(DBMigration):
|
||||
version = 200
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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"))
|
||||
|
||||
# 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)
|
||||
|
||||
# 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"))
|
||||
|
||||
# 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()
|
||||
|
||||
# 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()
|
||||
|
||||
# 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("_", " ")
|
||||
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()
|
||||
|
||||
# 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)
|
||||
)
|
||||
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)
|
||||
|
||||
# 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
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.execute(
|
||||
text("""
|
||||
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()
|
||||
|
||||
# DB indices for improved performance
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
)
|
||||
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)")
|
||||
)
|
||||
|
||||
|
||||
class MigrationTo201(DBMigration):
|
||||
version = 201
|
||||
initial_version = 200
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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"))
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class MigrationTo202(DBMigration):
|
||||
version = 202
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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()
|
||||
logger.info(fmt_log("Verified TagParent table data"))
|
||||
|
||||
|
||||
class MigrationTo300(DBMigration):
|
||||
version = 300
|
||||
|
||||
@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)
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
# transfer data to new table
|
||||
session.execute(
|
||||
text("""
|
||||
INSERT INTO entries_new (id, path, suffix, date_created, date_modified, date_added,
|
||||
filename)
|
||||
SELECT id, path, suffix, date_created, date_modified, date_added, filename
|
||||
FROM entries
|
||||
""")
|
||||
)
|
||||
# delete old table
|
||||
session.execute(text("DROP TABLE entries"))
|
||||
# rename new table to old table
|
||||
session.execute(text("ALTER TABLE entries_new RENAME TO entries"))
|
||||
session.flush()
|
||||
|
||||
## drop table "folders"
|
||||
session.execute(text("DROP TABLE folders"))
|
||||
session.flush()
|
||||
|
||||
|
||||
class MigrationTo400(DBMigration):
|
||||
version = 400
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def run(cls, session: Session, 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),
|
||||
|
||||
PRIMARY KEY (tag_id, category_id)
|
||||
)
|
||||
""")
|
||||
)
|
||||
session.flush()
|
||||
@@ -16,7 +16,7 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
DatetimeField,
|
||||
TextField,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.joins import TagParent
|
||||
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent
|
||||
|
||||
|
||||
class Namespace(Base):
|
||||
@@ -41,7 +41,7 @@ class TagAlias(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"))
|
||||
tag: Mapped["Tag"] = relationship(back_populates="aliases")
|
||||
tag: Mapped[Tag] = relationship(back_populates="aliases")
|
||||
|
||||
def __init__(self, name: str, tag_id: int | None = None):
|
||||
self.name = name
|
||||
@@ -97,13 +97,19 @@ class Tag(Base):
|
||||
is_hidden: Mapped[bool]
|
||||
icon: Mapped[str | None]
|
||||
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
|
||||
parent_tags: Mapped[set["Tag"]] = relationship(
|
||||
parent_tags: Mapped[set[Tag]] = relationship(
|
||||
secondary=TagParent.__tablename__,
|
||||
primaryjoin="Tag.id == TagParent.child_id",
|
||||
secondaryjoin="Tag.id == TagParent.parent_id",
|
||||
back_populates="parent_tags",
|
||||
)
|
||||
disambiguation_id: Mapped[int | None]
|
||||
category_exclusions: Mapped[set[Tag]] = relationship(
|
||||
secondary=CategoryExclusion.__tablename__,
|
||||
primaryjoin="Tag.id == CategoryExclusion.tag_id",
|
||||
secondaryjoin="Tag.id == CategoryExclusion.category_id",
|
||||
back_populates="category_exclusions",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
@@ -124,19 +130,24 @@ class Tag(Base):
|
||||
def alias_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.aliases]
|
||||
|
||||
@property
|
||||
def exclusion_ids(self) -> list[int]:
|
||||
return [tag.id for tag in self.category_exclusions]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
id: int | None = None,
|
||||
shorthand: str | None = None,
|
||||
aliases: set[TagAlias] | None = None,
|
||||
parent_tags: set["Tag"] | None = None,
|
||||
parent_tags: set[Tag] | None = None,
|
||||
icon: str | None = None,
|
||||
color_namespace: str | None = None,
|
||||
color_slug: str | None = None,
|
||||
disambiguation_id: int | None = None,
|
||||
is_category: bool = False,
|
||||
is_hidden: bool = False,
|
||||
category_exclusions: set[Tag] | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.aliases = aliases or set()
|
||||
@@ -149,6 +160,7 @@ class Tag(Base):
|
||||
self.is_category = is_category
|
||||
self.is_hidden = is_hidden
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.category_exclusions = category_exclusions or set()
|
||||
super().__init__()
|
||||
|
||||
@override
|
||||
@@ -169,36 +181,24 @@ class Tag(Base):
|
||||
return False
|
||||
return self.id == value.id
|
||||
|
||||
def __lt__(self, other: "Tag") -> bool:
|
||||
def __lt__(self, other: Tag) -> bool:
|
||||
return self.name < other.name
|
||||
|
||||
def __le__(self, other: "Tag") -> bool:
|
||||
def __le__(self, other: Tag) -> bool:
|
||||
return self.name <= other.name
|
||||
|
||||
def __gt__(self, other: "Tag") -> bool:
|
||||
def __gt__(self, other: Tag) -> bool:
|
||||
return self.name > other.name
|
||||
|
||||
def __ge__(self, other: "Tag") -> bool:
|
||||
def __ge__(self, other: Tag) -> bool:
|
||||
return self.name >= other.name
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
# TODO - implement this
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
uuid: Mapped[str] = mapped_column(unique=True)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
|
||||
folder: Mapped[Folder] = relationship("Folder")
|
||||
|
||||
path: Mapped[Path] = mapped_column(PathType, unique=True)
|
||||
filename: Mapped[str] = mapped_column()
|
||||
suffix: Mapped[str] = mapped_column()
|
||||
@@ -235,7 +235,6 @@ class Entry(Base):
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
folder: Folder,
|
||||
fields: list[BaseField],
|
||||
id: int | None = None,
|
||||
date_created: dt | None = None,
|
||||
@@ -244,7 +243,6 @@ class Entry(Base):
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.id = id # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.filename = path.name
|
||||
self.suffix = path.suffix.lstrip(".").lower()
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from wcmatch import pathlib
|
||||
from wcmatch import glob, pathlib
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
@@ -54,7 +54,7 @@ class UnlinkedRegistry:
|
||||
# NOTE: ignore_to_glob() is needed for wcmatch, not ripgrep.
|
||||
ignore_patterns = ignore_to_glob(Ignore.get_patterns(library_dir))
|
||||
for path in pathlib.Path(str(library_dir)).glob(
|
||||
f"***/{match_entry.path.name}",
|
||||
patterns=f"***/{glob.escape(match_entry.path.name)}",
|
||||
flags=PATH_GLOB_FLAGS,
|
||||
exclude=ignore_patterns,
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
@@ -861,7 +861,7 @@ class Library:
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(self.library_dir / t).stat().st_ctime,
|
||||
)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
except FileExistsError, FileNotFoundError:
|
||||
print(
|
||||
"[LIBRARY] [ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
|
||||
from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -41,7 +40,6 @@ class RefreshTracker:
|
||||
entries = [
|
||||
Entry(
|
||||
path=entry_path,
|
||||
folder=unwrap(self.library.folder),
|
||||
fields=[],
|
||||
date_added=dt.now(),
|
||||
)
|
||||
|
||||
@@ -105,6 +105,7 @@ class MediaCategories:
|
||||
# These sets are used either individually or together to form the final sets
|
||||
# for the MediaCategory(s).
|
||||
# These sets may be combined and are NOT 1:1 with the final categories.
|
||||
_ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"}
|
||||
_ADOBE_PHOTOSHOP_SET: set[str] = {
|
||||
".pdd",
|
||||
".psb",
|
||||
@@ -304,6 +305,7 @@ class MediaCategories:
|
||||
".nef",
|
||||
".nrw",
|
||||
".orf",
|
||||
".r3d",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
@@ -580,7 +582,7 @@ class MediaCategories:
|
||||
)
|
||||
PDF_TYPES = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET,
|
||||
extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET,
|
||||
is_iana=False,
|
||||
name="pdf",
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ class ConstraintType(Enum):
|
||||
Special = 5
|
||||
|
||||
@staticmethod
|
||||
def from_string(text: str) -> "ConstraintType | None":
|
||||
def from_string(text: str) -> ConstraintType | None:
|
||||
return {
|
||||
"tag": ConstraintType.Tag,
|
||||
"tag_id": ConstraintType.TagID,
|
||||
@@ -28,7 +28,7 @@ class ConstraintType(Enum):
|
||||
|
||||
|
||||
class AST:
|
||||
parent: "AST | None" = None
|
||||
parent: AST | None = None
|
||||
|
||||
@override
|
||||
def __str__(self):
|
||||
@@ -65,9 +65,9 @@ class ORList(AST):
|
||||
class Constraint(AST):
|
||||
type: ConstraintType
|
||||
value: str
|
||||
properties: list["Property"]
|
||||
properties: list[Property]
|
||||
|
||||
def __init__(self, type: ConstraintType, value: str, properties: list["Property"]) -> None:
|
||||
def __init__(self, type: ConstraintType, value: str, properties: list[Property]) -> None:
|
||||
super().__init__()
|
||||
for prop in properties:
|
||||
prop.parent = self
|
||||
|
||||
@@ -39,11 +39,11 @@ class Token:
|
||||
self.end = end
|
||||
|
||||
@staticmethod
|
||||
def from_type(type: TokenType, pos: int) -> "Token":
|
||||
def from_type(type: TokenType, pos: int) -> Token:
|
||||
return Token(type, None, pos, pos)
|
||||
|
||||
@staticmethod
|
||||
def EOF(pos: int) -> "Token": # noqa: N802
|
||||
def EOF(pos: int) -> Token: # noqa: N802
|
||||
return Token.from_type(TokenType.EOF, pos)
|
||||
|
||||
@override
|
||||
|
||||
@@ -25,6 +25,8 @@ class TagStudioCore:
|
||||
@lru_cache(maxsize=1)
|
||||
def get_most_recent_release_version() -> str | None:
|
||||
"""Get the version of the most recent GitHub release."""
|
||||
logger.info("[Core] Checking for updates...")
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest"
|
||||
|
||||
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
|
||||
return vcur.patch < vlat.patch
|
||||
else:
|
||||
return vcur.prerelease is not None or vcur.build is not None
|
||||
|
||||
|
||||
def format_duration(duration: int | float) -> str:
|
||||
"""Format a duration in seconds as M:SS or H:MM:SS."""
|
||||
try:
|
||||
seconds = int(float(duration))
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
|
||||
except OverflowError, ValueError:
|
||||
return "-:--"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
"""A collection of platform-dependant strings."""
|
||||
|
||||
import platform
|
||||
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.i18n.translations import Translations
|
||||
|
||||
|
||||
def open_file_str() -> str:
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from collections import defaultdict
|
||||
@@ -10,7 +10,7 @@ from typing import Any
|
||||
import structlog
|
||||
import ujson
|
||||
|
||||
from tagstudio.qt.mnemonics import remove_mnemonic_marker
|
||||
from tagstudio.qt.helpers.mnemonics import remove_mnemonic_marker
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -86,7 +86,7 @@ class Translator:
|
||||
def __format(self, text: str, **kwargs: ...) -> str:
|
||||
try:
|
||||
return text.format(**kwargs)
|
||||
except (KeyError, ValueError):
|
||||
except KeyError, ValueError:
|
||||
logger.error(
|
||||
"[Translations] Error while formatting translation.",
|
||||
text=text,
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
"""TagStudio launcher."""
|
||||
|
||||
import argparse
|
||||
import traceback
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.constants import VERSION, VERSION_BRANCH
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
# appid = "cyanvoxel.tagstudio.9"
|
||||
# ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
|
||||
|
||||
# Parse arguments.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--open",
|
||||
dest="open",
|
||||
type=str,
|
||||
help="Path to a TagStudio Library folder to open on start.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--settings-file",
|
||||
dest="settings_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .toml global settings file to use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--cache-file",
|
||||
dest="cache_file",
|
||||
type=str,
|
||||
help="Path to a TagStudio .ini or .plist cache file to use.",
|
||||
)
|
||||
|
||||
# parser.add_argument('--browse', dest='browse', action='store_true',
|
||||
# help='Jumps to entry browsing on startup.')
|
||||
# parser.add_argument('--external_preview', dest='external_preview', action='store_true',
|
||||
# help='Outputs current preview thumbnail to a live-updating file.')
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
dest="debug",
|
||||
action="store_true",
|
||||
help="Reveals additional internal data useful for debugging.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="version",
|
||||
help="Displays TagStudio version information.",
|
||||
version=f"TagStudio v{VERSION} {VERSION_BRANCH}",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
driver = QtDriver(args)
|
||||
ui_name = "Qt"
|
||||
|
||||
# Run the chosen frontend driver.
|
||||
try:
|
||||
driver.start()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
logger.info(f"\nTagStudio Frontend ({ui_name}) Crashed! Press Enter to Continue...")
|
||||
input()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,913 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import math
|
||||
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.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories, MediaType
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
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
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FileRenderer:
|
||||
"""A class for rendering image previews and thumbnails from files."""
|
||||
|
||||
rm: ResourceManager = ResourceManager()
|
||||
cached_img_ext: str = ".webp"
|
||||
|
||||
def __init__(self, library: Library, settings: AppSettings) -> None:
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.settings = settings
|
||||
|
||||
# Cached thumbnail elements.
|
||||
# Key: Size + Pixel Ratio Tuple + Radius Scale
|
||||
# (Ex. (512, 512, 1.25, 4))
|
||||
self.thumb_masks: dict[tuple[int, int, float, float], Image.Image] = {}
|
||||
self.raised_edges: dict[tuple[int, int, float], tuple[Image.Image, Image.Image]] = {}
|
||||
|
||||
# Key: ("name", UiColor, 512, 512, 1.25)
|
||||
self.icons: dict[tuple[str, UiColor, int, int, float], Image.Image] = {}
|
||||
|
||||
def _get_resource_id(self, url: Path) -> str:
|
||||
"""Return the name of the icon resource to use for a file type.
|
||||
|
||||
Special terms will return special resources.
|
||||
|
||||
Args:
|
||||
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
|
||||
|
||||
return "file_generic"
|
||||
|
||||
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
|
||||
def _get_mask(
|
||||
self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False
|
||||
) -> Image.Image:
|
||||
"""Return a thumbnail mask given a size, pixel ratio, and radius scaling option.
|
||||
|
||||
If one is not already cached, a new one will be rendered.
|
||||
|
||||
Args:
|
||||
size (tuple[int, int]): The size of the graphic.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
scale_radius (bool): Option to scale the radius up (Used for Preview Panel).
|
||||
"""
|
||||
thumb_scale: int = 512
|
||||
radius_scale: float = 1
|
||||
if scale_radius:
|
||||
radius_scale = max(size[0], size[1]) / thumb_scale
|
||||
|
||||
item: Image.Image | None = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
|
||||
if not item:
|
||||
item = self._render_mask(size, pixel_ratio, radius_scale)
|
||||
self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item
|
||||
return item
|
||||
|
||||
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
|
||||
def _get_edge(
|
||||
self, size: tuple[int, int], pixel_ratio: float
|
||||
) -> tuple[Image.Image, Image.Image]:
|
||||
"""Return a thumbnail edge given a size, pixel ratio, and radius scaling option.
|
||||
|
||||
If one is not already cached, a new one will be rendered.
|
||||
|
||||
Args:
|
||||
size (tuple[int, int]): The size of the graphic.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
"""
|
||||
item: tuple[Image.Image, Image.Image] | None = self.raised_edges.get((*size, pixel_ratio))
|
||||
if not item:
|
||||
item = self._render_edge(size, pixel_ratio)
|
||||
self.raised_edges[(*size, pixel_ratio)] = item
|
||||
return item
|
||||
|
||||
def _get_icon(
|
||||
self,
|
||||
name: str,
|
||||
color: UiColor,
|
||||
size: tuple[int, int],
|
||||
theme: Theme,
|
||||
dpi_scale: float = 1.0,
|
||||
bg_image: Image.Image | None = None,
|
||||
draw_edge: bool = True,
|
||||
is_corner: bool = False,
|
||||
) -> Image.Image:
|
||||
"""Return an icon given a size, pixel ratio, and radius scaling option.
|
||||
|
||||
Args:
|
||||
name (str): The name of the icon resource. "thumb_loading" will not draw a border.
|
||||
color (str): The color to use for the icon.
|
||||
size (tuple[int,int]): The size of the icon.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
dpi_scale (float): The screen pixel ratio.
|
||||
bg_image (Image.Image): Optional background image to go behind the icon.
|
||||
draw_edge (bool): Flag for is the raised edge should be drawn.
|
||||
is_corner (bool): Flag for is the icon should render with the "corner" style
|
||||
"""
|
||||
draw_border: bool = True
|
||||
if name == "thumb_loading":
|
||||
draw_border = False
|
||||
|
||||
item: Image.Image | None = self.icons.get((name, color, *size, dpi_scale))
|
||||
if not item:
|
||||
item_flat: Image.Image = (
|
||||
self._render_corner_icon(name, color, size, dpi_scale, theme, bg_image)
|
||||
if is_corner
|
||||
else self._render_center_icon(
|
||||
name, color, size, dpi_scale, theme, draw_border, bg_image
|
||||
)
|
||||
)
|
||||
if draw_edge:
|
||||
edge: tuple[Image.Image, Image.Image] = self._get_edge(size, dpi_scale)
|
||||
item = self._apply_edge(item_flat, edge, theme, faded=True)
|
||||
self.icons[(name, color, *size, dpi_scale)] = item
|
||||
else:
|
||||
item = item_flat
|
||||
return item
|
||||
|
||||
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
|
||||
def _render_mask(
|
||||
self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1
|
||||
) -> Image.Image:
|
||||
"""Render a thumbnail mask graphic.
|
||||
|
||||
Args:
|
||||
size (tuple[int,int]): The size of the graphic.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
radius_scale (float): The scale factor of the border radius (Used by Preview Panel).
|
||||
"""
|
||||
smooth_factor: int = 2
|
||||
radius_factor: int = 8
|
||||
|
||||
im: Image.Image = Image.new(
|
||||
mode="L",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="black",
|
||||
)
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + tuple([d - 1 for d in im.size]),
|
||||
radius=math.ceil(radius_factor * smooth_factor * pixel_ratio * radius_scale),
|
||||
fill="white",
|
||||
)
|
||||
im = im.resize(
|
||||
size,
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
)
|
||||
return im
|
||||
|
||||
# NOTE: This method will be replaced with frontend specific decorations (Qt painting)
|
||||
def _render_edge(
|
||||
self, size: tuple[int, int], pixel_ratio: float
|
||||
) -> tuple[Image.Image, Image.Image]:
|
||||
"""Render a thumbnail edge graphic.
|
||||
|
||||
Args:
|
||||
size (tuple[int,int]): The size of the graphic.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
"""
|
||||
smooth_factor: int = 2
|
||||
radius_factor: int = 8
|
||||
width: int = math.floor(pixel_ratio * 2)
|
||||
|
||||
# Highlight
|
||||
im_hl: Image.Image = Image.new(
|
||||
mode="RGBA",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="#00000000",
|
||||
)
|
||||
draw = ImageDraw.Draw(im_hl)
|
||||
draw.rounded_rectangle(
|
||||
(width, width) + tuple([d - (width + 1) for d in im_hl.size]),
|
||||
radius=math.ceil((radius_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 3)),
|
||||
fill=None,
|
||||
outline="white",
|
||||
width=width,
|
||||
)
|
||||
im_hl = im_hl.resize(size, resample=Image.Resampling.BILINEAR)
|
||||
|
||||
# Shadow
|
||||
im_sh: Image.Image = Image.new(
|
||||
mode="RGBA",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="#00000000",
|
||||
)
|
||||
draw = ImageDraw.Draw(im_sh)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + tuple([d - 1 for d in im_sh.size]),
|
||||
radius=math.ceil(radius_factor * smooth_factor * pixel_ratio),
|
||||
fill=None,
|
||||
outline="black",
|
||||
width=width,
|
||||
)
|
||||
im_sh = im_sh.resize(size, resample=Image.Resampling.BILINEAR)
|
||||
|
||||
return (im_hl, im_sh)
|
||||
|
||||
def _render_center_icon(
|
||||
self,
|
||||
name: str,
|
||||
color: UiColor,
|
||||
size: tuple[int, int],
|
||||
pixel_ratio: float,
|
||||
theme: Theme,
|
||||
draw_border: bool = True,
|
||||
bg_image: Image.Image | None = None,
|
||||
) -> Image.Image:
|
||||
"""Render a thumbnail icon.
|
||||
|
||||
Args:
|
||||
name (str): The name of the icon resource.
|
||||
color (UiColor): The color to use for the icon.
|
||||
size (tuple[int,int]): The size of the icon.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
draw_border (bool): Option to draw a border.
|
||||
bg_image (Image.Image): Optional background image to go behind the icon.
|
||||
"""
|
||||
border_factor: int = 5
|
||||
smooth_factor: int = math.ceil(2 * pixel_ratio)
|
||||
radius_factor: int = 8
|
||||
icon_ratio: float = 1.75
|
||||
|
||||
# Create larger blank image based on smooth_factor
|
||||
im: Image.Image = Image.new(
|
||||
"RGBA",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="#FF000000",
|
||||
)
|
||||
|
||||
# Create solid background color
|
||||
bg: Image.Image
|
||||
bg = Image.new(
|
||||
"RGB",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="#000000FF",
|
||||
)
|
||||
|
||||
# Use a background image if provided
|
||||
if bg_image:
|
||||
bg_im = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # pyright: ignore[reportArgumentType]
|
||||
bg_im = ImageEnhance.Brightness(bg_im).enhance(0.3) # Reduce the brightness
|
||||
bg.paste(bg_im)
|
||||
|
||||
# Paste background color with rounded rectangle mask onto blank image
|
||||
im.paste(
|
||||
bg,
|
||||
(0, 0),
|
||||
mask=self._get_mask(
|
||||
tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
(pixel_ratio * smooth_factor),
|
||||
),
|
||||
)
|
||||
|
||||
# Draw rounded rectangle border
|
||||
if draw_border:
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + tuple([d - 1 for d in im.size]),
|
||||
radius=math.ceil(
|
||||
(radius_factor * smooth_factor * pixel_ratio) + (pixel_ratio * 1.5)
|
||||
),
|
||||
fill=None if bg_image else "black",
|
||||
outline="#FF0000",
|
||||
width=math.floor(
|
||||
(border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5)
|
||||
),
|
||||
)
|
||||
|
||||
# Resize image to final size
|
||||
im = im.resize(size, resample=Image.Resampling.BILINEAR)
|
||||
fg: Image.Image = Image.new("RGB", size=size, color="#00FF00")
|
||||
|
||||
# Get icon by name
|
||||
icon = self.rm.get(name)
|
||||
assert isinstance(icon, Image.Image) or icon is None
|
||||
if not icon:
|
||||
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)))
|
||||
|
||||
# Paste icon centered
|
||||
im.paste(
|
||||
im=fg.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))),
|
||||
box=(
|
||||
math.ceil((size[0] - (size[0] // icon_ratio)) // 2),
|
||||
math.ceil((size[1] - (size[1] // icon_ratio)) // 2),
|
||||
),
|
||||
mask=icon.getchannel(3),
|
||||
)
|
||||
|
||||
# Apply color overlay
|
||||
im = self._apply_overlay_color(im, color, theme)
|
||||
|
||||
return im
|
||||
|
||||
def _render_corner_icon(
|
||||
self,
|
||||
name: str,
|
||||
color: UiColor,
|
||||
size: tuple[int, int],
|
||||
pixel_ratio: float,
|
||||
theme: Theme,
|
||||
bg_image: Image.Image | None = None,
|
||||
) -> Image.Image:
|
||||
"""Render a thumbnail icon with the icon in the upper-left corner.
|
||||
|
||||
Args:
|
||||
name (str): The name of the icon resource.
|
||||
color (UiColor): The color to use for the icon.
|
||||
size (tuple[int,int]): The size of the icon.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
draw_border (bool): Option to draw a border.
|
||||
bg_image (Image.Image): Optional background image to go behind the icon.
|
||||
"""
|
||||
smooth_factor: int = math.ceil(2 * pixel_ratio)
|
||||
icon_ratio: float = 5
|
||||
padding_factor = 18
|
||||
|
||||
# Create larger blank image based on smooth_factor
|
||||
im: Image.Image = Image.new(
|
||||
"RGBA",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="#00000000",
|
||||
)
|
||||
|
||||
bg: Image.Image
|
||||
# Use a background image if provided
|
||||
if bg_image:
|
||||
bg = Image.Image.resize(bg_image, size=tuple([d * smooth_factor for d in size])) # pyright: ignore[reportArgumentType]
|
||||
# Create solid background color
|
||||
else:
|
||||
bg = Image.new(
|
||||
"RGB",
|
||||
size=tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
color="#000000",
|
||||
)
|
||||
# Apply color overlay
|
||||
bg = self._apply_overlay_color(im, color, theme)
|
||||
|
||||
# Paste background color with rounded rectangle mask onto blank image
|
||||
im.paste(
|
||||
bg,
|
||||
(0, 0),
|
||||
mask=self._get_mask(
|
||||
tuple([d * smooth_factor for d in size]), # pyright: ignore[reportArgumentType]
|
||||
(pixel_ratio * smooth_factor),
|
||||
),
|
||||
)
|
||||
|
||||
colors = UI_COLORS.get(color) or UI_COLORS[UiColor.DEFAULT]
|
||||
primary_color = colors.get(ColorType.PRIMARY)
|
||||
|
||||
# Resize image to final size
|
||||
im = im.resize(size, resample=Image.Resampling.BILINEAR)
|
||||
fg: Image.Image = Image.new("RGB", size=size, color=primary_color)
|
||||
|
||||
# Get icon by name
|
||||
icon = self.rm.get(name)
|
||||
assert isinstance(icon, Image.Image)
|
||||
if not icon:
|
||||
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)))
|
||||
|
||||
# Paste icon
|
||||
im.paste(
|
||||
im=fg.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))),
|
||||
box=(size[0] // padding_factor, size[1] // padding_factor),
|
||||
mask=icon.getchannel(3),
|
||||
)
|
||||
|
||||
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,
|
||||
image: Image.Image,
|
||||
edge: tuple[Image.Image, Image.Image],
|
||||
theme: Theme,
|
||||
faded: bool = False,
|
||||
) -> Image.Image:
|
||||
"""Apply a given edge effect to an image.
|
||||
|
||||
Args:
|
||||
image (Image.Image): The image to apply the edge to.
|
||||
edge (tuple[Image.Image, Image.Image]): The edge images to apply.
|
||||
Item 0 is the inner highlight, and item 1 is the outer shadow.
|
||||
theme (Theme): A theme enum to determine the light/dark theme.
|
||||
faded (bool): Whether to apply a faded version of the edge.
|
||||
Used for light themes.
|
||||
"""
|
||||
opacity: float = 1.0 if not faded else 0.8
|
||||
shade_reduction: float = 0 if theme == Theme.DARK else 0.3
|
||||
im: Image.Image = image
|
||||
im_hl, im_sh = deepcopy(edge)
|
||||
|
||||
# Configure and apply a soft light overlay.
|
||||
# This makes up the bulk of the effect.
|
||||
im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(opacity))
|
||||
im.paste(ImageChops.soft_light(im, im_hl), mask=im_hl.getchannel(3))
|
||||
|
||||
# Configure and apply a normal shading overlay.
|
||||
# This helps with contrast.
|
||||
im_sh.putalpha(
|
||||
ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(max(0, opacity - shade_reduction))
|
||||
)
|
||||
im.paste(im_sh, mask=im_sh.getchannel(3))
|
||||
|
||||
return im
|
||||
|
||||
def render(
|
||||
self,
|
||||
cache: CacheManager | None,
|
||||
timestamp: float,
|
||||
filepath: Path | str,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
theme: Theme = Theme.DARK,
|
||||
is_loading: bool = False,
|
||||
is_thumb: bool = False,
|
||||
):
|
||||
"""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_loading (bool): Is this a loading graphic?
|
||||
is_thumb (bool): Is this specifically a thumbnail? Use for specifying small variants.
|
||||
update_on_ratio_change (bool): Should an updated ratio signal be sent?
|
||||
"""
|
||||
render_mask_and_edge: bool = True
|
||||
scaled_size = math.ceil(max(size[0], size[1]) * dpi_scale)
|
||||
theme_color: UiColor = UiColor.THEME_LIGHT if theme == Theme.LIGHT else UiColor.THEME_DARK
|
||||
if isinstance(filepath, str):
|
||||
filepath = Path(filepath)
|
||||
|
||||
def render_default(size: tuple[int, int], dpi_scale: float) -> Image.Image:
|
||||
im = self._get_icon(
|
||||
name=self._get_resource_id(filepath),
|
||||
color=theme_color,
|
||||
size=size,
|
||||
theme=theme,
|
||||
dpi_scale=dpi_scale,
|
||||
)
|
||||
return im
|
||||
|
||||
def render_unlinked(
|
||||
size: tuple[int, int], dpi_scale: float, cached_im: Image.Image | None = None
|
||||
) -> Image.Image:
|
||||
im = self._get_icon(
|
||||
name="broken_link_icon",
|
||||
color=UiColor.RED,
|
||||
size=size,
|
||||
theme=theme,
|
||||
dpi_scale=dpi_scale,
|
||||
bg_image=cached_im,
|
||||
draw_edge=not cached_im,
|
||||
is_corner=False,
|
||||
)
|
||||
return im
|
||||
|
||||
def render_ignored(size: tuple[int, int], im: Image.Image) -> Image.Image:
|
||||
icon_ratio: float = 5
|
||||
padding_factor = 18
|
||||
|
||||
im_ = im
|
||||
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(
|
||||
(math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))
|
||||
),
|
||||
box=(size[0] // padding_factor, size[1] // padding_factor),
|
||||
mask=icon.getchannel(3),
|
||||
)
|
||||
|
||||
return im_
|
||||
|
||||
def fetch_cached_image(file_name: Path):
|
||||
image: Image.Image | None = None
|
||||
if not cache:
|
||||
return image
|
||||
cached_path = cache.get_file_path(file_name)
|
||||
|
||||
if cached_path and cached_path.is_file():
|
||||
try:
|
||||
image = Image.open(cached_path)
|
||||
if not image:
|
||||
raise UnidentifiedImageError # pyright: ignore[reportUnreachable]
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[FileRenderer] Couldn't open cached thumbnail!", path=cached_path, error=e
|
||||
)
|
||||
return image
|
||||
|
||||
image: Image.Image | None = None
|
||||
# Try to get a non-loading thumbnail for the grid.
|
||||
if not is_loading and is_thumb and filepath and filepath != Path("."):
|
||||
# Attempt to retrieve cached image from disk
|
||||
mod_time: str = ""
|
||||
with contextlib.suppress(Exception):
|
||||
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}")
|
||||
image = fetch_cached_image(file_name)
|
||||
|
||||
if not image and self.settings.generate_thumbs:
|
||||
settings_res = self.settings.cached_thumb_resolution
|
||||
thumb_res = (
|
||||
settings_res
|
||||
if settings_res >= MIN_CACHED_THUMB_RES and settings_res <= MAX_CACHED_THUMB_RES
|
||||
else DEFAULT_CACHED_THUMB_RES
|
||||
)
|
||||
|
||||
# Render from file, return result, and try to save a cached version.
|
||||
# TODO: Audio waveforms are dynamically sized based on the base_size, so hardcoding
|
||||
# the resolution breaks that.
|
||||
image = self._render(
|
||||
cache=cache,
|
||||
filepath=filepath,
|
||||
size=(thumb_res, thumb_res),
|
||||
dpi_scale=1,
|
||||
theme=theme,
|
||||
is_thumb=is_thumb,
|
||||
cache_filename=file_name,
|
||||
)
|
||||
|
||||
# If the normal renderer failed, fallback the defaults
|
||||
# (with native non-cached sizing!)
|
||||
if not image:
|
||||
image = (
|
||||
render_unlinked((scaled_size, scaled_size), dpi_scale)
|
||||
if not filepath.exists() or filepath.is_dir()
|
||||
else render_default((scaled_size, scaled_size), dpi_scale)
|
||||
)
|
||||
render_mask_and_edge = False
|
||||
|
||||
# Apply the mask and edge
|
||||
if image:
|
||||
image = self._resize_image(image, (scaled_size, scaled_size))
|
||||
if render_mask_and_edge:
|
||||
mask = self._get_mask((scaled_size, scaled_size), dpi_scale)
|
||||
edge: tuple[Image.Image, Image.Image] = self._get_edge(
|
||||
(scaled_size, scaled_size), dpi_scale
|
||||
)
|
||||
image = self._apply_edge(
|
||||
four_corner_gradient(image, (scaled_size, scaled_size), mask), edge, theme
|
||||
)
|
||||
|
||||
# Check if the file is supposed to be ignored and render an overlay if needed
|
||||
try:
|
||||
if (
|
||||
image
|
||||
and Ignore.compiled_patterns
|
||||
and Ignore.compiled_patterns.match(
|
||||
filepath.relative_to(unwrap(self.lib.library_dir))
|
||||
)
|
||||
):
|
||||
image = render_ignored((scaled_size, scaled_size), image)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# A loading thumbnail (cached in memory)
|
||||
elif is_loading:
|
||||
# Initialize "Loading" thumbnail
|
||||
loading_thumb: Image.Image = self._get_icon(
|
||||
"thumb_loading", theme_color, (scaled_size, scaled_size), theme, dpi_scale
|
||||
)
|
||||
image = loading_thumb.resize(
|
||||
(scaled_size, scaled_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
# A full preview image (never cached)
|
||||
elif not is_thumb:
|
||||
image = self._render(cache, filepath, size, dpi_scale, theme)
|
||||
if not image:
|
||||
image = (
|
||||
render_unlinked((512, 512), 2)
|
||||
if not filepath.exists() or filepath.is_dir()
|
||||
else render_default((512, 512), 2)
|
||||
)
|
||||
render_mask_and_edge = False
|
||||
mask = self._get_mask(image.size, dpi_scale, scale_radius=True)
|
||||
bg = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
bg.paste(image, mask=mask.getchannel(0))
|
||||
image = bg
|
||||
|
||||
# If the image couldn't be rendered, use a default media image.
|
||||
if not image:
|
||||
image = Image.new("RGBA", (128, 128), color="#FF00FF")
|
||||
|
||||
return (
|
||||
image,
|
||||
(math.ceil(scaled_size / dpi_scale), math.ceil(image.size[1] / dpi_scale)),
|
||||
timestamp,
|
||||
)
|
||||
|
||||
def _render(
|
||||
self,
|
||||
cache: CacheManager | None,
|
||||
filepath: str | Path,
|
||||
size: tuple[int, int],
|
||||
dpi_scale: float,
|
||||
theme: Theme = Theme.DARK,
|
||||
is_thumb: 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.
|
||||
cache_filename (Path | None): An optional filename to use to save to the cache.
|
||||
|
||||
"""
|
||||
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():
|
||||
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
|
||||
|
||||
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:
|
||||
image = None
|
||||
|
||||
return image
|
||||
|
||||
def _resize_image(self, image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
orig_x, orig_y = image.size
|
||||
new_x, new_y = size
|
||||
|
||||
if orig_x > orig_y:
|
||||
new_x = size[0]
|
||||
new_y = math.ceil(size[1] * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = size[1]
|
||||
new_x = math.ceil(size[0] * (orig_x / orig_y))
|
||||
|
||||
resampling_method = (
|
||||
Image.Resampling.NEAREST
|
||||
if max(image.size[0], image.size[1]) < max(size)
|
||||
else Image.Resampling.BILINEAR
|
||||
)
|
||||
image = image.resize((new_x, new_y), resample=resampling_method)
|
||||
|
||||
return image
|
||||
@@ -0,0 +1,164 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import tarfile
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import py7zr
|
||||
import py7zr.io
|
||||
import rarfile
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.previews.renderers.raster_image import image_from_bytes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
|
||||
|
||||
|
||||
class SevenZipFile(py7zr.SevenZipFile):
|
||||
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
|
||||
|
||||
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
|
||||
super().__init__(filepath, mode)
|
||||
|
||||
def read(self, name: str) -> bytes:
|
||||
# SevenZipFile must be reset after every extraction
|
||||
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
|
||||
self.reset()
|
||||
factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB
|
||||
self.extract(targets=[name], factory=factory)
|
||||
return factory.get(name).read()
|
||||
|
||||
|
||||
class TarFile:
|
||||
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
|
||||
|
||||
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
|
||||
self.tar: tarfile.TarFile
|
||||
self.filepath = filepath
|
||||
self.mode: Literal["r"] = mode
|
||||
|
||||
def namelist(self) -> list[str]:
|
||||
return self.tar.getnames()
|
||||
|
||||
def read(self, name: str) -> bytes:
|
||||
return unwrap(self.tar.extractfile(name)).read()
|
||||
|
||||
def __enter__(self) -> TarFile:
|
||||
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
|
||||
self.tar.__exit__(*args)
|
||||
|
||||
|
||||
def open_archive(filepath: Path, ext: str = "") -> Archive:
|
||||
"""Open an archive with its corresponding archiver.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the archive.
|
||||
ext (str): The file extension.
|
||||
|
||||
Returns:
|
||||
Archive: The opened archive.
|
||||
"""
|
||||
archiver: type[Archive] = zipfile.ZipFile
|
||||
if ext in {".7z", ".cb7", ".s7z"}:
|
||||
archiver = SevenZipFile
|
||||
elif ext in {".cbr", ".rar"}:
|
||||
archiver = rarfile.RarFile
|
||||
elif ext in {".cbt", ".tar", ".tgz"}:
|
||||
archiver = TarFile
|
||||
return archiver(filepath, "r")
|
||||
|
||||
|
||||
def first_image_in_archive(archive: Archive) -> Image.Image | None:
|
||||
"""Find and extract the first renderable image in the archive.
|
||||
|
||||
Args:
|
||||
archive (Archive): The current archive.
|
||||
|
||||
Returns:
|
||||
Image: The first renderable image in the archive.
|
||||
"""
|
||||
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
|
||||
ext = Path(file_name).suffix
|
||||
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
|
||||
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
|
||||
return image_from_bytes(BytesIO(image_data))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def archive_thumb(
|
||||
filepath: Path,
|
||||
image_names: list[Path] | list[str] | None = None,
|
||||
ext: str = "",
|
||||
) -> Image.Image | None:
|
||||
"""Extract an embedded preview image from an archive.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the archive.
|
||||
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
|
||||
ext (str): The file extension. Used to help determine more specific archive type.
|
||||
|
||||
Returns:
|
||||
Image: The first image found in the archive.
|
||||
"""
|
||||
try:
|
||||
with open_archive(filepath, ext) as archive:
|
||||
# If no list of image names to search for was provided, default to the first image.
|
||||
if not image_names:
|
||||
return first_image_in_archive(archive)
|
||||
|
||||
for image_name in image_names:
|
||||
if image_name in archive.namelist():
|
||||
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
|
||||
return image_from_bytes(BytesIO(file_data))
|
||||
|
||||
# If no images were found with the given names, fallback to the first image found.
|
||||
if not image_names:
|
||||
return first_image_in_archive(archive)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,149 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from warnings import catch_warnings
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
from mutagen import flac, id3, mp4
|
||||
from mutagen._util import MutagenError
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from tagstudio.previews.vendored.pydub.audio_segment import (
|
||||
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
|
||||
"""Return an album cover thumb from an audio file if a cover is present.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
ext (str): The file extension (with leading ".").
|
||||
"""
|
||||
image: Image.Image | None = None
|
||||
try:
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError
|
||||
|
||||
artwork = None
|
||||
if ext in [".mp3"]:
|
||||
id3_tags: id3.ID3 = id3.ID3(filepath)
|
||||
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
|
||||
if id3_covers:
|
||||
artwork = Image.open(BytesIO(id3_covers[0].data))
|
||||
elif ext in [".flac"]:
|
||||
flac_tags: flac.FLAC = flac.FLAC(filepath)
|
||||
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
|
||||
if flac_covers:
|
||||
artwork = Image.open(BytesIO(flac_covers[0].data))
|
||||
elif ext in [".mp4", ".m4a", ".aac"]:
|
||||
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
|
||||
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
|
||||
if mp4_covers:
|
||||
artwork = Image.open(BytesIO(mp4_covers[0]))
|
||||
if artwork:
|
||||
image = artwork
|
||||
except (
|
||||
FileNotFoundError,
|
||||
id3.ID3NoHeaderError,
|
||||
mp4.MP4MetadataError,
|
||||
mp4.MP4StreamInfoError,
|
||||
MutagenError,
|
||||
) as e:
|
||||
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
|
||||
return image
|
||||
|
||||
|
||||
def audio_waveform_thumb(
|
||||
filepath: Path, ext: str, size: int, pixel_ratio: float
|
||||
) -> Image.Image | None:
|
||||
"""Render a waveform image from an audio file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
ext (str): The file extension (with leading ".").
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
"""
|
||||
# BASE_SCALE used for drawing on a larger image and resampling down
|
||||
# to provide an antialiased effect.
|
||||
base_scale: int = 2
|
||||
samples_per_bar: int = 3
|
||||
size_scaled: int = size * base_scale
|
||||
allow_small_min: bool = False
|
||||
im: Image.Image | None = None
|
||||
|
||||
try:
|
||||
bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64)
|
||||
audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType]
|
||||
data = np.frombuffer(buffer=audio._data, dtype=np.int16)
|
||||
data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar)
|
||||
bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2
|
||||
line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale
|
||||
bar_height: float = (size_scaled) - (size_scaled // bar_margin)
|
||||
|
||||
count: int = 0
|
||||
maximum_item: int = 0
|
||||
max_array: list[int] = []
|
||||
highest_line: int = 0
|
||||
|
||||
for i in range(-1, len(data_indices)):
|
||||
d = data[math.ceil(data_indices[i]) - 1]
|
||||
if count < samples_per_bar:
|
||||
count = count + 1
|
||||
with catch_warnings(record=True):
|
||||
if abs(d) > maximum_item:
|
||||
maximum_item = int(abs(d))
|
||||
else:
|
||||
max_array.append(maximum_item)
|
||||
|
||||
if maximum_item > highest_line:
|
||||
highest_line = maximum_item
|
||||
|
||||
maximum_item = 0
|
||||
count = 1
|
||||
|
||||
line_ratio = max(highest_line / bar_height, 1)
|
||||
|
||||
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
|
||||
draw = ImageDraw.Draw(im)
|
||||
|
||||
current_x = bar_margin
|
||||
for item in max_array:
|
||||
item_height = item / line_ratio
|
||||
|
||||
# If small minimums are not allowed, raise all values
|
||||
# smaller than the line width to the same value.
|
||||
if not allow_small_min:
|
||||
item_height = max(item_height, line_width)
|
||||
|
||||
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
|
||||
|
||||
draw.rounded_rectangle(
|
||||
(
|
||||
current_x,
|
||||
current_y,
|
||||
(current_x + line_width),
|
||||
(current_y + item_height),
|
||||
),
|
||||
radius=100 * base_scale,
|
||||
fill=("#FF0000"),
|
||||
outline=("#FFFF00"),
|
||||
width=max(math.ceil(line_width / 6), base_scale),
|
||||
)
|
||||
|
||||
current_x = current_x + line_width + bar_margin
|
||||
|
||||
im.resize((size, size), Image.Resampling.BILINEAR)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,41 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from tagstudio.previews.vendored.blender_thumbnailer import blend_thumb
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def blender_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
bg_color: str = (
|
||||
"#1e1e1e"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#FFFFFF"
|
||||
)
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
if (blend_image := blend_thumb(str(filepath))) is not None:
|
||||
bg = Image.new("RGB", blend_image.size, color=bg_color)
|
||||
bg.paste(blend_image, mask=blend_image.getchannel(3))
|
||||
im = bg
|
||||
else:
|
||||
logger.info(
|
||||
f"[ThumbRenderer][BLENDER][INFO] {filepath.name} "
|
||||
"Doesn't have an embedded thumbnail."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,41 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import sqlite3
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the .clip file.
|
||||
|
||||
Returns:
|
||||
Image: The embedded thumbnail, if extractable.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
blob = f.read()
|
||||
sqlite_index = blob.find(b"SQLite format 3")
|
||||
if sqlite_index == -1:
|
||||
return im
|
||||
|
||||
with sqlite3.connect(":memory:") as conn:
|
||||
conn.deserialize(blob[sqlite_index:])
|
||||
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
|
||||
if thumbnail:
|
||||
im = Image.open(BytesIO(thumbnail[0]))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,73 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.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:
|
||||
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the ePub file.
|
||||
ext (str): The file extension.
|
||||
|
||||
Returns:
|
||||
Image: The cover specified in ComicInfo.xml,
|
||||
the first image found in the ePub file, or None by default.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open_archive(filepath, ext) as archive:
|
||||
if "ComicInfo.xml" in archive.namelist():
|
||||
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
|
||||
im = _cover_from_comic_info(archive, comic_info, "FrontCover")
|
||||
if not im:
|
||||
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
|
||||
|
||||
if not im:
|
||||
im = first_image_in_archive(archive)
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
|
||||
|
||||
def _cover_from_comic_info(
|
||||
archive: Archive, comic_info: Element, cover_type: str
|
||||
) -> Image.Image | None:
|
||||
"""Extract the cover specified in ComicInfo.xml.
|
||||
|
||||
Args:
|
||||
archive (Archive): The current ePub file.
|
||||
comic_info (Element): The parsed ComicInfo.xml.
|
||||
cover_type (str): The type of cover to load.
|
||||
|
||||
Returns:
|
||||
Image: The cover specified in ComicInfo.xml.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
|
||||
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
|
||||
if cover is not None:
|
||||
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType]
|
||||
page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType]
|
||||
ext = Path(page_name).suffix
|
||||
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
|
||||
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
|
||||
im = image_from_bytes(BytesIO(image_data))
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,108 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
|
||||
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
|
||||
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
|
||||
"""Render a small font preview ("Aa") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
bg = Image.new("RGB", (size, size), color="#000000")
|
||||
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
|
||||
draw = ImageDraw.Draw(raw)
|
||||
font = ImageFont.truetype(filepath, size=size)
|
||||
# NOTE: While a stroke effect is desired, the text
|
||||
# method only allows for outer strokes, which looks
|
||||
# a bit weird when rendering fonts.
|
||||
draw.text(
|
||||
(size // 8, size // 8),
|
||||
"Aa",
|
||||
font=font,
|
||||
fill="#FF0000",
|
||||
# stroke_width=math.ceil(size / 96),
|
||||
# stroke_fill="#FFFF00",
|
||||
)
|
||||
# NOTE: Change to getchannel(1) if using an outline.
|
||||
data = np.asarray(raw.getchannel(0))
|
||||
|
||||
m, n = data.shape[:2]
|
||||
col: np.ndarray = cast(np.ndarray, data.any(0))
|
||||
row: np.ndarray = cast(np.ndarray, data.any(1))
|
||||
cropped_data = np.asarray(raw)[
|
||||
row.argmax() : m - row[::-1].argmax(),
|
||||
col.argmax() : n - col[::-1].argmax(),
|
||||
]
|
||||
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
|
||||
|
||||
margin: int = math.ceil(size // 16)
|
||||
|
||||
orig_x, orig_y = cropped_im.size
|
||||
new_x, new_y = (size, size)
|
||||
if orig_x > orig_y:
|
||||
new_x = size
|
||||
new_y = math.ceil(size * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = size
|
||||
new_x = math.ceil(size * (orig_x / orig_y))
|
||||
|
||||
cropped_im = cropped_im.resize(
|
||||
size=(new_x - (margin * 2), new_y - (margin * 2)),
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
)
|
||||
bg.paste(
|
||||
cropped_im,
|
||||
box=(margin, margin + ((size - new_y) // 2)),
|
||||
)
|
||||
im = bg
|
||||
except OSError as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
|
||||
def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
|
||||
"""Render a large font preview ("Alphabet") thumbnail from a font file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
"""
|
||||
# Scale the sample font sizes to the preview image
|
||||
# resolution,assuming the sizes are tuned for 256px.
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
|
||||
bg = Image.new("RGBA", (size, size), color="#00000000")
|
||||
draw = ImageDraw.Draw(bg)
|
||||
lines_of_padding = 2
|
||||
y_offset = 0.0
|
||||
|
||||
for font_size in scaled_sizes:
|
||||
font = ImageFont.truetype(filepath, size=font_size)
|
||||
text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw)
|
||||
draw.multiline_text((0, y_offset), text_wrapped, font=font)
|
||||
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
|
||||
(0, 0), "A", font=font
|
||||
)[-1]
|
||||
im = auto_theme_overlay(bg, use_alpha=False)
|
||||
except OSError as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,58 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import os
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract the thumbnail from a .mdp file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the .mdp file.
|
||||
|
||||
Returns:
|
||||
Image: The embedded thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
magic = struct.unpack("<7sx", f.read(8))[0]
|
||||
if magic != b"mdipack":
|
||||
return im
|
||||
|
||||
bin_header = struct.unpack("<LLL", f.read(12))
|
||||
xml_header = ET.fromstring(f.read(bin_header[1]))
|
||||
mdibin_count = len(xml_header.findall("./*Layer")) + 1
|
||||
for _ in range(mdibin_count):
|
||||
pac_header = struct.unpack("<3sxLLLL48s64s", f.read(132))
|
||||
if not pac_header[6].startswith(b"thumb"):
|
||||
f.seek(pac_header[3], os.SEEK_CUR)
|
||||
continue
|
||||
|
||||
thumb_element = unwrap(xml_header.find("Thumb"))
|
||||
dimensions = (
|
||||
int(unwrap(thumb_element.get("width"))),
|
||||
int(unwrap(thumb_element.get("height"))),
|
||||
)
|
||||
thumb_blob = f.read(pac_header[3])
|
||||
if pac_header[2] == 1:
|
||||
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
|
||||
|
||||
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import base64
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def paint_dot_net_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract the base64-encoded thumbnail from a .pdn file header.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the .pdn file.
|
||||
|
||||
Returns:
|
||||
Image: the decoded PNG thumbnail or None by default.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
with open(filepath, "rb") as f:
|
||||
try:
|
||||
# First 4 bytes are the magic number
|
||||
if f.read(4) != b"PDN3":
|
||||
return im
|
||||
|
||||
# Header length is a little-endian 24-bit int
|
||||
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
|
||||
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
|
||||
if thumb_element is None:
|
||||
return im
|
||||
|
||||
encoded_png = thumb_element.get("png")
|
||||
if encoded_png:
|
||||
decoded_png = base64.b64decode(encoded_png)
|
||||
im = Image.open(BytesIO(decoded_png))
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
|
||||
return im
|
||||
@@ -0,0 +1,66 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
|
||||
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
|
||||
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import Image
|
||||
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
|
||||
|
||||
from tagstudio.qt.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:
|
||||
"""Render a thumbnail for a PDF or Adobe Illustrator file.
|
||||
|
||||
filepath (Path): The path of the file.
|
||||
size (int): The size of the icon.
|
||||
ext (str): The file extension.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
|
||||
file: QFile = QFile(filepath)
|
||||
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
|
||||
if not success:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath)
|
||||
return im
|
||||
document: QPdfDocument = QPdfDocument()
|
||||
document.load(file)
|
||||
file.close()
|
||||
# Transform page_size in points to pixels with proper aspect ratio
|
||||
page_size: QSizeF = document.pagePointSize(0)
|
||||
ratio_hw: float = page_size.height() / page_size.width()
|
||||
if ratio_hw >= 1:
|
||||
page_size *= size / page_size.height()
|
||||
else:
|
||||
page_size *= size / page_size.width()
|
||||
# Enlarge image for anti-aliasing
|
||||
scale_factor = 2.5 if ext in {".pdf"} else 1
|
||||
page_size *= scale_factor
|
||||
# Render image with no anti-aliasing for speed
|
||||
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
|
||||
render_options.setRenderFlags(
|
||||
QPdfDocumentRenderOptions.RenderFlag.TextAliased
|
||||
| QPdfDocumentRenderOptions.RenderFlag.ImageAliased
|
||||
| QPdfDocumentRenderOptions.RenderFlag.PathAliased
|
||||
)
|
||||
# Convert QImage to PIL Image
|
||||
q_image: QImage = document.render(0, page_size.toSize(), render_options)
|
||||
buffer: QBuffer = QBuffer()
|
||||
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
|
||||
try:
|
||||
q_image.save(buffer, "PNG") # pyright: ignore
|
||||
im = Image.open(BytesIO(buffer.buffer().data()))
|
||||
finally:
|
||||
buffer.close()
|
||||
# Replace transparent pixels with white (otherwise Background defaults to transparent)
|
||||
return replace_transparent_pixels(im)
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
|
||||
from rawpy import (
|
||||
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
|
||||
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
try:
|
||||
import pillow_jxl # noqa: F401 # pyright: ignore
|
||||
except ImportError as e:
|
||||
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
|
||||
|
||||
register_heif_opener()
|
||||
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
|
||||
|
||||
|
||||
def raster_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a standard image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with filepath.open("rb") as file:
|
||||
im = image_from_bytes(BytesIO(file.read()))
|
||||
except (
|
||||
FileNotFoundError,
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
NotImplementedError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
|
||||
def exr_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a EXR image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
# Load the EXR data to an array and rotate the color space from BGRA -> RGBA
|
||||
raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED)
|
||||
assert raw_array is not None
|
||||
raw_array[..., :3] = raw_array[..., 2::-1]
|
||||
|
||||
# Correct the gamma of the raw array
|
||||
gamma = 2.2
|
||||
array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma)
|
||||
array = (array_gamma * 255).astype(np.uint8)
|
||||
|
||||
im = Image.fromarray(array, mode="RGBA")
|
||||
|
||||
# Paste solid background
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
|
||||
def raw_image_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a RAW image type.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess(use_camera_wb=True)
|
||||
im = Image.frombytes(
|
||||
"RGB",
|
||||
(rgb.shape[1], rgb.shape[0]),
|
||||
rgb,
|
||||
decoder_name="raw",
|
||||
)
|
||||
except (
|
||||
DecompressionBombError,
|
||||
LibRawIOError,
|
||||
LibRawFileUnsupportedError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
|
||||
|
||||
def image_from_bytes(image_data: BytesIO) -> Image.Image:
|
||||
"""Load a raster image and add a background if it's transparent.
|
||||
|
||||
Args:
|
||||
image_data (BytesIO): The binary image data.
|
||||
|
||||
Returns:
|
||||
Image.Image: The loaded raster image, with a background if needed.
|
||||
"""
|
||||
im: Image.Image = Image.open(image_data)
|
||||
if im.mode != "RGB" and im.mode != "RGBA":
|
||||
im = im.convert(mode="RGBA")
|
||||
if im.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
|
||||
new_bg.paste(im, mask=im.getchannel(3))
|
||||
im = new_bg
|
||||
return unwrap(ImageOps.exif_transpose(im))
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import srctools
|
||||
import structlog
|
||||
from PIL import Image
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def vtf_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
|
||||
|
||||
Uses the srctools library for reading VTF files.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
vtf = srctools.VTF.read(f)
|
||||
im = vtf.get(frame=0).to_PIL()
|
||||
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from tagstudio.core.utils.encoding import detect_char_encoding
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def text_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a plaintext file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
|
||||
bg_color: str = (
|
||||
"#1e1e1e"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#FFFFFF"
|
||||
)
|
||||
fg_color: str = (
|
||||
"#FFFFFF"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#111111"
|
||||
)
|
||||
|
||||
try:
|
||||
encoding = detect_char_encoding(filepath)
|
||||
with open(filepath, encoding=encoding) as text_file:
|
||||
text = text_file.read(256)
|
||||
bg = Image.new("RGB", (256, 256), color=bg_color)
|
||||
draw = ImageDraw.Draw(bg)
|
||||
draw.text((16, 16), text, fill=fg_color)
|
||||
im = bg
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
UnicodeDecodeError,
|
||||
OSError,
|
||||
FileNotFoundError,
|
||||
) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
|
||||
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from PIL import (
|
||||
Image,
|
||||
UnidentifiedImageError,
|
||||
)
|
||||
from PySide6.QtCore import QBuffer, Qt
|
||||
from PySide6.QtGui import QImage, QPainter
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
|
||||
"""Render a thumbnail for a vector image, such as SVG.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
size (tuple[int,int]): The size of the thumbnail.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
# Create an image to draw the svg to and a painter to do the drawing
|
||||
q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32)
|
||||
q_image.fill("#1e1e1e")
|
||||
|
||||
# Create an svg renderer, then render to the painter
|
||||
svg: QSvgRenderer = QSvgRenderer(str(filepath))
|
||||
|
||||
if not svg.isValid():
|
||||
raise UnidentifiedImageError
|
||||
|
||||
painter: QPainter = QPainter(q_image)
|
||||
svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
|
||||
svg.render(painter)
|
||||
painter.end()
|
||||
|
||||
# Write the image to a buffer as png
|
||||
buffer: QBuffer = QBuffer()
|
||||
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
|
||||
q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType]
|
||||
|
||||
# Load the image from the buffer
|
||||
im = Image.new("RGB", (size, size), color="#1e1e1e")
|
||||
im.paste(Image.open(BytesIO(buffer.data().data())))
|
||||
im = im.convert(mode="RGB")
|
||||
|
||||
buffer.close()
|
||||
return im
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import structlog
|
||||
from cv2.typing import MatLike
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
|
||||
from tagstudio.previews.video_tester import is_readable_video
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def video_thumb(filepath: Path) -> Image.Image | None:
|
||||
"""Render a thumbnail for a video file.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path of the file.
|
||||
"""
|
||||
im: Image.Image | None = None
|
||||
frame: MatLike | None = None
|
||||
try:
|
||||
if is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
# TODO: Move this check to is_readable_video()
|
||||
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
|
||||
raise cv2.error("File is invalid or has 0 frames")
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
max_frame_seek: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
if frame is not None:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
im = Image.fromarray(frame)
|
||||
except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e:
|
||||
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
|
||||
return im
|
||||
@@ -1,33 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: (c) 2017 Blender Foundation
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
# <pep8 compliant>
|
||||
|
||||
|
||||
## This file is a modified script that gets the thumbnail data stored in a blend file
|
||||
|
||||
"""Extract an embedded thumbnail from a Blender file."""
|
||||
|
||||
import gzip
|
||||
import os
|
||||
import struct
|
||||
from io import BufferedReader
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def blend_extract_thumb(path) -> tuple[bytes | None, int, int]:
|
||||
def blend_extract_thumb(path: Path | str) -> tuple[bytes | None, int, int]:
|
||||
rend = b"REND"
|
||||
test = b"TEST"
|
||||
|
||||
blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115
|
||||
blendfile: BufferedReader | gzip.GzipFile = open(path, "rb")
|
||||
|
||||
head = blendfile.read(12)
|
||||
|
||||
if head[0:2] == b"\x1f\x8b": # gzip magic
|
||||
blendfile.close()
|
||||
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115
|
||||
blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb"))
|
||||
head = blendfile.read(12)
|
||||
|
||||
if not head.startswith(b"BLENDER"):
|
||||
@@ -82,7 +78,7 @@ def blend_extract_thumb(path) -> tuple[bytes | None, int, int]:
|
||||
return image_buffer, x, y
|
||||
|
||||
|
||||
def blend_thumb(file_in) -> Image.Image | None:
|
||||
def blend_thumb(file_in: Path | str) -> Image.Image | None:
|
||||
buf, width, height = blend_extract_thumb(file_in)
|
||||
if buf is None:
|
||||
return None
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro)
|
||||
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from pydub
|
||||
@@ -43,7 +43,7 @@ from pydub.utils import (
|
||||
)
|
||||
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen
|
||||
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
|
||||
from tagstudio.previews.vendored.pydub.utils import _mediainfo_json
|
||||
|
||||
basestring = str
|
||||
xrange = range
|
||||
@@ -256,7 +256,7 @@ class _AudioSegment:
|
||||
self.sample_width = 4
|
||||
self.frame_width = self.channels * self.sample_width
|
||||
|
||||
super(_AudioSegment, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def raw_data(self):
|
||||
@@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com
|
||||
# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# Vendored from pydub
|
||||
@@ -8,11 +8,7 @@ import json
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from pydub.utils import (
|
||||
_fd_or_path_or_tempfile,
|
||||
fsdecode,
|
||||
get_extra_info,
|
||||
)
|
||||
from pydub.utils import _fd_or_path_or_tempfile, fsdecode, get_extra_info
|
||||
|
||||
from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
|
||||
from tagstudio.core.utils.silent_subprocess import silent_popen
|
||||
@@ -1,12 +1,12 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from tagstudio.qt.previews.vendored.probe import probe
|
||||
from tagstudio.previews.vendored.probe import probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
@@ -23,11 +23,7 @@ def is_readable_video(filepath: Path | str):
|
||||
return False
|
||||
for stream in result["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
"drma",
|
||||
"drms",
|
||||
"drmi",
|
||||
]:
|
||||
if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]:
|
||||
return False
|
||||
except ffmpeg.Error:
|
||||
return False
|
||||
@@ -59,7 +59,7 @@ class TomlEnumEncoder(toml.TomlEncoder):
|
||||
# NOTE: pydantic also has a BaseSettings class (from pydantic-settings) that allows any settings
|
||||
# properties to be overwritten with environment variables. As TagStudio is not currently using
|
||||
# environment variables, this was not based on that, but that may be useful in the future.
|
||||
class GlobalSettings(BaseModel):
|
||||
class AppSettings(BaseModel):
|
||||
language: str = Field(default="en")
|
||||
open_last_loaded_on_startup: bool = Field(default=True)
|
||||
generate_thumbs: bool = Field(default=True)
|
||||
@@ -74,6 +74,9 @@ class GlobalSettings(BaseModel):
|
||||
infinite_scroll: bool = Field(default=True)
|
||||
show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT)
|
||||
tag_click_action: TagClickActionOption = Field(default=TagClickActionOption.DEFAULT)
|
||||
edit_tag_on_create: bool = Field(default=False)
|
||||
edit_field_on_add: bool = Field(default=True)
|
||||
keep_suggest_boxes_open: bool = Field(default=True)
|
||||
theme: Theme = Field(default=Theme.SYSTEM)
|
||||
splash: Splash = Field(default=Splash.DEFAULT)
|
||||
windows_start_command: bool = Field(default=False)
|
||||
@@ -85,17 +88,17 @@ class GlobalSettings(BaseModel):
|
||||
loaded_from: Path = Field(default=DEFAULT_GLOBAL_SETTINGS_PATH, exclude=True)
|
||||
|
||||
@staticmethod
|
||||
def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> "GlobalSettings":
|
||||
def read_settings(path: Path = DEFAULT_GLOBAL_SETTINGS_PATH) -> AppSettings:
|
||||
if path.exists():
|
||||
with open(path) as file:
|
||||
filecontents = file.read()
|
||||
if len(filecontents.strip()) != 0:
|
||||
logger.info("[Settings] Reading Global Settings File", path=path)
|
||||
settings_data = toml.loads(filecontents)
|
||||
settings = GlobalSettings(**settings_data, loaded_from=path)
|
||||
settings = AppSettings(**settings_data, loaded_from=path)
|
||||
return settings
|
||||
|
||||
return GlobalSettings(loaded_from=path)
|
||||
return AppSettings(loaded_from=path)
|
||||
|
||||
def save(self, path: Path | None = None) -> None:
|
||||
if path is None:
|
||||
@@ -12,7 +12,7 @@ import structlog
|
||||
from PIL import Image
|
||||
|
||||
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
|
||||
from tagstudio.qt.global_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
|
||||
from tagstudio.qt.app_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QPoint, Signal
|
||||
from PySide6.QtWidgets import QLineEdit, QMenu, QWidget
|
||||
|
||||
from tagstudio.qt.views.styles.stylesheets import (
|
||||
autofill_scroll_top_focus_style,
|
||||
autofill_scroll_top_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AutofillLineEdit(QLineEdit):
|
||||
return_pressed = Signal()
|
||||
shift_return_pressed = Signal()
|
||||
holding_shift = Signal(bool)
|
||||
index_updated = Signal(int)
|
||||
|
||||
def __init__(self, popup: QWidget) -> None:
|
||||
super().__init__()
|
||||
self._popup = popup
|
||||
|
||||
@override
|
||||
def focusOutEvent(self, arg__1: QtGui.QFocusEvent) -> None:
|
||||
self._popup.setStyleSheet(autofill_scroll_top_style("container"))
|
||||
return super().focusOutEvent(arg__1)
|
||||
|
||||
@override
|
||||
def focusInEvent(self, arg__1: QtGui.QFocusEvent) -> None:
|
||||
self._popup.setStyleSheet(autofill_scroll_top_focus_style("container"))
|
||||
return super().focusInEvent(arg__1)
|
||||
|
||||
@override
|
||||
def event(self, arg__1: QtCore.QEvent) -> bool:
|
||||
if arg__1.type() == QtCore.QEvent.Type.KeyPress:
|
||||
assert isinstance(arg__1, QtGui.QKeyEvent)
|
||||
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Tab:
|
||||
self.index_updated.emit(1)
|
||||
return True
|
||||
elif arg__1.key() == QtCore.Qt.Key.Key_Backtab:
|
||||
self.index_updated.emit(-1)
|
||||
return True
|
||||
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.holding_shift.emit(True) # noqa: FBT003
|
||||
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.setText("")
|
||||
self.clearFocus()
|
||||
elif (
|
||||
arg__1.key() == QtCore.Qt.Key.Key_Enter or arg__1.key() == QtCore.Qt.Key.Key_Return
|
||||
):
|
||||
if arg__1.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier:
|
||||
self.shift_return_pressed.emit()
|
||||
else:
|
||||
self.return_pressed.emit()
|
||||
|
||||
return super().event(arg__1)
|
||||
|
||||
@override
|
||||
def keyReleaseEvent(self, arg__1: QtGui.QKeyEvent) -> None:
|
||||
if arg__1.key() == QtCore.Qt.Key.Key_Shift:
|
||||
self.holding_shift.emit(False) # noqa: FBT003
|
||||
return super().keyReleaseEvent(arg__1)
|
||||
|
||||
def show_action_menu(self, pos: QPoint) -> None:
|
||||
"""Show a context menu of actions."""
|
||||
menu = QMenu(self)
|
||||
for action in self.actions():
|
||||
# Filter out icon action(s)
|
||||
if action.text():
|
||||
menu.addAction(action)
|
||||
menu.exec(self.mapToGlobal(pos))
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
@@ -18,7 +18,7 @@ class ClickableSlider(QSlider):
|
||||
based on click events.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args: Any, **kwargs: Any): # pyright: ignore[reportExplicitAny]
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@override
|
||||
@@ -9,13 +9,14 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
DatetimeFieldTemplate,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
|
||||
from tagstudio.qt.views.styles.stylesheets import line_edit_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class EditFieldTemplateModal(EditFieldTemplateModalView):
|
||||
field_type_map: dict[str, str] = {
|
||||
"TextFieldTemplate": Translations["field_type.text"],
|
||||
@@ -73,8 +74,8 @@ class EditFieldTemplateModal(EditFieldTemplateModalView):
|
||||
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_empty else "")
|
||||
|
||||
if self.panel_save_button is not None:
|
||||
self.panel_save_button.setDisabled(is_empty)
|
||||
if self.save_button is not None:
|
||||
self.save_button.setDisabled(is_empty)
|
||||
|
||||
def __on_type_changed(self, index: int):
|
||||
old_type = self.__field_type
|
||||
|
||||
@@ -11,6 +11,7 @@ from tagstudio.qt.views.edit_text_view import EditTextView
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class EditText(EditTextView):
|
||||
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
|
||||
super().__init__()
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.app_settings import AppSettings
|
||||
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
|
||||
from tagstudio.qt.controllers.field_template_widget import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.suggest_box import SuggestBox
|
||||
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldSuggestBox(SuggestBox[BaseFieldTemplate]):
|
||||
def __init__(self, library: Library, settings: AppSettings, placeholder_text: str = ""):
|
||||
super().__init__(library, settings, placeholder_text)
|
||||
|
||||
# Context Menu Actions
|
||||
edit_field_on_add_action = QAction(Translations["settings.edit_field_on_add"], self)
|
||||
edit_field_on_add_action.setCheckable(True)
|
||||
self.addAction(edit_field_on_add_action)
|
||||
self.layout().search_field.addAction(edit_field_on_add_action)
|
||||
edit_field_on_add_action.setChecked(self._settings.edit_field_on_add)
|
||||
edit_field_on_add_action.triggered.connect(
|
||||
lambda checked: self._toggle_edit_on_field_add(checked)
|
||||
)
|
||||
|
||||
def _toggle_edit_on_field_add(self, checked: bool) -> None:
|
||||
"""Toggle the setting for opening the edit window after adding a field."""
|
||||
self._settings.edit_field_on_add = checked
|
||||
self._settings.save()
|
||||
|
||||
@override
|
||||
def _on_item_create(self) -> None:
|
||||
"""Creates a new field template and adds it to the currently selected entries.
|
||||
|
||||
Optionally opens up an edit panel after creation and before adding to entries.
|
||||
Populates name field using current search query.
|
||||
"""
|
||||
# NOTE: Unlike tags, creating new field templates will ALWAYS spawn an edit window
|
||||
# since the user needs to decide what type of field it should be before it's created.
|
||||
query: str = self.layout().search_field.text()
|
||||
panel = EditFieldTemplateModal()
|
||||
modal = Modal(
|
||||
panel,
|
||||
Translations["field_template.new"],
|
||||
Translations["field_template.new"],
|
||||
is_savable=True,
|
||||
)
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self._create_item_from_modal(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: Modal = Modal(panel, item.name, Translations["field_template.edit"], is_savable=True)
|
||||
modal.saved.connect(lambda: self._edit_item(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
|
||||
self.item_chosen.emit(item)
|
||||
self.done.emit("*")
|
||||
|
||||
@override
|
||||
def _search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
|
||||
if query != "":
|
||||
return self._lib.search_field_templates(name=query, limit=0), []
|
||||
else:
|
||||
return ([], [])
|
||||
|
||||
@override
|
||||
def _update_hint_icon(self) -> None:
|
||||
if self._is_shift_held:
|
||||
self.set_hint_icon(self._rm.hint_field_create)
|
||||
elif self.layout().search_field.text() and len(self._search_results) > 0:
|
||||
self.set_hint_icon(self._rm.hint_field_add)
|
||||
elif self.layout().search_field.text():
|
||||
self.set_hint_icon(self._rm.hint_field_create)
|
||||
else:
|
||||
self.set_hint_icon(None)
|
||||
|
||||
@override
|
||||
def _set_item_widget(self, item: BaseFieldTemplate | None, index: int) -> None:
|
||||
"""Set the field template of a field template widget at a specific index."""
|
||||
underlined_widget: UnderlinedWidget = self._get_item_widget(index, self._lib)
|
||||
field_template_widget = underlined_widget.widget
|
||||
assert isinstance(field_template_widget, FieldTemplateWidget)
|
||||
field_template_widget.has_remove = False
|
||||
field_template_widget.set_field_template(item)
|
||||
underlined_widget.setHidden(item is None)
|
||||
|
||||
if item is None:
|
||||
return
|
||||
|
||||
# TODO: Add tabbing to different items, and use underline to indicate which will be added
|
||||
underlined_widget.toggle_underline(index != 0)
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
field_template_widget.on_edit.disconnect()
|
||||
field_template_widget.on_remove.disconnect()
|
||||
field_template_widget.on_click.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
field_template_widget.on_edit.connect(lambda item_=item: self._on_item_edit(item_))
|
||||
field_template_widget.on_click.connect(
|
||||
lambda checked=False, item_=item: self._on_item_chosen(item_)
|
||||
)
|
||||
|
||||
@override
|
||||
def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
self._lib.add_field_template(template)
|
||||
self._on_item_chosen(template)
|
||||
self._clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self._on_search_query_changed(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _edit_item(self, edit_item_panel: ModalContent) -> None:
|
||||
if not isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
return
|
||||
|
||||
self._lib.update_field_template(
|
||||
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
|
||||
)
|
||||
self._update_items(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def _get_item_widget(self, index: int, library: Library | None) -> UnderlinedWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
if self.layout().content_layout.count() <= index:
|
||||
while self.layout().content_layout.count() <= index:
|
||||
field_template_widget = FieldTemplateWidget()
|
||||
widget = UnderlinedWidget(field_template_widget)
|
||||
widget.setHidden(True)
|
||||
self.layout().content_layout.addWidget(widget)
|
||||
|
||||
item = unwrap(self.layout().content_layout.itemAt(index))
|
||||
widget_: QWidget = unwrap(item.widget())
|
||||
assert isinstance(widget_, UnderlinedWidget)
|
||||
return widget_
|
||||
@@ -7,57 +7,41 @@ from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.controllers.edit_field_template_modal import EditFieldTemplateModal
|
||||
from tagstudio.qt.controllers.field_template_widget_controller import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.field_template_search_panel_view import FieldTemplateSearchPanelView
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.controllers.field_template_widget import FieldTemplateWidget
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.controllers.search_panel import SearchPanel
|
||||
from tagstudio.qt.views.search_panel_view import SearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldTemplateSearchModal(PanelModal):
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
is_field_template_chooser: bool = True,
|
||||
has_save: bool = False,
|
||||
) -> None:
|
||||
self.search_panel: FieldTemplateSearchPanel = FieldTemplateSearchPanel(
|
||||
library,
|
||||
is_field_template_chooser,
|
||||
view=FieldTemplateSearchPanelView(is_field_template_chooser),
|
||||
)
|
||||
super().__init__(
|
||||
self.search_panel,
|
||||
Translations["field.add.plural"],
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
|
||||
class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
field_template_chosen = Signal(object)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
is_field_template_chooser: bool = True,
|
||||
view: FieldTemplateSearchPanelView | None = None,
|
||||
is_chooser: bool = True,
|
||||
view: SearchPanelView | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
view=view or FieldTemplateSearchPanelView(is_field_template_chooser),
|
||||
view=view
|
||||
or SearchPanelView(Translations["home.search_field_templates"], is_chooser=is_chooser),
|
||||
exclude=[],
|
||||
is_chooser=is_field_template_chooser,
|
||||
is_chooser=is_chooser,
|
||||
)
|
||||
self.__lib = library
|
||||
|
||||
self._unlimited_limit_item_label = Translations["field_template.all_field_templates"]
|
||||
self._create_and_add_button_label_key = "field_template.create_add"
|
||||
self._create_and_add_button_key = "field_template.create_add"
|
||||
|
||||
@override
|
||||
def _get_max_limit(self) -> int:
|
||||
@@ -76,7 +60,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal()
|
||||
modal: PanelModal = PanelModal(
|
||||
modal: Modal = Modal(
|
||||
panel,
|
||||
Translations["field_template.new"],
|
||||
Translations["field_template.new"],
|
||||
@@ -93,7 +77,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
def on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: PanelModal = PanelModal(
|
||||
modal: Modal = Modal(
|
||||
panel,
|
||||
item.name,
|
||||
Translations["field_template.edit"],
|
||||
@@ -105,7 +89,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
|
||||
@override
|
||||
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
|
||||
if self.is_chooser:
|
||||
if self._is_chooser:
|
||||
return
|
||||
|
||||
message_box = QMessageBox(
|
||||
@@ -141,7 +125,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
if item is None:
|
||||
return
|
||||
|
||||
field_template_widget.has_remove = not self.is_chooser
|
||||
field_template_widget.has_remove = not self._is_chooser
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
@@ -157,7 +141,7 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
)
|
||||
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
def create_item(self, edit_item_panel: ModalContent, choose_item: bool = False) -> None:
|
||||
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
@@ -171,11 +155,26 @@ class FieldTemplateSearchPanel(SearchPanel[BaseFieldTemplate]):
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
def edit_item(self, edit_item_panel: ModalContent) -> None:
|
||||
if not isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
return
|
||||
|
||||
self.__lib.update_field_template(
|
||||
edit_item_panel.old_field_type, edit_item_panel.build_field_template()
|
||||
)
|
||||
self.update_items(self.search_field.text())
|
||||
self.update_items(self.layout().search_field.text())
|
||||
|
||||
@override
|
||||
def get_item_widget(self, index: int, library: Library | None) -> FieldTemplateWidget:
|
||||
"""Gets the item widget at a specific index."""
|
||||
# Create any new item widgets needed up to the given index
|
||||
if self.layout().scroll_layout.count() <= index:
|
||||
while self.layout().scroll_layout.count() <= index:
|
||||
pad_field_template_widget = FieldTemplateWidget()
|
||||
pad_field_template_widget.setHidden(True)
|
||||
self.layout().scroll_layout.addWidget(pad_field_template_widget)
|
||||
|
||||
item = unwrap(self.layout().scroll_layout.itemAt(index))
|
||||
field_template_widget: QWidget = unwrap(item.widget())
|
||||
assert isinstance(field_template_widget, FieldTemplateWidget)
|
||||
return field_template_widget
|
||||
@@ -7,10 +7,11 @@ from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QAction, QEnterEvent
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.field_template_widget_view import FieldTemplateWidgetView
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class FieldTemplateWidget(FieldTemplateWidgetView):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
from PySide6.QtWidgets import QLabel, QWidget
|
||||
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.utils.file_opener import FileOpenerHelper
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FileOpenerLabel(QLabel):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Initialize the FileOpenerLabel.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
"""
|
||||
self.filepath: Path | None = None
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
def set_file_path(self, filepath: Path) -> None:
|
||||
"""Set the filepath to open.
|
||||
|
||||
Args:
|
||||
filepath (Path): The path to the file to open.
|
||||
"""
|
||||
self.filepath = filepath
|
||||
|
||||
@override
|
||||
def mousePressEvent(self, ev: QMouseEvent) -> None:
|
||||
"""Handle mouse press events.
|
||||
|
||||
On a left click, open the file in the default file explorer.
|
||||
On a right click, show a context menu.
|
||||
|
||||
Args:
|
||||
ev (QMouseEvent): The mouse press event.
|
||||
"""
|
||||
if ev.button() == Qt.MouseButton.LeftButton:
|
||||
opener = FileOpenerHelper(unwrap(self.filepath))
|
||||
opener.open_explorer()
|
||||
elif ev.button() == Qt.MouseButton.RightButton:
|
||||
# Show context menu
|
||||
pass
|
||||
else:
|
||||
super().mousePressEvent(ev)
|
||||
@@ -9,21 +9,22 @@ from PySide6 import QtGui
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.registries.ignored_registry import IgnoredRegistry
|
||||
from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.controllers.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.mixed.remove_ignored_modal import RemoveIgnoredModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.fix_ignored_modal_view import FixIgnoredEntriesModalView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
from tagstudio.qt.views.styles.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
from tagstudio.qt.qt_driver import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
def __init__(self, library: Library, driver: QtDriver):
|
||||
super().__init__(library, driver)
|
||||
self.tracker = IgnoredRegistry(self.lib)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtWidgets import QScrollArea
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class HorizontalScrollArea(QScrollArea):
|
||||
"""A QScrollArea that translates vertical scrolling to horizontal movement."""
|
||||
|
||||
@override
|
||||
def wheelEvent(self, arg__1: QtGui.QWheelEvent) -> None:
|
||||
angle_y = arg__1.angleDelta().y()
|
||||
pixel_y = arg__1.pixelDelta().y()
|
||||
if angle_y != 0 or pixel_y != 0:
|
||||
translated_event = QtGui.QWheelEvent(
|
||||
arg__1.position(),
|
||||
arg__1.globalPosition(),
|
||||
QtCore.QPoint(pixel_y * -1, 0),
|
||||
QtCore.QPoint(angle_y * -1, 0),
|
||||
arg__1.buttons(),
|
||||
arg__1.modifiers(),
|
||||
arg__1.phase(),
|
||||
arg__1.inverted(),
|
||||
)
|
||||
arg__1.accept()
|
||||
return super().wheelEvent(translated_event)
|
||||
else:
|
||||
return super().wheelEvent(arg__1)
|
||||
@@ -19,6 +19,7 @@ from tagstudio.qt.views.ignore_modal_view import IgnoreModalView
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class IgnoreModal(IgnoreModalView):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QShortcut
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseField,
|
||||
BaseFieldTemplate,
|
||||
DatetimeField,
|
||||
DatetimeFieldTemplate,
|
||||
TextField,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.i18n.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.controllers.edit_text import EditText
|
||||
from tagstudio.qt.controllers.modal import Modal
|
||||
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
|
||||
from tagstudio.qt.mixed.field_containers import FieldContainers
|
||||
from tagstudio.qt.mixed.file_attributes import FileAttributeData
|
||||
from tagstudio.qt.views.inspector_view import InspectorView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.qt_driver import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class _ItemMode(IntEnum):
|
||||
TAG = 1
|
||||
FIELD = 2
|
||||
|
||||
|
||||
class Inspector(QWidget):
|
||||
def __init__(self, driver: QtDriver) -> None:
|
||||
super().__init__()
|
||||
self._driver = driver
|
||||
self._lib = self._driver.lib
|
||||
self._settings = self._driver.settings
|
||||
self._selected: list[int]
|
||||
self._current_stats: FileAttributeData | None = None
|
||||
|
||||
self._open_tag_search_action = QShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_T,
|
||||
),
|
||||
self,
|
||||
)
|
||||
self._open_field_search_action = QShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_L,
|
||||
),
|
||||
self,
|
||||
)
|
||||
|
||||
self.setLayout(InspectorView(driver=driver, pixel_ratio=self.devicePixelRatio()))
|
||||
self._set_item_mode(None)
|
||||
self._connect_callbacks()
|
||||
|
||||
def _connect_callbacks(self) -> None:
|
||||
# Tag Search
|
||||
self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG))
|
||||
self._open_tag_search_action.activated.connect(self._open_tag_search_callback)
|
||||
self.layout().tag_search_box.done.connect(partial(self._tag_added_callback))
|
||||
self.layout().containers.on_tags_update.connect(self._update_added_callback)
|
||||
|
||||
# Field Search
|
||||
self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD))
|
||||
self._open_field_search_action.activated.connect(self._open_field_search_callback)
|
||||
self.layout().field_search_box.done.connect(partial(self._field_added_callback))
|
||||
|
||||
# Previews
|
||||
self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback)
|
||||
self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
def _set_item_mode(self, mode: _ItemMode | None):
|
||||
def hide_and_disable_buttons():
|
||||
self.layout().add_tag_button.setHidden(True)
|
||||
self.layout().add_tag_button.setEnabled(False)
|
||||
self.layout().add_field_button.setHidden(True)
|
||||
self.layout().add_field_button.setEnabled(False)
|
||||
|
||||
def restore_buttons():
|
||||
self.layout().add_tag_button.setHidden(False)
|
||||
self.layout().add_tag_button.setEnabled(True)
|
||||
self.layout().add_field_button.setHidden(False)
|
||||
self.layout().add_field_button.setEnabled(True)
|
||||
|
||||
if mode == _ItemMode.TAG:
|
||||
self.layout().tag_search_box.added = self.layout().containers.tags
|
||||
self.layout().field_search_box.hide_and_reset()
|
||||
self.layout().tag_search_box.setHidden(False)
|
||||
hide_and_disable_buttons()
|
||||
elif mode == _ItemMode.FIELD:
|
||||
self.layout().tag_search_box.hide_and_reset()
|
||||
self.layout().field_search_box.setHidden(False)
|
||||
hide_and_disable_buttons()
|
||||
else:
|
||||
self.layout().tag_search_box.hide_and_reset()
|
||||
self.layout().field_search_box.hide_and_reset()
|
||||
restore_buttons()
|
||||
|
||||
def _open_tag_search_callback(self) -> None:
|
||||
self.layout().add_tag_button.setFocus()
|
||||
self.layout().add_tag_button.click()
|
||||
|
||||
def _open_field_search_callback(self) -> None:
|
||||
self.layout().add_field_button.setFocus()
|
||||
self.layout().add_field_button.click()
|
||||
|
||||
def _tag_added_callback(self, query: str):
|
||||
if not query or not self._settings.keep_suggest_boxes_open:
|
||||
self._set_item_mode(None)
|
||||
self.layout().add_tag_button.setFocus()
|
||||
else:
|
||||
self._update_added_callback()
|
||||
self.layout().tag_search_box.layout().search_field.setFocus()
|
||||
|
||||
def _field_added_callback(self, query: str):
|
||||
if not query or not self._settings.keep_suggest_boxes_open:
|
||||
self._set_item_mode(None)
|
||||
self.layout().add_field_button.setFocus()
|
||||
else:
|
||||
self.layout().field_search_box.layout().search_field.setFocus()
|
||||
|
||||
def _update_added_callback(self):
|
||||
self.layout().tag_search_box.added = self.layout().containers.tags
|
||||
|
||||
def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
|
||||
if len(self._selected) != 1:
|
||||
return
|
||||
|
||||
if filepath != self.layout().preview_thumb.current_file:
|
||||
return
|
||||
|
||||
if self._current_stats is None:
|
||||
self._current_stats = FileAttributeData()
|
||||
|
||||
if stats.width is not None:
|
||||
self._current_stats.width = stats.width
|
||||
if stats.height is not None:
|
||||
self._current_stats.height = stats.height
|
||||
if stats.duration is not None:
|
||||
self._current_stats.duration = stats.duration
|
||||
|
||||
self.layout().file_attrs.update_stats(filepath, self._current_stats)
|
||||
|
||||
def _set_selection_callback(self) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.layout().field_search_box.item_chosen.disconnect()
|
||||
self.layout().tag_search_box.item_chosen.disconnect()
|
||||
|
||||
self.layout().field_search_box.item_chosen.connect(self._add_field_to_selected)
|
||||
self.layout().tag_search_box.item_chosen.connect(self._add_tag_to_selected)
|
||||
|
||||
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
|
||||
self.layout().containers.add_field_to_selected(template)
|
||||
# TODO: Allow editing of fields across multiple entries at once.
|
||||
if len(self._selected) == 1:
|
||||
if self._driver.settings.edit_field_on_add:
|
||||
entry = unwrap(self._lib.get_entry_full(self._selected[0]))
|
||||
entry_field = None
|
||||
if isinstance(template, TextFieldTemplate):
|
||||
entry_field = entry.text_fields[-1]
|
||||
elif isinstance(template, DatetimeFieldTemplate):
|
||||
entry_field = entry.datetime_fields[-1]
|
||||
if entry_field is not None:
|
||||
self._edit_field(entry.id, entry_field)
|
||||
|
||||
self.layout().containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _edit_field(self, entry_id: int, field: BaseField) -> None:
|
||||
# TODO: A lot of this code is similar to or straight up shared with FieldContainers.
|
||||
# It's possible to reuse it later, after a FieldContainers refactor.
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
|
||||
if type(field) is TextField:
|
||||
edit_modal = Modal(
|
||||
EditText(field.name, field.value, field.is_multiline),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(self.layout().containers.update_text_field_callback, field, entry_id)
|
||||
)
|
||||
edit_modal.show()
|
||||
elif type(field) is DatetimeField:
|
||||
edit_modal = Modal(
|
||||
DatetimePicker(self._driver, field.name, field.value or dt.now()),
|
||||
window_title=f"{Translations['field.edit']} ({Translations[field_name_key]})",
|
||||
is_savable=True,
|
||||
inline_title=False,
|
||||
)
|
||||
edit_modal.saved_data.connect(
|
||||
partial(self.layout().containers.update_datetime_field_callback, field, entry_id)
|
||||
)
|
||||
edit_modal.show()
|
||||
|
||||
def _add_tag_to_selected(self, tag_id: int) -> None:
|
||||
self.layout().containers.add_tags_to_selected(tag_id)
|
||||
if len(self._selected) == 1:
|
||||
self.layout().containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
|
||||
self.layout().warning_banner.show()
|
||||
return
|
||||
|
||||
self.layout().warning_banner.hide()
|
||||
|
||||
def set_selection(self, selected: list[int], update_preview: bool = True) -> None:
|
||||
"""Render the panel widgets with the newest data from the Library.
|
||||
|
||||
Args:
|
||||
selected (list[int]): List of the IDs of the selected entries.
|
||||
update_preview (bool): Should the file preview be updated?
|
||||
(Only works with one or more items selected)
|
||||
"""
|
||||
self._selected = selected
|
||||
self._set_item_mode(None)
|
||||
try:
|
||||
# No Items Selected
|
||||
if len(selected) == 0:
|
||||
self.layout().preview_thumb.hide_preview()
|
||||
self._current_stats = None
|
||||
self.layout().file_attrs.update_stats()
|
||||
self.layout().file_attrs.update_date_label()
|
||||
self.layout().containers.hide_containers()
|
||||
self.layout().add_tag_button.setEnabled(False)
|
||||
self.layout().add_field_button.setEnabled(False)
|
||||
|
||||
# One Item Selected
|
||||
elif len(selected) == 1:
|
||||
entry_id = selected[0]
|
||||
entry: Entry = unwrap(self._lib.get_entry(entry_id))
|
||||
|
||||
filepath: Path = unwrap(self._lib.library_dir) / entry.path
|
||||
if filepath != self.layout().preview_thumb.current_file:
|
||||
self._current_stats = None
|
||||
|
||||
if update_preview:
|
||||
stats: FileAttributeData = self.layout().preview_thumb.display_file(filepath)
|
||||
self._current_stats = stats
|
||||
self.layout().file_attrs.update_stats(filepath, stats)
|
||||
self.layout().file_attrs.update_date_label(filepath)
|
||||
self.layout().containers.update_from_entry(entry_id)
|
||||
self._set_selection_callback()
|
||||
|
||||
# Multiple Selected Items
|
||||
elif len(selected) > 1:
|
||||
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
|
||||
self.layout().preview_thumb.hide_preview() # TODO: Render mixed selection
|
||||
self._current_stats = None
|
||||
self.layout().file_attrs.update_multi_selection(len(selected))
|
||||
self.layout().file_attrs.update_date_label()
|
||||
self.layout().containers.hide_containers() # TODO: Allow for mixed editing
|
||||
self._set_selection_callback()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[Preview Panel] Error updating selection", error=e)
|
||||
|
||||
def stop_media_playback(self) -> None:
|
||||
"""Stop any media playback in the inspector."""
|
||||
self.layout().preview_thumb.media_player.stop()
|
||||
|
||||
@property
|
||||
def containers(self) -> FieldContainers:
|
||||
return self.layout().containers
|
||||
|
||||
@override
|
||||
def layout(self) -> InspectorView:
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
@@ -19,20 +19,21 @@ from tagstudio.core.library.alchemy.constants import (
|
||||
)
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.utils import file_opener
|
||||
from tagstudio.qt.views.library_info_window_view import LibraryInfoWindowView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
from tagstudio.qt.views.styles.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
from tagstudio.qt.qt_driver import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: Use newer MVC style guidelines
|
||||
class LibraryInfoWindow(LibraryInfoWindowView):
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
def __init__(self, library: Library, driver: QtDriver):
|
||||
super().__init__(library, driver)
|
||||
|
||||
# Statistics Buttons
|
||||
@@ -1,14 +1,16 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
# TODO: Split view and logic, split classes into individual files
|
||||
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6 import QtCore
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import QMetaObject, QSize, QStringListModel, Qt
|
||||
from PySide6.QtGui import QAction, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -36,20 +38,20 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.enums import SortingModeEnum
|
||||
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.i18n.platform_strings import trash_term
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.controllers.inspector import Inspector
|
||||
from tagstudio.qt.helpers.mnemonics import assign_mnemonics
|
||||
from tagstudio.qt.mixed.landing import LandingWidget
|
||||
from tagstudio.qt.mixed.pagination import Pagination
|
||||
from tagstudio.qt.mnemonics import assign_mnemonics
|
||||
from tagstudio.qt.platform_strings import trash_term
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import checkbox_style
|
||||
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
|
||||
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.views.styles.stylesheets import checkbox_style
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
from tagstudio.qt.qt_driver import QtDriver
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -182,10 +184,10 @@ class MainMenuBar(QMenuBar):
|
||||
self.new_tag_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_T,
|
||||
QtCore.Qt.Key.Key_N,
|
||||
)
|
||||
)
|
||||
self.new_tag_action.setToolTip("Ctrl+T")
|
||||
self.new_tag_action.setToolTip("Ctrl+N")
|
||||
self.new_tag_action.setEnabled(False)
|
||||
self.edit_menu.addAction(self.new_tag_action)
|
||||
|
||||
@@ -220,9 +222,8 @@ class MainMenuBar(QMenuBar):
|
||||
|
||||
# Clear Selection
|
||||
self.clear_select_action = QAction(Translations["select.clear"], self)
|
||||
self.clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.clear_select_action.setEnabled(False)
|
||||
self.clear_select_action.setToolTip("Esc")
|
||||
self.edit_menu.addAction(self.clear_select_action)
|
||||
|
||||
# Copy Fields
|
||||
@@ -457,7 +458,7 @@ class MainWindow(QMainWindow):
|
||||
(Translations["home.thumbnail_size.mini"], 76),
|
||||
]
|
||||
|
||||
def __init__(self, driver: "QtDriver", parent: QWidget | None = None) -> None:
|
||||
def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.rm = ResourceManager()
|
||||
|
||||
@@ -491,7 +492,7 @@ class MainWindow(QMainWindow):
|
||||
self.pagination: Pagination
|
||||
|
||||
# initialized in setup_preview_panel
|
||||
self.preview_panel: PreviewPanel
|
||||
self.preview_panel: Inspector
|
||||
# endregion
|
||||
|
||||
if not self.objectName():
|
||||
@@ -528,7 +529,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# endregion
|
||||
|
||||
def setup_central_widget(self, driver: "QtDriver"):
|
||||
def setup_central_widget(self, driver: QtDriver):
|
||||
self.central_widget = QWidget(self)
|
||||
self.central_widget.setObjectName("central_widget")
|
||||
self.central_layout = QGridLayout(self.central_widget)
|
||||
@@ -650,7 +651,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.central_layout.addLayout(self.extra_input_layout, 5, 0, 1, 1)
|
||||
|
||||
def setup_content(self, driver: "QtDriver"):
|
||||
def setup_content(self, driver: QtDriver):
|
||||
self.content_layout = QHBoxLayout()
|
||||
self.content_layout.setObjectName("content_layout")
|
||||
|
||||
@@ -666,7 +667,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.central_layout.addLayout(self.content_layout, 10, 0, 1, 1)
|
||||
|
||||
def setup_entry_list(self, driver: "QtDriver"):
|
||||
def setup_entry_list(self, driver: QtDriver):
|
||||
self.entry_list_container = QWidget()
|
||||
self.entry_list_layout = QVBoxLayout(self.entry_list_container)
|
||||
self.entry_list_layout.setSpacing(0)
|
||||
@@ -699,11 +700,13 @@ class MainWindow(QMainWindow):
|
||||
self.entry_list_layout.addWidget(self.pagination)
|
||||
self.content_splitter.addWidget(self.entry_list_container)
|
||||
|
||||
def setup_preview_panel(self, driver: "QtDriver"):
|
||||
self.preview_panel = PreviewPanel(driver.lib, driver)
|
||||
def setup_preview_panel(self, driver: QtDriver):
|
||||
self.preview_panel = Inspector(driver)
|
||||
self.content_splitter.addWidget(self.preview_panel)
|
||||
|
||||
def setup_status_bar(self):
|
||||
# BUG: Clicking the status bar does not count as losing focus on other widgets
|
||||
# (for example, the "Add Tag" line edit). Can this be fixed?
|
||||
self.status_bar = QStatusBar(self)
|
||||
self.status_bar.setObjectName("status_bar")
|
||||
status_bar_size_policy = QSizePolicy(
|
||||
@@ -746,3 +749,9 @@ class MainWindow(QMainWindow):
|
||||
def show_hidden_entries(self) -> bool:
|
||||
"""Whether to show entries tagged with hidden tags."""
|
||||
return self.show_hidden_entries_checkbox.isChecked()
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
self.menu_bar.clear_select_action.trigger()
|
||||
return super().keyPressEvent(event)
|
||||
@@ -8,18 +8,17 @@ from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
|
||||
from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.i18n.translations import Translations
|
||||
from tagstudio.qt.controllers.progress_bar import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
from tagstudio.qt.qt_driver import QtDriver
|
||||
|
||||
|
||||
class MergeDuplicateEntries(QObject):
|
||||
class MergeDuplicateEntriesProgress(QObject):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
def __init__(self, library: Library, driver: QtDriver):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtGui
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from tagstudio.qt.controllers.modal_content import ModalContent
|
||||
from tagstudio.qt.views.modal_view import ModalView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Modal(QWidget):
|
||||
"""A generic modal window widget with common signals and styling."""
|
||||
|
||||
done = Signal()
|
||||
saved = Signal()
|
||||
saved_data = Signal(type(Any))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content_widget: ModalContent,
|
||||
title: str = "",
|
||||
window_title: str | None = None,
|
||||
is_savable: bool = False,
|
||||
inline_title: bool = True,
|
||||
):
|
||||
super().__init__()
|
||||
self.setWindowTitle(title if window_title is None else window_title)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setLayout(
|
||||
ModalView(
|
||||
content_widget=content_widget,
|
||||
title=title,
|
||||
is_savable=is_savable,
|
||||
inline_title=inline_title,
|
||||
)
|
||||
)
|
||||
|
||||
# [Done]
|
||||
# - OR -
|
||||
# [Cancel] [Save]
|
||||
if not is_savable:
|
||||
done_button = self.layout().content_widget.done_button
|
||||
if done_button:
|
||||
done_button.clicked.connect(self.hide)
|
||||
done_button.clicked.connect(self.done.emit)
|
||||
else:
|
||||
cancel_button = self.layout().content_widget.cancel_button
|
||||
if cancel_button:
|
||||
cancel_button.clicked.connect(self.hide)
|
||||
cancel_button.clicked.connect(content_widget.reset)
|
||||
|
||||
save_button = self.layout().content_widget.save_button
|
||||
if save_button:
|
||||
save_button.clicked.connect(self.hide)
|
||||
save_button.clicked.connect(self.saved.emit)
|
||||
save_button.clicked.connect(
|
||||
lambda: self.saved_data.emit(content_widget.saved_data())
|
||||
)
|
||||
|
||||
content_widget.parent_post_init()
|
||||
|
||||
@override
|
||||
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
|
||||
with contextlib.suppress(AttributeError):
|
||||
cancel_button = self.layout().content_widget.cancel_button
|
||||
if cancel_button:
|
||||
cancel_button.click()
|
||||
with contextlib.suppress(AttributeError):
|
||||
done_button = self.layout().content_widget.done_button
|
||||
if done_button:
|
||||
done_button.click()
|
||||
event.accept()
|
||||
|
||||
@override
|
||||
def layout(self) -> ModalView:
|
||||
"""Return the typed layout for this widget."""
|
||||
return super().layout() # pyright: ignore[reportReturnType]
|
||||
@@ -0,0 +1,47 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QPushButton, QWidget
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ModalContent(QWidget):
|
||||
"""Base class for widgets that go inside a Modal widget."""
|
||||
|
||||
save_button: QPushButton | None = None
|
||||
cancel_button: QPushButton | None = None
|
||||
done_button: QPushButton | None = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def saved_data(self) -> Any: # pyright: ignore[reportExplicitAny]
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
def parent_post_init(self) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
if self.cancel_button:
|
||||
self.cancel_button.click()
|
||||
elif self.done_button:
|
||||
self.done_button.click()
|
||||
elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||
if self.save_button:
|
||||
self.save_button.click()
|
||||
elif self.done_button:
|
||||
self.done_button.click()
|
||||
else: # Other key presses
|
||||
super().keyPressEvent(event)
|
||||
@@ -1,65 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import typing
|
||||
from warnings import catch_warnings
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class PreviewPanel(PreviewPanelView):
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(
|
||||
self.lib, title=Translations["tag.add.plural"], is_tag_chooser=True
|
||||
)
|
||||
self.__add_tag_modal.tsp.set_driver(driver)
|
||||
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
@typing.override
|
||||
def _add_field_button_callback(self) -> None:
|
||||
self.__add_field_modal.show()
|
||||
|
||||
@typing.override
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
self.__add_tag_modal.show()
|
||||
|
||||
@typing.override
|
||||
def _set_selection_callback(self) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
|
||||
self.__add_tag_modal.tsp.item_chosen.disconnect()
|
||||
|
||||
self.__add_field_modal.search_panel.field_template_chosen.connect(
|
||||
self._add_field_to_selected
|
||||
)
|
||||
self.__add_tag_modal.tsp.item_chosen.connect(self._add_tag_to_selected)
|
||||
|
||||
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
|
||||
self._containers.add_field_to_selected(template)
|
||||
if len(self._selected) == 1:
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _add_tag_to_selected(self, tag_id: int) -> None:
|
||||
self._containers.add_tags_to_selected(tag_id)
|
||||
if len(self._selected) == 1:
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()):
|
||||
self._ffmpeg_warning_widget.show()
|
||||
return
|
||||
|
||||
self._ffmpeg_warning_widget.hide()
|
||||