Compare commits
50 Commits
0719e1e886
..
v9.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e93e953d9 | |||
| 008cef363d | |||
| 727f441c55 | |||
| e327dc7ee1 | |||
| 8eded29f45 | |||
| a01b5c423b | |||
| a865573c4b | |||
| 6005129003 | |||
| f44662296f | |||
| 0fa15ce18e | |||
| 41024260de | |||
| fca21bf5fa | |||
| 6b15beefbd | |||
| 4919c972da | |||
| e509e247d5 | |||
| 4da6037cbd | |||
| 0d87307f38 | |||
| 1b0bbba080 | |||
| 0f319985c4 | |||
| a3477722b3 | |||
| 7a0a1f6047 | |||
| d640859856 | |||
| cf1006ee51 | |||
| 7f15990084 | |||
| d9c56d534e | |||
| 85676c0836 | |||
| 71a01c7156 | |||
| e499a3e68b | |||
| 4321bbfdb3 | |||
| 185691bcd2 | |||
| ae787eaf5e | |||
| 8b6f649c54 | |||
| 204d4be303 | |||
| e5fa85448a | |||
| 6f3c66efd9 | |||
| 2c85c082b7 | |||
| 6502c755c7 | |||
| 268efc952f | |||
| 0dd0616281 | |||
| 0380f1b0ca | |||
| e1a02ca843 | |||
| c7eb22b253 | |||
| 49fdfc8248 | |||
| 5e1c1f2014 | |||
| 38da7bb3a9 | |||
| ab3394ad40 | |||
| 41f9749814 | |||
| 2ab8203967 | |||
| 3846b65758 | |||
| a7b6636a0b |
@@ -1,5 +1,2 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
---
|
||||
patreon: cyanvoxel
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: Bug Report
|
||||
description: File a bug or issue report.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: Feature Request
|
||||
description: Suggest a new feature.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
### Summary
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
---
|
||||
name: MyPy
|
||||
name: Pyright
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
mypy:
|
||||
name: Run MyPy
|
||||
pyright:
|
||||
name: Run Pyright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup reviewdog
|
||||
uses: reviewdog/action-setup@v1
|
||||
with:
|
||||
reviewdog_version: latest
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -27,10 +22,10 @@ jobs:
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade uv
|
||||
uv pip install --system .[mypy]
|
||||
uv pip install --system .[pyright,pytest]
|
||||
|
||||
- name: Execute MyPy
|
||||
uses: tsuyoshicho/action-mypy@v4
|
||||
- name: Execute Pyright
|
||||
uses: jordemort/action-pyright@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fail_on_error: true
|
||||
reporter: ${{ github.event_name == 'pull_request' && 'github-pr-review' || 'github-check' }}
|
||||
@@ -52,12 +52,6 @@ jobs:
|
||||
run: |
|
||||
xvfb-run pytest --cov-report xml --cov=tagstudio
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage.xml
|
||||
|
||||
pytest-windows:
|
||||
name: Run pytest (Windows)
|
||||
runs-on: windows-2025
|
||||
@@ -74,24 +68,3 @@ jobs:
|
||||
- name: Execute pytest
|
||||
run: |
|
||||
pytest
|
||||
|
||||
coverage:
|
||||
name: Check coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: pytest-linux
|
||||
|
||||
steps:
|
||||
- name: Fetch coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
|
||||
- name: Check coverage
|
||||
uses: yedpodtrzitko/coverage@main
|
||||
with:
|
||||
thresholdAll: 0.4
|
||||
thresholdNew: 0.4
|
||||
thresholdModified: 0.4
|
||||
coverageFile: coverage.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
sourceDir: tagstudio/src
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Execute Ruff format
|
||||
uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
version: 0.11.0
|
||||
version: 0.15.17
|
||||
args: format --check
|
||||
|
||||
ruff-check:
|
||||
@@ -29,5 +29,5 @@ jobs:
|
||||
- name: Execute Ruff check
|
||||
uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
version: 0.11.8
|
||||
version: 0.15.17
|
||||
args: check
|
||||
|
||||
@@ -7,10 +7,10 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
# C Extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
# Distribution / Packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
@@ -41,7 +41,7 @@ MANIFEST
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
# Unit test / Coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
@@ -88,6 +88,8 @@ profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
@@ -95,14 +97,21 @@ ipython_config.py
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
poetry.lock
|
||||
poetry.toml
|
||||
|
||||
# uv
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
uv.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
@@ -111,6 +120,8 @@ ipython_config.py
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
@@ -138,8 +149,24 @@ venv.bak/
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# macoS
|
||||
*.DS_Store
|
||||
# macOS
|
||||
.DocumentRevisions-V100
|
||||
.DS_Store
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
|
||||
# Linux
|
||||
.directory
|
||||
.fuse_hidden*
|
||||
.nfs*
|
||||
.Trash-*
|
||||
|
||||
# Windows
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: mypy
|
||||
entry: mypy
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[](https://github.com/TagStudioDev/TagStudio/actions/workflows/ruff.yaml)
|
||||
|
||||
<p align="center">
|
||||
<img width="60%" src="docs/assets/ts-9-3_logo_text.png">
|
||||
<img width="60%" src="src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png">
|
||||
</p>
|
||||
|
||||
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. **Read the documentation and more at [docs.tagstud.io](https://docs.tagstud.io)!**
|
||||
@@ -172,7 +172,6 @@ See the [**Roadmap**](docs/roadmap.md) on the documentation site for a complete
|
||||
- To provide powerful methods for organization, notably the concept of tag inheritance, or "taggable tags" _(and in the near future, the combination of composition-based tags)._
|
||||
- To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or requiring them to change their existing file structures and workflows.
|
||||
- To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries.
|
||||
- To make the dang thing look nice, too. It’s 2025, not 1995.
|
||||
|
||||
### Project Priorities
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ path = [
|
||||
|
||||
".git-blame-ignore-revs",
|
||||
".gitattributes",
|
||||
".github/FUNDING.yml",
|
||||
".github/ISSUE_TEMPLATE/**",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
".gitignore",
|
||||
".pre-commit-config.yaml",
|
||||
"flake.lock",
|
||||
@@ -33,17 +36,18 @@ SPDX-License-Identifier = "GPL-3.0-or-later"
|
||||
|
||||
[[annotations]]
|
||||
path = [
|
||||
"src/tagstudio/resources/qt/images/bxs-clipboard-regular.png",
|
||||
"src/tagstudio/resources/qt/images/bxs-left-arrow.png",
|
||||
"src/tagstudio/resources/qt/images/bxs-pencil-solid.png",
|
||||
"src/tagstudio/resources/qt/images/bxs-right-arrow.png",
|
||||
"src/tagstudio/resources/qt/images/bxs-trash-solid.png",
|
||||
"src/tagstudio/resources/qt/images/bxs-volume-full-solid.png",
|
||||
"src/tagstudio/resources/qt/images/file_icons/database.png",
|
||||
]
|
||||
SPDX-FileCopyrightText = "(c) 2026 Boxicons"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
[[annotations]]
|
||||
path = [
|
||||
"src/tagstudio/resources/qt/images/volume.svg",
|
||||
"src/tagstudio/resources/qt/images/volume_mute.svg",
|
||||
]
|
||||
path = ["src/tagstudio/resources/qt/images/dupe_file_stat.png"]
|
||||
SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors"
|
||||
SPDX-License-Identifier = "Apache-2.0"
|
||||
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
../../src/tagstudio/resources/icon.ico
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 677 KiB |
@@ -1,10 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 739 739" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
|
||||
<path d="M535.939,863.161C515.931,843.153 203.505,529.713 183.497,509.705C169.086,495.294 161.469,476.645 160.649,457.754C160.046,443.845 138.078,230.102 137.923,217.139C137.681,196.785 145.323,176.356 160.839,160.839C177.115,144.564 198.795,136.951 220.125,138.016C232.439,138.63 447.036,159.52 461.817,160.931C479.3,162.6 496.329,170.12 509.705,183.497C523.113,196.904 849.753,522.531 863.161,535.939C893.716,566.494 893.716,616.108 863.161,646.663L646.663,863.161C616.108,893.716 566.494,893.716 535.939,863.161ZM321.355,223.613C296.045,198.303 254.947,198.303 229.636,223.613C204.326,248.924 204.326,290.022 229.636,315.332C254.947,340.643 296.045,340.643 321.355,315.332C346.666,290.022 346.666,248.924 321.355,223.613ZM362.109,606.786C409.476,654.152 424.103,598.401 454.027,606.786C468.584,610.865 453.72,642.505 443.028,673.551C425.086,725.641 484.094,757.817 516.601,720.743C545.603,687.667 503.579,655.692 520.581,632.527C537.795,609.074 563.542,633.319 565.542,665.527C568.921,719.955 535.825,735.585 543.999,774.591C553.59,820.348 624.181,827.565 638,774.591C647.736,737.269 603.102,705.31 628.352,644.476C636.209,625.545 662.786,619.154 669.759,644.476C673.976,659.791 660.264,670.152 666.759,693.55C674.41,721.114 725.088,732.96 740.374,693.55C746.873,676.793 734.853,651.273 731.597,640.406C714.283,582.611 826.807,582.426 762.374,517.789C703.034,458.263 493.6,249.017 493.6,249.017C479.164,234.58 457.464,234.58 443.028,249.017L249.017,443.028C234.58,457.464 234.58,479.164 249.017,493.6C249.017,493.6 340.149,584.825 362.109,606.786Z" style="fill:white;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.986683,0,0,0.986683,-136.081,-136.081)">
|
||||
<path d="M733.962,560.164C740.987,553.139 740.987,541.733 733.962,534.708L482.173,282.92C475.148,275.895 463.742,275.895 456.717,282.92L431.261,308.376C424.237,315.4 424.237,326.807 431.261,333.831L683.05,585.62C690.075,592.645 701.481,592.645 708.506,585.62L733.962,560.164ZM439.639,559.207C446.664,552.182 446.664,540.776 439.639,533.751L335.491,429.602C328.466,422.578 317.059,422.578 310.035,429.602L284.579,455.058C277.554,462.083 277.554,473.489 284.579,480.514L388.728,584.663C395.752,591.688 407.159,591.688 414.184,584.663L439.639,559.207ZM584.306,556.624C591.331,549.599 591.331,538.192 584.306,531.168L409.115,355.977C402.091,348.953 390.684,348.953 383.66,355.977L358.204,381.433C351.179,388.458 351.179,399.864 358.204,406.889L533.394,582.079C540.419,589.104 551.825,589.104 558.85,582.079L584.306,556.624ZM298.425,246.543C311.081,259.198 311.081,279.747 298.425,292.402C285.77,305.058 265.221,305.058 252.566,292.402C239.911,279.747 239.911,259.198 252.566,246.543C265.221,233.888 285.77,233.888 298.425,246.543Z" style="fill:white;"/>
|
||||
<svg width="100%" height="100%" viewBox="0 0 944 944" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.707107,0.707107,-0.707107,0.707107,48.0404,-676.037)">
|
||||
<path d="M754.971,240.435C773.719,224.654 797.438,216 821.944,216L1440,216C1497.44,216 1544,262.562 1544,320L1544,704C1544,761.438 1497.44,808 1440,808L821.944,808C797.438,808 773.719,799.346 754.971,783.565L526.872,591.565C503.396,571.805 489.845,542.685 489.845,512C489.845,481.315 503.396,452.195 526.872,432.435L754.971,240.435ZM1059.36,727.98C1142.98,727.98 1106.68,665.856 1140.5,646.842C1156.95,637.593 1171.76,678.644 1189.72,715.486C1219.87,777.305 1300.35,753.619 1296.32,692.2C1292.73,637.402 1227.41,646.272 1221.97,610.817C1216.46,574.918 1260.59,573.593 1290.78,600.258C1341.81,645.319 1326.39,688.332 1368.04,715.546C1416.89,747.471 1485.58,691.53 1451.02,632.571C1426.66,591.033 1359.05,602.221 1327.64,526.233C1317.87,502.586 1335.69,473.485 1364.19,489.683C1381.44,499.479 1378.48,520.729 1404.87,535.649C1435.95,553.226 1491.14,518.948 1469.85,470.669C1460.79,450.139 1427.65,438.223 1415.19,431.504C1348.89,395.77 1448.05,296.282 1334.12,296.102C1229.19,295.936 859.62,296.102 859.62,296.102C834.132,296.102 814.978,315.256 814.978,340.744L814.978,683.256C814.978,708.744 834.132,727.898 859.62,727.898C859.62,727.898 1020.59,727.98 1059.36,727.98ZM1085.8,618L1085.8,662C1085.8,674.142 1075.94,684 1063.8,684L878,684C865.858,684 856,674.142 856,662L856,618C856,605.858 865.858,596 878,596L1063.8,596C1075.94,596 1085.8,605.858 1085.8,618ZM1346.44,362L1346.44,406C1346.44,418.142 1336.59,428 1324.44,428L878,428C865.858,428 856,418.142 856,406L856,362C856,349.858 865.858,340 878,340L1324.44,340C1336.59,340 1346.44,349.858 1346.44,362ZM1216,490L1216,534C1216,546.142 1206.14,556 1194,556L878,556C865.858,556 856,546.142 856,534L856,490C856,477.858 865.858,468 878,468L1194,468C1206.14,468 1216,477.858 1216,490ZM752.58,455.42C721.352,424.193 670.648,424.193 639.42,455.42C608.193,486.648 608.193,537.352 639.42,568.58C670.648,599.807 721.352,599.807 752.58,568.58C783.807,537.352 783.807,486.648 752.58,455.42ZM724.29,483.71C739.903,499.324 739.903,524.676 724.29,540.29C708.676,555.903 683.324,555.903 667.71,540.29C652.097,524.676 652.097,499.324 667.71,483.71C683.324,468.097 708.676,468.097 724.29,483.71Z" style="fill:white;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1 @@
|
||||
../../src/tagstudio/resources/qt/images/tagstudio_logo-text_color.png
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 234 KiB |
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Colors
|
||||
icon: material/palette
|
||||
---
|
||||
|
||||
|
||||
@@ -1,164 +1,150 @@
|
||||
---
|
||||
title: Contributing
|
||||
icon: material/file-plus
|
||||
---
|
||||
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
# :material-file-plus: Contributing
|
||||
# :material-file-plus: Contribution Guidelines
|
||||
|
||||
Thank you so much for showing interest in contributing to TagStudio! Here are a set of instructions and guidelines for contributing code or documentation to the project. This document will change over time, so make sure that your contributions still line up with the requirements here before submitting a pull request.
|
||||
Thank you so much for showing interest in contributing to TagStudio! This page goes over the instructions and guidelines for contributing to the TagStudio. This page will change over time, so make sure that your contributions still line up with the current guidelines before submitting a pull request.
|
||||
|
||||
## Getting Started
|
||||
If you wish to discuss TagStudio further, feel free to join the [Discord Server](https://discord.com/invite/hRNnVKhF2G)!
|
||||
|
||||
- Check the [Feature Roadmap](roadmap.md) page to see what priority features there are, the [FAQ](https://github.com/TagStudioDev/TagStudio/blob/main/README.md#faq), as well as the project's [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls).
|
||||
- If you'd like to add a feature that isn't on the feature roadmap or doesn't have an open issue, **PLEASE create a feature request** issue for it discussing your intentions so any feedback or important information can be given by the team first.
|
||||
- We don't want you wasting time developing a feature or making a change that can't/won't be added for any reason ranging from pre-existing refactors to design philosophy differences.
|
||||
- **Please don't** create pull requests that consist of large refactors, _especially_ without discussing them with us first. These end up doing more harm than good for the project by continuously delaying progress and disrupting everyone else's work.
|
||||
- If you wish to discuss TagStudio further, feel free to join the [Discord Server](https://discord.com/invite/hRNnVKhF2G)!
|
||||
## :material-order-bool-ascending-variant: Contribution Checklist
|
||||
|
||||
#### All Contributions
|
||||
|
||||
- [x] I've read the Contribution Guidelines (this page) and the [Style Guide](style.md)
|
||||
- [x] I've read the [FAQ](https://github.com/TagStudioDev/TagStudio/blob/main/README.md#faq)
|
||||
- [x] I've checked the project's [pull requests](https://github.com/TagStudioDev/TagStudio/pulls) for any existing or conflicting PRs
|
||||
- [x] I've set up my [development environment](developing.md) including [Ruff](developing.md#ruff), [Pyright](developing.md#pyright), and [Pytest](developing.md#pytest)
|
||||
|
||||
#### Feature Additions
|
||||
|
||||
- [x] I've read the [Roadmap](roadmap.md) and understand what core features are planned, their priorities, and their scheduled timelines
|
||||
- [x] I've found an existing [feature request](https://github.com/TagStudioDev/TagStudio/issues) or created my own **_before starting work on a feature_** so that the feature can be discussed beforehand
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
If the fix is small and self-explanatory (i.e. a typo), then it doesn't require an issue to be opened first. Issue tracking is supposed to make our lives easier, not harder. Please use your best judgement to minimize the amount of work for everyone involved.
|
||||
!!! danger "Before Developing Features"
|
||||
**PLEASE** open a [feature request](https://github.com/TagStudioDev/TagStudio/issues) or ensure that one already exists **_before you begin work on the feature_**. This allows us to discuss the feature idea beforehand, approve or reject it, and give any specific implementation requirements. We **do not want** to have to close pull requests because they add features that conflict with the project's goals, guidelines, or other planned features.
|
||||
|
||||
### Contribution Checklist
|
||||
#### Fixes
|
||||
|
||||
- I've read the [Feature Roadmap](roadmap.md) page
|
||||
- I've read the [FAQ](https://github.com/TagStudioDev/TagStudio/blob/main/README.md#faq)
|
||||
- I've checked the project's [Issues](https://github.com/TagStudioDev/TagStudio/issues) and [Pull Requests](https://github.com/TagStudioDev/TagStudio/pulls)
|
||||
- **I've created a new issue for my feature/fix _before_ starting work on it**, or have at least notified others in the relevant existing issue(s) of my intention to work on it
|
||||
- I've set up my development environment including Ruff, Mypy, and PyTest
|
||||
- I've read the CONTRIBUTING.md/Contributing page on the documentation site as well as the and/or [Style Guide](style.md)
|
||||
- **_I mean it, I've found or created an issue for my feature/fix!_**
|
||||
- [x] I've found an existing [bug report](https://github.com/TagStudioDev/TagStudio/issues) or created my own for this fix _(as long as the fix is substantial enough to warrant opening a bug report for)_
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! failure "Unacceptable Code"
|
||||
The following types of code will NOT be accepted to the project:
|
||||
!!! note "Issue Exceptions for Small Fixes"
|
||||
If the fix is small and self-explanatory (e.g. a typo), then it doesn't require an issue to be opened first. Issue tracking is supposed to make our lives easier, not harder. Please use your best judgement to minimize the amount of work for everyone involved.
|
||||
|
||||
- Code that is not yours or does not have a compatible license with TagStudio's [own one](../LICENSE)
|
||||
- Code that you do not understand and/or cannot explain
|
||||
## :material-thumb-down:{.red} Unacceptable Code
|
||||
|
||||
## Creating a Development Environment
|
||||
The following types of code will **NOT** be accepted to the project:
|
||||
|
||||
If you wish to develop for TagStudio, you'll need to create a development environment by installing the required dependencies. You have a number of options depending on your level of experience and familiarly with existing Python toolchains.
|
||||
- Code that does not follow the [Contribution Checklist](#contribution-checklist) or violates the Contribution Guidelines
|
||||
- Large refactors that have not been discussed with us first
|
||||
- These types of refactors end up doing more harm than good for the project by continuously delaying progress and disrupting everyone else's work
|
||||
- Other people/projects' code that is used without consent or does not have a [compatible license](#licenses)
|
||||
- Code that you do not understand and/or cannot explain (i.e. "vibe coding")
|
||||
- Code that adds a drastic amount of complexity with minimal utility
|
||||
|
||||
If you know what you're doing and have developed for Python projects in the past, you can get started quickly with the "Brief Instructions" below. Otherwise, please see the full instructions on the documentation website for "[Creating a Development Environment](https://docs.tagstud.io/install/#creating-a-development-environment)".
|
||||
---
|
||||
|
||||
### Brief Instructions
|
||||
## :material-license: Licenses
|
||||
|
||||
1. Have [Python 3.12](https://www.python.org/downloads/) and PIP installed. Also have [FFmpeg](https://ffmpeg.org/download.html) installed if you wish to have audio/video playback and thumbnails.
|
||||
2. Clone the repository to the folder of your choosing:
|
||||
```
|
||||
git clone https://github.com/TagStudioDev/TagStudio.git
|
||||
```
|
||||
3. Use a dependency manager such as [uv](https://docs.astral.sh/uv/) or [Poetry 2.0](https://python-poetry.org/blog/category/releases/) to install the required dependencies, or alternatively create and activate a [virtual environment](https://docs.tagstud.io/install/#manual-installation) with `venv`.
|
||||
|
||||
4. If using a virtual environment instead of a dependency manager, install an editable version of the program and development dependencies with the following PIP command:
|
||||
|
||||
```
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Otherwise, modify the command above for use with your dependency manager of choice. For example if using uv, you may use this:
|
||||
|
||||
```
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Workflow Checks
|
||||
|
||||
When pushing your code, several automated 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.
|
||||
As of May 2026, the TagStudio project has begun migrating to different licenses for different sections of the codebase where possible. Any new code contributed inside the "core" of TagStudio must be under the [MIT license](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSES/MIT.txt), while any code specific to the Qt frontend is to remain under [GPL-3.0](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSES/GPL-3.0-only.txt) where possible.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
To format the code automatically before each commit, there's a configured action available for the `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`.
|
||||
!!! question "Relicensing Process"
|
||||
Existing GPL-3.0 core code is **only** migrated to MIT if all of the original contributors have given their consent, or if the code becomes replaced by a significantly different implementation.
|
||||
|
||||
### [Ruff](https://github.com/astral-sh/ruff)
|
||||
Licensing is now accomplished using the [REUSE](https://reuse.software/spec-3.3/) specification. This means that each file is licensed separately, with text files having a comment header with the license and copyright and other files having this information declared in the [RESUSE.toml](https://github.com/TagStudioDev/TagStudio/blob/main/REUSE.toml) file.
|
||||
|
||||
A Python linter and code formatter. Ruff uses the `pyproject.toml` as its config file and runs whenever code is pushed or pulled into the project.
|
||||
<!-- prettier-ignore-start -->
|
||||
=== "Python GPL-3.0 Comment"
|
||||
```py
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
```
|
||||
=== "Python MIT Comment"
|
||||
```py
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
```
|
||||
=== "Markdown/HTML Comment"
|
||||
```html
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
```
|
||||
=== "CSS Comment"
|
||||
```css
|
||||
/*
|
||||
* SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
```
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
#### Running Locally
|
||||
#### Types of Files That Should Be MIT
|
||||
|
||||
Inside the root repository directory:
|
||||
- Backend code that supports TagStudio's core systems, irrespective of the frontend used
|
||||
- e.g. The database backend for storing TagStudio data, the search query system, database tests, etc.
|
||||
- Frontend code for the CLI _(to be developed)_
|
||||
- Platform-agnostic thumbnail extraction, rendering, and caching
|
||||
- Translations added to an MIT-labeled [Weblate](https://hosted.weblate.org/projects/tagstudio/) component
|
||||
|
||||
- Lint code with `ruff check`
|
||||
- Some linting suggestions can be automatically formatted with `ruff check --fix`
|
||||
- Format code with `ruff format`
|
||||
#### Types of Files That Should Be GPL-3.0
|
||||
|
||||
Ruff should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file. For more information, see the [ruff configuration discovery docs](https://docs.astral.sh/ruff/configuration/#config-file-discovery).
|
||||
- Code for the Qt frontend
|
||||
- e.g. Qt widgets, views, controllers, Qt rendering code, Qt tests, etc.
|
||||
|
||||
Ruff is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/).
|
||||
---
|
||||
|
||||
### [Mypy](https://github.com/python/mypy)
|
||||
|
||||
Mypy is a static type checker for Python. It sure has a lot to say sometimes, but we recommend you take its advice when possible. Mypy also uses the `pyproject.toml` as its config file and runs whenever code is pushed or pulled into the project.
|
||||
|
||||
#### Running Locally
|
||||
|
||||
- **(First time only)** Run the following:
|
||||
- `mkdir -p .mypy_cache`
|
||||
- `mypy --install-types --non-interactive`
|
||||
- You can now check code by running `mypy --config-file pyproject.toml .` in the repository root. _(Don't forget the "." at the end!)_
|
||||
|
||||
Mypy is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=matangover.mypy), PyCharm [plugin](https://plugins.jetbrains.com/plugin/11086-mypy), and [more](https://plugins.jetbrains.com/plugin/11086-mypy).
|
||||
|
||||
### PyTest
|
||||
|
||||
- Run all tests by running `pytest tests/` in the repository root.
|
||||
|
||||
## Code Style
|
||||
|
||||
See the [Style Guide](style.md)
|
||||
|
||||
### Modules & Implementations
|
||||
|
||||
- **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
|
||||
|
||||
### Commit and Pull Request Style
|
||||
## :material-code-braces-box: Commits and Pull Requests
|
||||
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) as a guideline for commit messages. This allows us to easily generate changelogs for releases.
|
||||
- See some [examples](https://www.conventionalcommits.org/en/v1.0.0/#examples) of what this looks like in practice.
|
||||
- Use clear and concise commit messages. If your commit does too much, either consider breaking it up into smaller commits or providing extra detail in the commit description.
|
||||
- Pull requests should have an adequate title and description which clearly outline your intentions and changes/additions. Feel free to provide screenshots, GIFs, or videos, especially for UI changes.
|
||||
- Pull requests should ideally be limited to **a single** feature or fix.
|
||||
- Pull requests should ideally be limited to a **single** feature or fix.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! important
|
||||
!!! danger "Force Pushing"
|
||||
**Please do not force push if your PR is open for review!** Force pushing makes it impossible to discern which changes have already been reviewed and which haven't. This means a reviewer will then have to re-review all the already reviewed code, which is a lot of unnecessary work for reviewers.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
!!! 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
|
||||
|
||||
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:
|
||||
|
||||
- [Ruff](developing.md#ruff) [`check`](https://docs.astral.sh/ruff/linter) and [`format`](https://docs.astral.sh/ruff/formatter/) (read-only)
|
||||
- [Pyright](developing.md#pyright) type checking
|
||||
- [Pytest](developing.md#pytest) tests
|
||||
- REUSE [license compliance](#licenses)
|
||||
|
||||
### Runtime Requirements
|
||||
|
||||
- Final code must function on supported versions of Windows, macOS, and Linux:
|
||||
- Windows: 10, 11
|
||||
- macOS: 13.0+
|
||||
- Linux: _Varies_
|
||||
- Final 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
|
||||
Code must function on all of the supported operating systems and versions:
|
||||
|
||||
## Documentation Guidelines
|
||||
- Windows 10 & 11
|
||||
- macOS 14.0+
|
||||
- Common Linux distributions and versions
|
||||
|
||||
Documentation contributions include anything inside of the `docs/` folder, as well as the `README.md` and `CONTRIBUTING.md` files. Documentation inside the `docs/` folder is built and hosted on our static documentation site, [docs.tagstud.io](https://docs.tagstud.io/).
|
||||
## :material-file-document: Documentation Guidelines
|
||||
|
||||
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 all embedded media
|
||||
- Provide alt text for embedded media
|
||||
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
|
||||
|
||||
## Translation Guidelines
|
||||
## :material-translate: Translation Guidelines
|
||||
|
||||
Translations are performed on the TagStudio [Weblate project](https://hosted.weblate.org/projects/tagstudio/).
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Developing
|
||||
icon: material/code-braces
|
||||
---
|
||||
|
||||
@@ -11,7 +12,7 @@ If you wish to develop for TagStudio, you'll need to create a development enviro
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "Contributing"
|
||||
If you wish to contribute to TagStudio's development, please read our [CONTRIBUTING.md](https://github.com/TagStudioDev/TagStudio/blob/main/CONTRIBUTING.md)!
|
||||
If you wish to contribute to TagStudio's development, please read our [Contribution Guidelines](contributing.md)!
|
||||
|
||||
## Installing Python
|
||||
|
||||
@@ -49,7 +50,7 @@ git clone https://github.com/TagStudioDev/TagStudio.git
|
||||
|
||||
## Installing Dependencies
|
||||
|
||||
To install the required dependencies, you can use a dependency manager such as [uv](https://docs.astral.sh/uv) or [Poetry 2.0](https://python-poetry.org). Alternatively you can create a virtual environment and manually install the dependencies yourself.
|
||||
To install the required dependencies, you can use a dependency manager such as [uv](https://docs.astral.sh/uv) (recommended) or [Poetry 2.0](https://python-poetry.org). Alternatively you can create a virtual environment and manually install the dependencies yourself.
|
||||
|
||||
### Installing with uv
|
||||
|
||||
@@ -59,7 +60,7 @@ If using [uv](https://docs.astral.sh/uv), you can install the dependencies for T
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
A reference `.envrc` is provided for use with [direnv](#direnv), see [`contrib/.envrc-uv`](https://github.com/TagStudioDev/TagStudio/blob/main/contrib/.envrc-uv).
|
||||
TagStudio should now be runnable using the `tagstudio` command.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,6 +72,8 @@ If using [Poetry](https://python-poetry.org), you can install the dependencies f
|
||||
poetry install --with dev
|
||||
```
|
||||
|
||||
TagStudio should now be runnable using the `tagstudio` command.
|
||||
|
||||
---
|
||||
|
||||
### Manual Installation
|
||||
@@ -109,6 +112,12 @@ If you choose to manually set up a virtual environment and install dependencies
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
4. TagStudio should now be runnable using the `tagstudio` command.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! 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)
|
||||
|
||||
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:
|
||||
@@ -144,9 +153,54 @@ The entry point for TagStudio is `src/tagstudio/main.py`. You can target this fi
|
||||
}
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
To format the code automatically before each commit, there's a configured action available for the `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`.
|
||||
|
||||
### Ruff
|
||||
|
||||
[Ruff](https://github.com/astral-sh/ruff) is a Python linter and code formatter that helps enforce a consistent formatting style across our codebase.
|
||||
|
||||
Ruff is installed alongside the `pip install -e ".[dev]"` command, but is also available as a VS Code [extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff), PyCharm [plugin](https://plugins.jetbrains.com/plugin/20574-ruff), and [more](https://docs.astral.sh/ruff/integrations/).
|
||||
|
||||
```sh title="Lint Code"
|
||||
ruff check
|
||||
|
||||
# Apply automatic fixes with
|
||||
ruff check --fix
|
||||
```
|
||||
|
||||
```sh title="Format Code"
|
||||
ruff format
|
||||
```
|
||||
|
||||
Ruff should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file. For more information, see the [ruff configuration discovery docs](https://docs.astral.sh/ruff/configuration/#config-file-discovery).
|
||||
|
||||
### Pyright
|
||||
|
||||
[Pyright](https://github.com/microsoft/pyright) is a static type checker for Python that helps enforce type strictness and prevent easy-to-miss errors across our codebase.
|
||||
|
||||
Pyright is installed alongside the `pip install -e ".[dev]"` command, but is also available as VS Code extensions (see the [Pyright](https://marketplace.visualstudio.com/items?itemName=ms-pyright.pyright), [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), and [basedpyright](https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright) extensions), a PyCharm [setting](https://www.jetbrains.com/help/pycharm/lsp-tools.html#pyright), or in the form of forks such as [basedpyright](https://docs.basedpyright.com/latest/).
|
||||
|
||||
```sh title="Run Checks"
|
||||
pyright
|
||||
```
|
||||
|
||||
Pyright/basedpyright should automatically discover the configuration options inside the [pyproject.toml](https://github.com/TagStudioDev/TagStudio/blob/main/pyproject.toml) file.
|
||||
|
||||
### Pytest
|
||||
|
||||
[Pytest](https://github.com/pytest-dev/pytest) runs our Python code against the tests inside the [`tests/`](https://github.com/TagStudioDev/TagStudio/tree/main/tests) directory.
|
||||
|
||||
Pytest is installed alongside the `pip install -e ".[dev]"` command.
|
||||
|
||||
```sh title="Run Tests"
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### pre-commit
|
||||
|
||||
There is a [pre-commit](https://pre-commit.com/) configuration that will run through some checks before code is committed. Namely, mypy and the Ruff linter and formatter will check your code, catching those nits right away.
|
||||
There is a [pre-commit](https://pre-commit.com/) configuration that will run through some checks before code is committed. Namely Pyright and Ruff will check your code, catching those nits right away.
|
||||
|
||||
Once you have pre-commit installed, just run:
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Entries
|
||||
icon: material/file
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Fields
|
||||
icon: material/text-box
|
||||
---
|
||||
|
||||
@@ -7,24 +8,53 @@ icon: material/text-box
|
||||
|
||||
# :material-text-box: Fields
|
||||
|
||||
Fields are additional types of metadata that you can attach to [file entries](./entries.md). Like [tags](tags.md), fields are not stored inside files themselves nor in sidecar files, but rather inside the respective TagStudio [library](./index.md) save file.
|
||||
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.
|
||||
|
||||
## Field Types
|
||||
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**.
|
||||
|
||||
### Text Line
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>Example of tags and various fields on a file entry.</figcaption>
|
||||
</figure>
|
||||
|
||||
A string of text, displayed as a single line.
|
||||
## :material-text-box-plus-outline: Field Templates
|
||||
|
||||
- e.g: Title, Author, Artist, URL, etc.
|
||||
Field templates are handy templates to use when adding fields to entries that contain preconfigured options but no actual data. When you add a field to an entry from the "Add Field" button, you choose from a template to add and then fill in the information afterwards. TagStudio includes a handful of field templates to start you off with, but you're free to modify or delete them, or simply create your own.
|
||||
|
||||
### Text Box
|
||||
Field templates can be viewed, created, and deleted from the **Edit -> Manage Field Templates** window. You can also edit field templates from the "Add Field" menu, and create new ones on the fly from the search bar. Note that you can not currently delete field templates from the "Add Field" menu, just like tags.
|
||||
|
||||
A long string of text displayed as a box of text.
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>Field Template Manager from <b>Edit -> Manage Field Templates</b>.</figcaption>
|
||||
</figure>
|
||||
|
||||
- e.g: Description, Notes, etc.
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>The field template editor, shown creating a new "Citations" field.</figcaption>
|
||||
</figure>
|
||||
|
||||
### Datetime
|
||||
## :material-format-list-bulleted-type: Field Types
|
||||
|
||||
A date and time value.
|
||||
Fields come in a variety of types that are better suited for different types of information, and may provide additional options unique to those types. Single lines are good for fields like titles, while multiline blocks are good for things like comments and notes.
|
||||
|
||||
- e.g: Date Published, Date Taken, etc.
|
||||
### :material-text-box: Text
|
||||
|
||||
Text fields contain a piece of text with the option to display it either a single line or a multiline body of text.
|
||||
|
||||
| Option | Value | Description |
|
||||
| --------- | ---------- | ------------------------------------------------------------------------ |
|
||||
| Multiline | True/False | Indicates if the text should be displayed on multiple lines or just one. |
|
||||
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>The text field editor, editing a "Comments" field on an entry.</figcaption>
|
||||
</figure>
|
||||
|
||||
### :material-calendar-month: Datetime
|
||||
|
||||
Datetime fields contain a date and time value. Dates are formatted using the format specified in your application settings.
|
||||
|
||||
<figure markdown="span">
|
||||

|
||||
<figcaption>The datetime field editor, expanded to show the date picker.</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Installing FFmpeg
|
||||
icon: material/movie-open-cog
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ hide:
|
||||
<link rel="stylesheet" href="stylesheets/home.css">
|
||||
|
||||
<figure markdown="span">
|
||||
{ width=80% }<h2>A User-Focused Photo & File Management System</h2>
|
||||
{ width=80% }<h2>A User-Focused Photo & File Management System</h2>
|
||||
</figure>
|
||||
|
||||
<br>
|
||||
@@ -87,7 +87,7 @@ hide:
|
||||
|
||||
[:material-arrow-right: View License](https://github.com/TagStudioDev/TagStudio/blob/main/LICENSE)
|
||||
|
||||
[:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#core-library-api)
|
||||
[:material-arrow-right: Roadmap to MIT Core Library License](roadmap.md#core-library-cli)
|
||||
|
||||
- :material-database:{ .lg .middle } **Central Save File**
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Installation
|
||||
icon: material/download
|
||||
---
|
||||
|
||||
@@ -226,19 +227,36 @@ For audio/video thumbnails and playback you'll need [FFmpeg](https://ffmpeg.org/
|
||||
To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extractor capable of handling them.
|
||||
|
||||
- :material-penguin: On Linux you'll need to install either `unrar` (likely in you distro's non-free repository) or `unrar-free` from your package manager.
|
||||
|
||||
- :fontawesome-brands-apple: On macOS `unrar` can be installed through Homebrew's [`rar`](https://formulae.brew.sh/cask/rar) formula.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup"
|
||||
On macOS, you may be met with a message similar to "**"unrar" Not Opened. Apple could not verify "unrar" is free of malware that may harm your Mac or compromise your privacy**" If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says "**"unrar" was blocked from use because it is not from an identified developer.**" Click the "Open Anyway" button to allow unrar to be used.
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning ":fontawesome-brands-apple: macOS "Privacy & Security" Popup"
|
||||
On macOS, you may be met with a message similar to "**"unrar" Not Opened. Apple could not verify "unrar" is free of malware that may harm your Mac or compromise your privacy**" If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says "**"unrar" was blocked from use because it is not from an identified developer.**" Click the "Open Anyway" button to allow unrar to be used.
|
||||
|
||||
- :fontawesome-brands-windows: On Windows you'll need to install either [`WinRAR`](https://www.rarlab.com/download.htm) or [`7-zip`](https://www.7-zip.org/) and add their folder to you `PATH`.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "WinRAR License"
|
||||
Both `unrar` and `WinRAR` require a license, but since the evaluation copy has no time limit you can simply dismiss the prompt.
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip "WinRAR License"
|
||||
Both `unrar` and `WinRAR` require a license, but since the evaluation copy has no time limit you can simply dismiss the prompt.
|
||||
|
||||
### 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.
|
||||
|
||||
## Common Error Messages
|
||||
|
||||
### Could not load the Qt platform plugin "xcb"
|
||||
|
||||
If you get an error message like this one:
|
||||
|
||||
```
|
||||
qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin.
|
||||
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "/tmp/_MEIayuTiW/cv2/qt/plugins" even though it was found.
|
||||
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
|
||||
|
||||
Available platform plugins are: vnc, wayland-egl, offscreen, wayland, linuxfb, minimalegl, eglfs, minimal, vkkhrdisplay, xcb.
|
||||
|
||||
Aborted (core dumped)
|
||||
```
|
||||
|
||||
Make sure you installed `libxcb-cursor` or `xcb-util-cursor`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Libraries
|
||||
icon: material/database
|
||||
---
|
||||
|
||||
@@ -7,10 +8,79 @@ icon: material/database
|
||||
|
||||
# :material-database: Libraries
|
||||
|
||||
A TagStudio library represents a folder of content (photos, documents, or [any other files](preview-support.md)) and contains TagStudio data specific to that library ([tags](tags.md), [fields](fields.md), [colors](colors.md), etc.) along with the associations between that data and your files. A library folder can be stored locally on your computer, on an external drive, on a network drive/NAS, or most other locations accessible by your system. TagStudio passively and non-destructively includes contents of this folder (including subfolders) in your library as [file entries](entries.md).
|
||||
|
||||
**Your files are not _moved_, _copied_, or _modified_ in any way!**
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info
|
||||
This page is a work in progress and needs to be updated with additional information.
|
||||
!!! note "Planned Library Features & Changes"
|
||||
- The *option* to **store library data separately** from library content
|
||||
- This will enable TagStudio libraries to be created for read-only folders
|
||||
- This will enable TagStudio to have different libraries for the same content folder(s)
|
||||
- **Multi-root libraries** that can read from multiple content folders
|
||||
- This will reduce the need for using complex [`.ts_ignore`](ignore.md) rules
|
||||
- This will enable having library content that spans across different drives, most notably on Windows, without the need for OS-dependant workarounds such as symlinks
|
||||
- Sharable tag packs and color packs
|
||||
- Global tags, accessible across different libraries
|
||||
|
||||
The library is how TagStudio represents your chosen directory, with every file inside being represented by a [file entry](entries.md). You can have as many or few libraries as you wish, since each libraries' data is stored within a `.TagStudio` folder at its root. From there the library save file itself is stored as `ts_library.sqlite`, with TagStudio versions 9.4 and below using a the legacy `ts_library.json` format.
|
||||
See the [Roadmap](roadmap.md#library) for more information.
|
||||
|
||||
Note that this means [tags](tags.md) you create only exist _per-library_. Global tags along with other library structure updates are planned for future releases on the [roadmap](roadmap.md#library).
|
||||
## :material-database-plus: Creating/Opening a Library
|
||||
|
||||
To create or open a [library](libraries.md), go to **File -> Open/Create Library** in the menu bar or use <kbd>Ctrl</kbd>+<kbd>O</kbd> (<kbd>⌘ Command </kbd>+<kbd>O</kbd> on macOS) and chose a folder with file contents you'd like to use as a TagStudio library. If a `.TagStudio` folder doesn't already exist inside the directory, TagStudio will create one and automatically scan the folder for files to include. Otherwise, the pre-existing library is opened.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! info "Legacy Library Migration"
|
||||
If you open a library created with TagStudio **v9.4.2 or earlier** in **[v9.5.0](changelog.md#950-march-3rd-2025) or later**, you'll be walked through a migration process that converts the old `ts_library.json` save file to the new `ts_library.sqlite` format. The original JSON file is preserved and can be easily deleted from the **View -> Library Information** panel once you're satisfied with the migration.
|
||||
|
||||
## :material-database-refresh: Refreshing Directories
|
||||
|
||||
TagStudio automatically scans for new or updated files when opening a library by default. This behavior can be toggled in the settings if your library is very large and/or located on a slow drive.
|
||||
|
||||

|
||||
|
||||
To manually refresh your library at any time, use **File -> Refresh Directories** from the menu or by using <kbd>Ctrl</kbd>+<kbd>R</kbd> (<kbd>⌘ Command </kbd>+<kbd>R</kbd> on macOS).
|
||||
|
||||
## :material-database-cog: Library Information Panel
|
||||
|
||||
The "Library Information" panel can be accessed from **Tools -> Library Information** in the menu bar, and includes various statistics about your library along with quick access to managing common library cleanup tasks such as relinking entries, updating ignored files, and managing library data backups.
|
||||
|
||||

|
||||
|
||||
## :material-database-clock: Saving and Creating Backups
|
||||
|
||||
As of v9.5.0, libraries save automatically as you work.
|
||||
|
||||
To create a timestamped backup of your library save file, go to **File -> Save Library Backup** or use <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> (<kbd>⌘ Command </kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> on macOS). Backups are also automatically created whenever the database file is migrated to a newer version as a precautionary measure. Backups currently _only_ include your `ts_library.sqlite` file, as that's the database file that contains your core TagStudio data. Your own files are **not** part of any of these backups.
|
||||
|
||||
Backups are stored inside the library data folder under `.TagStudio/backups/` and can be managed from the **Tools -> Library Information** panel.
|
||||
|
||||
## :material-folder: Library Data Folder
|
||||
|
||||
When you create a library, TagStudio creates a hidden `.TagStudio` folder at the root of the chosen content folder. This "data folder" contains all TagStudio data for that library. Library data includes what files are included in your library, what [tags](tags.md) you've created in that library, which files have what tags, and more. Note that this means tags you create only exist _per-library_. Global tags that are accessible across libraries are planned for a [future update](roadmap.md#library).
|
||||
|
||||
### :material-file-tree: Data Folder Structure
|
||||
|
||||
The library data folder (currently only named `.TagStudio`) is internally structured as follows:
|
||||
|
||||
| File/Folder | Description |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ts_library.sqlite` | The library save file. Stores all entries, tags, fields, and other metadata. _(v9.5.0+)_ |
|
||||
| `.ts_ignore` | An optional ["ignore" file](ignore.md) for excluding files and folders from library scans, similar to a [`.gitignore`](https://git-scm.com/docs/gitignore) file. |
|
||||
| `backups/` | Timestamped backups of the library save file. |
|
||||
| `thumbs/` | Thumbnail images for file previews. |
|
||||
|
||||
```yaml title="Library Folder Example"
|
||||
My Library/ # (Content Folder)
|
||||
├─ file_1.jpg
|
||||
├─ file_2.txt
|
||||
├─ .TagStudio/ # (Data Folder)
|
||||
│ ├─ ts_library.sqlite (References outer folder for files)
|
||||
│ ├─ .ts_ignore
|
||||
│ ├─ backups/
|
||||
│ ├─ thumbs/
|
||||
```
|
||||
|
||||
### :material-bag-suitcase: Library Portability
|
||||
|
||||
Because the `.TagStudio` _data folder_ is located in your library _content folder_, and it stores all file entry paths _relative_ to the content folder, your library folder can be freely moved to another location without files becoming [unlinked](entries.md#unlinked-entries). This also means that if you have a TagStudio library stored on an external drive, it can be freely moved around to different computers running TagStudio with no issues. Likewise, if your library is located on a network drive or NAS, you can access it from different computers that may map the network location differently from each other _(note that TagStudio currently does not support multiple users accessing the same library at once)._
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
---
|
||||
title: Library Changes
|
||||
icon: material/database-edit
|
||||
---
|
||||
|
||||
<!-- SPDX-FileCopyrightText: (c) TagStudio Contributors -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
|
||||
# :material-database-edit: Library Format
|
||||
# :material-database-edit: Library Changes
|
||||
|
||||
This page outlines the various changes made to the TagStudio library save file format over time, sometimes referred to as the "database" or "database file".
|
||||
|
||||
---
|
||||
|
||||
## JSON
|
||||
## JSON <small>v1.0.0 - v9.4.2</small>
|
||||
|
||||
Legacy (JSON) library save format versions were tied to the release version of the program itself. This number was stored in a `version` key inside the JSON file.
|
||||
|
||||
@@ -27,21 +28,30 @@ Replaced by the new SQLite format introduced in TagStudio [v9.5.0 Pre-Release 1]
|
||||
|
||||
---
|
||||
|
||||
## SQLite
|
||||
## SQLite <small>v9.5.0+</small>
|
||||
|
||||
Starting with TagStudio [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1), the library save format has been moved to a [SQLite](https://sqlite.org) format. Legacy JSON libraries are migrated (with the user's consent) to the new format when opening in current versions of the program. The save format versioning is now separate from the program's versioning number.
|
||||
|
||||
### Versioning
|
||||
|
||||
Versions **1-100** stored the database version in a table called `preferences` in a row with the `key` column of `"DB_VERSION"` inside the corresponding `value` column.
|
||||
|
||||
Versions **>101** store the database version in a table called `versions` in a row with the `key` column of `'CURRENT'` inside the corresponding `value` column. The `versions` table also stores the initial database version in which the file was created with under the `'INITIAL'` key. Databases created before this key was introduced will always have `'INITIAL'` value of `100`.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
versions {
|
||||
TEXT key PK "Values: ['INITIAL', 'CURRENT']"
|
||||
INTEGER value
|
||||
}
|
||||
```
|
||||
#### "versions" Table
|
||||
|
||||
| key (`VARCHAR`) | value (`INTEGER`) |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `'INITIAL'` | <Version DB was created with, minimum `100`\> |
|
||||
| `'CURRENT'` | <Current version of DB\> |
|
||||
|
||||
#### Major and Minor Versioning
|
||||
|
||||
Version **100** came along with a major/minor versioning system built into to the single version number. The version number divided by 100 denotes the major version, while remaining digits denote the minor version. TagStudio will allow reading from "future" databases so long as the major version does not increase past the last one it understands.
|
||||
|
||||
For example, a database with version 204 would still be readable in an older version of TagStudio that understands version 200. A database with version 300, on the other hand, would no longer be readable in that same older version and an error message would display.
|
||||
|
||||
---
|
||||
|
||||
### Versions 1 - 5
|
||||
|
||||
@@ -94,13 +104,13 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
---
|
||||
|
||||
### Versions 100 - 1xx
|
||||
### Versions 100 - 104
|
||||
|
||||
#### Version 100
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 74383e3c3c12f72be1481ab0b86c7360b95c2d85 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Introduces built-in minor versioning
|
||||
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
|
||||
@@ -109,11 +119,11 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 101
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [12e074b](https://github.com/TagStudioDev/TagStudio/commit/12e074b71d8860282b44e49e0e1a41b7a2e4bae8)/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 12e074b71d8860282b44e49e0e1a41b7a2e4bae8/[v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Deprecates the `preferences` table, set to be removed in a future TagStudio version.
|
||||
- Deprecates the `preferences` table, set to be removed in a [future](#version-104) TagStudio version.
|
||||
- Introduces the `versions` table
|
||||
- Has a string `key` column and an int `value` column
|
||||
- The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'`
|
||||
@@ -123,34 +133,36 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
|
||||
#### Version 102
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [71d0425](https://github.com/TagStudioDev/TagStudio/commit/71d04254cf87f4200bb7ffc81656e50dfb122e4d) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 71d04254cf87f4200bb7ffc81656e50dfb122e4d | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted.
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [88d0b47](https://github.com/TagStudioDev/TagStudio/commit/88d0b47a86821ccfadba653f30a515abce5b24b0)/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 88d0b47a86821ccfadba653f30a515abce5b24b0/[v9.5.7](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.7) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches.
|
||||
- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default.
|
||||
|
||||
#### Version 104
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| [ad2cbbc](https://github.com/TagStudioDev/TagStudio/commit/ad2cbbca483018d245b44348e2c4f5a0e0bb28f1) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| ad2cbbca483018d245b44348e2c4f5a0e0bb28f1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Removes the `preferences` table, after migrating the contained extension list to the .ts_ignore file, if necessary.
|
||||
|
||||
---
|
||||
|
||||
### Versions 200 - 2xx
|
||||
|
||||
#### Version 200
|
||||
|
||||
| Used From | Format | Location |
|
||||
| --------- | ------ | ----------------------------------------------- |
|
||||
| TBD | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| c15e2b56eedd0a3c13391fa43571b8f8f7c7a91f | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Adds `text_field_templates` and `date_field_templates` tables.
|
||||
- Drops `boolean_fields` and `value_type` tables.
|
||||
@@ -162,3 +174,12 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
|
||||
- Values are set to `TRUE` if the field row was previously a "TEXT_BOX" type.
|
||||
- Repairs existing "Description" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE` _(Previously done in [Version 7](#version-7))_.
|
||||
- Repairs existing "Comments" fields inside the `text_fields` table to have their `is_multiline` column set to `TRUE`.
|
||||
|
||||
#### Version 201
|
||||
|
||||
| Used From | Format | Location |
|
||||
| ---------------------------------------- | ------ | ----------------------------------------------- |
|
||||
| 38da7bb3a920a01d4d70fa065fd19c83ff6eecb1 | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
|
||||
|
||||
- Drops `type_key` columns from `text_fields` and `datetime_fields` tables.
|
||||
- Enforces column positions for `text_fields` and `datetime_fields` tables.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Tools & Macros
|
||||
icon: material/script-text
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Supported Previews
|
||||
icon: material/image-check
|
||||
---
|
||||
|
||||
@@ -123,6 +124,8 @@ Archive thumbnails will display the first image from the archive within the Prev
|
||||
!!! failure "3D Model Support"
|
||||
TagStudio does not currently support previews for 3D model files *(outside of Blender project embedded thumbnails)*. This is on our [roadmap](roadmap.md#uiux) for a future release.
|
||||
|
||||
See the [GitHub discussion](https://github.com/TagStudioDev/TagStudio/discussions/1231) relating to status of this feature.
|
||||
|
||||
### :material-format-font: Fonts
|
||||
|
||||
Font thumbnails will use a "Aa" example preview of the font, with a full alphanumeric of the font available in the Preview Panel.
|
||||
@@ -154,7 +157,7 @@ Text files render the first 256 bytes of text information to an image preview fo
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[^1]:
|
||||
The `.jpg_large` extension is unofficial and instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since this mangled extension is still in circulation, TagStudio supports it.
|
||||
The `.jpg_large` extension is unofficial and is instead the byproduct of how [Google Chrome used to download images from Twitter](https://fileinfo.com/extension/jpg_large). Since this mangled extension is still in circulation, TagStudio supports it.
|
||||
|
||||
[^2]:
|
||||
Apple Lossless traditionally uses `.m4a` and `.caf` containers, but may unofficially use the `.alac` extension. The `.m4a` container is also used for separate compressed audio codecs.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Roadmap
|
||||
icon: material/map-check
|
||||
---
|
||||
|
||||
@@ -11,7 +12,7 @@ This page outlines the current and planned features required for TagStudio to be
|
||||
|
||||
This roadmap will update as new features are planned or completed. If there's a feature you'd like to see but is not listed on this page, please check the GitHub [Issues](https://github.com/TagStudioDev/TagStudio/issues) page and submit a feature request if one does not already exist!
|
||||
|
||||
## Priority Levels
|
||||
## :material-chevron-triple-up: Priority Levels
|
||||
|
||||
Planned features and changes are assigned **priority levels** to signify how important they are to the feature-complete version of TagStudio and to serve as a general guide for what should be worked on first, along with [version estimates](#version-estimates). When features are completed, their priority level icons are removed.
|
||||
|
||||
@@ -21,75 +22,81 @@ Planned features and changes are assigned **priority levels** to signify how imp
|
||||
- :material-chevron-double-up:{ .priority-med title="Medium Priority" } **Medium Priority** - Important, but not necessary
|
||||
- :material-chevron-up:{ .priority-low title="Low Priority" } **Low Priority** - Just nice to have
|
||||
|
||||
## Version Estimates
|
||||
## :material-map-clock: Version Estimates
|
||||
|
||||
Features are given rough estimations for which version they will be completed in, and are listed next to their names (e.g. Feature **[v9.0.0]**). They are eventually replaced with links to the version changelog in which they were completed in, if applicable.
|
||||
Features are given rough estimations for which version they will be completed in listed next to their names (e.g. Feature **[v9.0.0]**). When the feature is completed they're linked to their respective changelog release, if applicable.
|
||||
|
||||
| Version Cycle | Goals |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ~~**Alpha v9.5.x**~~ | ~~Migrate from JSON to SQLite database format~~ |
|
||||
| **Alpha v9.6.x** | Necessary database changes for upcoming features ([fields](#entries), [file metadata](#sql-based-library-database), etc.) |
|
||||
| **Alpha v9.7.x** | Implement currently solidified features ([entry groups](#entries), etc.) |
|
||||
| **Beta v9.8.x** | Solidify remaining features and implementations ([component tags](#tags), etc.) |
|
||||
| **Beta v9.9.x** | Make any additions and fixes from earlier release cycles |
|
||||
| **v10.0.x** | Feature complete, versioning switches to [semver](https://semver.org/) |
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! tip
|
||||
For a more definitive and up-to-date list of features planned for near-future updates, please reference the current GitHub [Milestones](https://github.com/TagStudioDev/TagStudio/milestones)!
|
||||
|
||||
---
|
||||
## :material-engine: Core
|
||||
|
||||
## Core
|
||||
### :material-database: SQL-Based Library Database
|
||||
|
||||
### :material-database: SQL Library Database
|
||||
|
||||
An improved SQLite-based library save file format in which legacy JSON libraries are be migrated to.
|
||||
Must be finalized or deemed "feature complete" before other core features are developed or finalized.
|
||||
A SQLite database file used as the [library](./libraries.md) save file format. Legacy JSON libraries are migrated to this improved format.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
See the "[Library](#library)" section for features related to the library database rather than the underlying schema.
|
||||
|
||||
- [x] A SQLite-based library save file format **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [ ] Cached File Properties Table :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Date Entry Added to Library :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Cached File Properties Table :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.1]**
|
||||
- [x] Date Entry Added to Library
|
||||
- [ ] Date File Created :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Date File Modified :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Date Photo Taken :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Media Duration :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Media Dimensions :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Date Photo Taken :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Media Duration :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Media Dimensions :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Word Count :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
|
||||
### :material-database-cog: Core Library + API
|
||||
### :material-database-cog: Core Library + CLI
|
||||
|
||||
A separated, UI agnostic core library that would be used to interface with the TagStudio library format. Would host an API for communication from outside the program. This would be licensed under the more permissive [MIT](https://en.wikipedia.org/wiki/MIT_License) license to foster wider adoption compared to the TagStudio application source code.
|
||||
A separated, UI agnostic core library that would be used to interface with the TagStudio library format. Would come with a CLI to allow for interfacing with scripts and external programs, and to make bulk operations easier. This would be licensed under the more permissive [MIT](https://en.wikipedia.org/wiki/MIT_License) license to foster wider adoption compared to the TagStudio GUI application source code.
|
||||
|
||||
- [ ] Core Library :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]**
|
||||
- [ ] Core Library API :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]**
|
||||
- [ ] MIT License :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]**
|
||||
- [ ] Core Library :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.0]**
|
||||
- [ ] CLI :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
|
||||
- [ ] MIT License _(Core Library + CLI)_ :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.0]**
|
||||
|
||||
### :material-clipboard-text: Format Specification
|
||||
|
||||
A detailed written specification for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
|
||||
A detailed specification written for the TagStudio tag and/or library format. Intended for used by third-parties to build alternative cores or protocols that can remain interoperable.
|
||||
|
||||
- [ ] Format Specification Established :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]**
|
||||
- [ ] Format Specification Established :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v10.0.0]**
|
||||
|
||||
---
|
||||
|
||||
## Application
|
||||
## :material-application-outline: Application
|
||||
|
||||
### :material-button-cursor: UI/UX
|
||||
|
||||
- [x] Library Grid View
|
||||
- [ ] Explore Filesystem in Grid View :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Infinite Scrolling (No Pagination) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Library List View :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Explore Filesystem in List View :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Lightbox View :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Explore Filesystem in Grid View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [x] Infinite Scrolling **[[v9.5.6](changelog.md#956-october-20th-2025)]**
|
||||
- [ ] Library List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Explore Filesystem in List View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Lightbox View :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- Similar to List View in concept, but displays one large preview that can cycle back/forth between entries.
|
||||
- [ ] Smaller thumbnails of immediate adjacent entries below :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [x] Library Statistics Screen **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [x] Unified Library Health/Cleanup Screen **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [x] Fix Unlinked Entries
|
||||
- [x] Fix Duplicate Files
|
||||
- [ ] Fix Duplicate Files <small>(Regression)</small> **[v9.6.x]**
|
||||
- [x] ~~Fix Duplicate Entries~~
|
||||
- [x] Remove Ignored Entries **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [x] Delete Old Backups **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [x] Delete Legacy JSON File **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [x] Translations
|
||||
- [ ] Search Bar Rework :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] 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" }
|
||||
- [x] Unified Media Player
|
||||
@@ -99,23 +106,23 @@ A detailed written specification for the TagStudio tag and/or library format. In
|
||||
- [x] Toggle Autoplay
|
||||
- [x] Volume Control
|
||||
- [x] Toggle Mute
|
||||
- [x] Timeline scrubber
|
||||
- [ ] Fullscreen :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Fine-Tuned UI/UX :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6]**
|
||||
- [ ] 3D Model Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [x] Timeline Scrubber
|
||||
- [ ] Fullscreen Mode :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Fine-Tuned UI/UX :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] 3D Model Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" } _(See Discussion #1231)_
|
||||
- [ ] STL File Support
|
||||
- [ ] OBJ File Support
|
||||
- [ ] Plaintext Thumbnails/Previews :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Plaintext Thumbnails/Previews
|
||||
- [x] Basic Support
|
||||
- [ ] Full File Preview :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Syntax Highlighting :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Toggleable Persistent Tagging Panel :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] 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]**
|
||||
- [ ] Top Tags
|
||||
- [ ] Recent Tags
|
||||
- [ ] Tag Search
|
||||
- [ ] Pinned Tags
|
||||
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] New Tabbed Tag Building UI to Support New Tag Features :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Custom Thumbnail Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Media Duration Labels :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Word/Line Count Labels :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Custom Tag Badges :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
@@ -130,49 +137,45 @@ A detailed written specification for the TagStudio tag and/or library format. In
|
||||
- [x] Theme
|
||||
- [x] Thumbnail Generation **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [x] Configurable Page Size
|
||||
- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Library Settings :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Stored in `.TagStudio` folder :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Toggle File Extension Label :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Toggle Duration Label :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
|
||||
### :material-puzzle: Plugin Support
|
||||
|
||||
Some form of official plugin support for TagStudio, likely with its own API that may connect to or encapsulate part of the the [core library API](#core-library-api).
|
||||
|
||||
- [ ] Plugin Support :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]**
|
||||
|
||||
---
|
||||
|
||||
## [Library](libraries.md)
|
||||
## :material-database: Library
|
||||
|
||||
### :material-wrench: Library Mechanics
|
||||
|
||||
- [x] Per-Library Tags
|
||||
- [ ] Global Tags :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Global Tags :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.8.x]**
|
||||
- [ ] Multiple Root Directories :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Ability to store TagStudio library folder separate from library files :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Ability to store TagStudio data folder separate from library content folder(s) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Automatic Entry Relinking :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Detect Renames :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Detect Moves :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Detect Deletions :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Performant :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Background File Scanning :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Background File Scanning :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [x] Thumbnail Caching **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [ ] Audio Waveform Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Large Image Caching :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
|
||||
### :material-grid: [Entries](entries.md)
|
||||
### :material-grid: Entries
|
||||
|
||||
Library representations of files or file-like objects.
|
||||
File or file-like [entries](entries.md) stored in the library.
|
||||
|
||||
- [x] File Entries **[v1.0.0]**
|
||||
- [ ] Folder Entries :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] URL Entries / Bookmarks :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.6.x]**
|
||||
- [x] Fields
|
||||
- [x] Text Lines
|
||||
- [x] Text Boxes
|
||||
- [x] Datetimes **[[v9.5.4](changelog.md#954-september-1st-2025)]**
|
||||
- [ ] User-Titled Fields :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Removal of Deprecated Fields :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Numeric Fields :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Optional Units (e.g. inches, cm, height notation, degrees, bytes, etc.) :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Custom Field Names :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [x] Removal of Deprecated Fields **[v9.6.0]**
|
||||
- [ ] Entry Groups :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Non-exclusive; Entries can be in multiple groups :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Ability to number entries within group :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
@@ -181,7 +184,7 @@ Library representations of files or file-like objects.
|
||||
- [ ] Group is treated as entry with tags and metadata :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Nested groups :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
|
||||
### :material-tag-text: [Tags](tags.md)
|
||||
### :material-tag-text: Tags
|
||||
|
||||
Discrete library objects representing [attributes](<https://en.wikipedia.org/wiki/Property_(philosophy)>). Can be applied to library [entries](entries.md), or applied to other tags to build traversable relationships.
|
||||
|
||||
@@ -189,46 +192,47 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
- [x] Tag Shorthand Name **[v8.0.0]**
|
||||
- [x] Tag Aliases List **[v8.0.0]**
|
||||
- [x] Tag Color **[v8.0.0]**
|
||||
- [ ] Tag Description :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Tag Description :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [x] Tag Colors
|
||||
- [x] Built-in Color Palette **[v8.0.0]**
|
||||
- [x] User-Defined Colors **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Primary and Secondary Colors **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [ ] Built-in Icon Packs (i.e. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] User-Defined Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Tag Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Small Icons :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Large Icons for Profiles :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Built-in Icon Packs (e.g. Boxicons) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] 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]**
|
||||
- [ ] Hidden Property :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Built-in "Archived" tag has this property by default :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Checkbox near search bar to show hidden tags in search :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)]**
|
||||
- [ ] Tag Relationships
|
||||
- [x] [Parent Tags](tags.md#parent-tags) ([Inheritance](<https://en.wikipedia.org/wiki/Inheritance_(object-oriented_programming)>) Relationship) **[v9.0.0]**
|
||||
- [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] [Component Tags](tags.md#component-tags) ([Composition](https://en.wikipedia.org/wiki/Object_composition) Relationship) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Multiple Language Support :material-chevron-up:{ .priority-low title="Low Priority" } **[v9.9.x]**
|
||||
- [ ] Tag Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Tag Merging :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Tag Overrides :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.8.x]**
|
||||
- [ ] Tag Merging :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.x]**
|
||||
|
||||
### :material-magnify: [Search](search.md)
|
||||
### :material-magnify: Search
|
||||
|
||||
- [x] Tag Search **[v8.0.0]**
|
||||
- [x] Filename Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Glob Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Filetype Search **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Search by Extension (e.g. ".jpg", ".png") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Optional consolidation of extension synonyms (i.e. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Optional consolidation of extension synonyms (e.g. ".jpg" can equal ".jpeg") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] Search by media type (e.g. "image", "video", "document") **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Field Content Search :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [x] [Boolean Operators](search.md) **[[v9.5.0](changelog.md#950-march-3rd-2025)]**
|
||||
- [x] `AND` Operator
|
||||
- [x] `OR` Operator
|
||||
- [x] `NOT` Operator
|
||||
- [x] Parenthesis Grouping
|
||||
- [x] Character Escaping
|
||||
- [ ] `HAS` Operator (for [Component Tags](tags.md#component-tags)) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] `HAS` Operator (for [Component Tags](tags.md#component-tags)) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Conditional Search :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.7.x]**
|
||||
- [ ] Compare Dates :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Compare Durations :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
@@ -240,21 +244,20 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
- [x] Sort by Date Entry Added to Library **[[v9.5.2](changelog.md#952-march-31st-2025)]**
|
||||
- [ ] Sort by File Creation Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Sort by File Modification Date :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Sort by Date Taken (Photos) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Sort by Date Taken (Photos) :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.6.x]**
|
||||
- [x] Random/Shuffle Sort
|
||||
- [ ] OCR Search :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- [ ] Fuzzy Search :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
|
||||
### :material-file-cog: [Macros](macros.md)
|
||||
### :material-file-cog: Macros
|
||||
|
||||
- [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]**
|
||||
- [ ] Versioning System :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]**
|
||||
- [ ] Triggers **[v9.5.x]**
|
||||
- [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Versioning System :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Triggers **[v9.7.x]**
|
||||
- [ ] On File Added :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] On Library Refresh :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] [...]
|
||||
- [ ] Actions **[v9.5.x]**
|
||||
- [ ] Actions **[v9.7.x]**
|
||||
- [ ] Add Tag(s) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Add Field(s) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Set Field Content :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
@@ -265,22 +268,22 @@ Discrete library objects representing [attributes](<https://en.wikipedia.org/wik
|
||||
Sharable TagStudio library data in the form of data packs (tags, colors, etc.) or other formats.
|
||||
Packs are intended as an easy way to import and export specific data between libraries and users, while export-only formats are intended to be imported by other programs.
|
||||
|
||||
- [ ] Color Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]**
|
||||
- [ ] Color Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.6.x]**
|
||||
- [ ] Importable
|
||||
- [ ] Exportable
|
||||
- [x] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [x] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Versioning System :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Tag Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.9.x]**
|
||||
- [ ] Tag Packs :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.8.x]**
|
||||
- [ ] Importable :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Exportable :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] UUIDs + Namespaces :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Standard, Human Readable Format (TOML) :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Versioning System :material-chevron-double-up:{ .priority-med title="Medium Priority" }
|
||||
- [ ] Macro Sharing :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.5.x]**
|
||||
- [ ] Macro Sharing :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v9.7.x]**
|
||||
- [ ] Importable :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Exportable :material-chevron-triple-up:{ .priority-high title="High Priority" }
|
||||
- [ ] Sharable Entry Data :material-chevron-double-up:{ .priority-med title="Medium Priority" } **[v9.9.x]**
|
||||
- [ ] Sharable Entry Data :material-chevron-up:{ .priority-low title="Low Priority" }
|
||||
- _Specifics of this are yet to be determined_
|
||||
- [ ] Export Library to Human Readable Format :material-chevron-triple-up:{ .priority-high title="High Priority" } **[v10.0.0]**
|
||||
- Intended to give users more flexible options with their data if they wish to migrate away from TagStudio
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Searching
|
||||
icon: material/magnify
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Style Guide
|
||||
icon: material/sign-text
|
||||
---
|
||||
|
||||
@@ -24,9 +25,26 @@ Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older
|
||||
- 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
|
||||
|
||||
- **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
|
||||
|
||||
TagStudio provides an [EditorConfig](https://editorconfig.org/#example-file) file ([`.editorconfig`](../.editorconfig)) along with a [Prettier](https://prettier.io/) config file ([`.prettierrc.toml`](../.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/).
|
||||
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
|
||||
|
||||
|
||||
@@ -144,6 +144,11 @@ h2 > .twemoji {
|
||||
margin-top: 0.08rem;
|
||||
}
|
||||
|
||||
td code {
|
||||
margin-right: 0.3rem;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Matches the palette used by mkdocs-material */
|
||||
.priority-high {
|
||||
color: #f1185a;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Tags
|
||||
icon: material/tag-text
|
||||
---
|
||||
|
||||
@@ -69,7 +70,7 @@ Lastly, when searching your files with broader categories such as `Character` or
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! warning ""
|
||||
**_Coming in version 9.6.x_**
|
||||
**_Planned for future version_** *(See the [Roadmap](roadmap.md))*
|
||||
|
||||
Component tags will be built from a composition-based, or "HAS" type relationship between tags. This takes care of instances where an attribute may "have" another attribute, but doesn't inherit from it. Shrek may be an `Ogre`, he may be a `Character`, but he is NOT a `Leather Vest` - even if he's commonly seen _with_ it. Component tags, along with the upcoming "Tag Override" feature, are built to handle these cases in a way that still simplifies the tagging process without adding too much undue complexity for the user.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
title: Basic Usage
|
||||
icon: material/mouse
|
||||
---
|
||||
|
||||
@@ -7,13 +8,17 @@ icon: material/mouse
|
||||
|
||||
# :material-mouse: Basic Usage
|
||||
|
||||
## Creating/Opening a Library
|
||||
## :material-database-plus: Creating/Opening a Library
|
||||
|
||||
With TagStudio opened, start by creating a new library or opening an existing one using File -> Open/Create Library from the menu bar. TagStudio will automatically create a new library from the chosen directory if one does not already exist. Upon creating a new library, TagStudio will automatically scan your folders for files and add those to your library (no files are moved during this process!).
|
||||
To create or open a [library](libraries.md), go to **File -> Open/Create Library** in the menu bar or use <kbd>Ctrl</kbd>+<kbd>O</kbd> (<kbd>⌘ Command </kbd>+<kbd>O</kbd> on macOS) and chose a folder with file contents you'd like to use as a TagStudio library. If a `.TagStudio` folder doesn't already exist inside the directory, TagStudio will create one and automatically scan the folder for files to include. Otherwise, the pre-existing library is opened.
|
||||
|
||||
## Refreshing the Library
|
||||
### :material-database-refresh: Refreshing Directories
|
||||
|
||||
Libraries under 10,000 files automatically scan for new or modified files when opened. In order to refresh the library manually, select "Refresh Directories" under the File menu.
|
||||
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"
|
||||
To learn more about how TagStudio libraries work and how to use them, visit the **[Libraries](libraries.md)** page.
|
||||
|
||||
## Adding Tags to File Entries
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
perSystem =
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
python3 = pkgs.python312;
|
||||
python3 = pkgs.python313;
|
||||
in
|
||||
{
|
||||
packages =
|
||||
|
||||
@@ -35,28 +35,27 @@ nav:
|
||||
- Getting Started:
|
||||
- install.md
|
||||
- usage.md
|
||||
- Developing:
|
||||
- Developers:
|
||||
- developing.md
|
||||
- contributing.md
|
||||
- style.md
|
||||
- Help:
|
||||
- help/ffmpeg.md
|
||||
- Using Libraries:
|
||||
- Using TagStudio:
|
||||
- libraries.md
|
||||
- entries.md
|
||||
- preview-support.md
|
||||
- search.md
|
||||
- ignore.md
|
||||
- macros.md
|
||||
- Fields:
|
||||
- fields.md
|
||||
- Tags:
|
||||
- Tags & Fields:
|
||||
- tags.md
|
||||
- colors.md
|
||||
- fields.md
|
||||
- Updates:
|
||||
- changelog.md
|
||||
- roadmap.md
|
||||
- Schema History:
|
||||
- Format History:
|
||||
- library-changes.md
|
||||
|
||||
theme:
|
||||
@@ -85,7 +84,7 @@ theme:
|
||||
icon: material/lightbulb-night-outline
|
||||
name: Switch to System Preference
|
||||
logo: assets/icon_mono.svg
|
||||
favicon: assets/icon.ico
|
||||
favicon: assets/favicon.ico
|
||||
font:
|
||||
code: Jetbrains Mono
|
||||
language: en
|
||||
@@ -116,6 +115,7 @@ markdown_extensions:
|
||||
- md_in_html
|
||||
- tables
|
||||
- toc:
|
||||
title: Table of Contents
|
||||
permalink: true
|
||||
toc_depth: 3
|
||||
|
||||
@@ -133,6 +133,11 @@ markdown_extensions:
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.keys
|
||||
- pymdownx.mark
|
||||
- pymdownx.magiclink:
|
||||
repo_url_shorthand: True
|
||||
provider: github
|
||||
user: TagStudioDev
|
||||
repo: TagStudio
|
||||
- pymdownx.smartsymbols
|
||||
- pymdownx.snippets
|
||||
- pymdownx.superfences:
|
||||
@@ -150,6 +155,7 @@ markdown_extensions:
|
||||
plugins:
|
||||
- search
|
||||
- tags
|
||||
- typeset
|
||||
- social: # social embed cards
|
||||
enabled: !ENV [CI, false] # enabled only when running in CI (eg GitHub Actions)
|
||||
- redirects:
|
||||
|
||||
@@ -95,6 +95,7 @@ python3Packages.buildPythonApplication {
|
||||
dependencies =
|
||||
with python3Packages;
|
||||
[
|
||||
audioop-lts
|
||||
chardet
|
||||
ffmpeg-python
|
||||
humanfriendly
|
||||
|
||||
@@ -3,13 +3,24 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
|
||||
|
||||
|
||||
<!-- Table of contents item -->
|
||||
<li class="md-nav__item">
|
||||
<a href="{{ toc_item.url }}" class="md-nav__link">
|
||||
<span class="md-ellipsis">
|
||||
<a href="{{ toc_item.url }}" class="md-nav__link">
|
||||
<span class="md-ellipsis">
|
||||
|
||||
<!-- Typeset title -->
|
||||
{% if toc_item.typeset %}
|
||||
<span class="md-typeset">
|
||||
{{ toc_item.typeset.title }}
|
||||
</span>
|
||||
|
||||
<!-- Regular title -->
|
||||
{% else %}
|
||||
{{ toc_item.title }}
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Table of contents list -->
|
||||
{% if toc_item.children %}
|
||||
|
||||
@@ -9,11 +9,12 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "TagStudio"
|
||||
description = "A User-Focused Photo & File Management System."
|
||||
version = "9.5.7"
|
||||
version = "9.6.0"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"audioop-lts; python_version >= '3.13'",
|
||||
"chardet~=5.2",
|
||||
"ffmpeg-python~=0.2",
|
||||
"humanfriendly==10.*",
|
||||
@@ -23,7 +24,7 @@ dependencies = [
|
||||
"Pillow>=10.2,<12",
|
||||
"pillow-heif~=0.22",
|
||||
"pillow-jxl-plugin~=1.3",
|
||||
"py7zr==1.0.0",
|
||||
"py7zr~=1.1.3",
|
||||
"pydantic~=2.10",
|
||||
"pydub~=0.25",
|
||||
"PySide6==6.8.0.*",
|
||||
@@ -42,19 +43,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["tagstudio[mkdocs,mypy,pre-commit,pyinstaller,pytest,ruff]"]
|
||||
mkdocs = ["mkdocs-material[imaging]>=9.6.14", "mkdocs-redirects~=1.2"]
|
||||
mypy = ["mypy==1.15.0", "mypy-extensions==1.*", "types-ujson~=5.10"]
|
||||
dev = ["tagstudio[mkdocs,pyright,pre-commit,pyinstaller,pytest,ruff]"]
|
||||
mkdocs = ["mkdocs-material[imaging]>=9.7", "mkdocs-redirects~=1.2"]
|
||||
pyright = ["pyright~=1.1.409"]
|
||||
pre-commit = ["pre-commit~=4.2"]
|
||||
pyinstaller = ["Pyinstaller~=6.13"]
|
||||
pyinstaller = ["Pyinstaller~=6.21"]
|
||||
pytest = [
|
||||
"pytest==8.3.5",
|
||||
"pytest==9.0.3",
|
||||
"pytest-cov==6.1.1",
|
||||
"pytest-mock==3.15.1",
|
||||
"pytest-qt==4.4.0",
|
||||
"syrupy==4.9.1",
|
||||
"syrupy==5.1.0",
|
||||
]
|
||||
ruff = ["ruff==0.11.8"]
|
||||
ruff = ["ruff==0.15.17"]
|
||||
|
||||
[project.gui-scripts]
|
||||
tagstudio = "tagstudio.main:main"
|
||||
@@ -62,35 +63,11 @@ tagstudio = "tagstudio.main:main"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/tagstudio"]
|
||||
|
||||
[tool.mypy]
|
||||
mypy_path = ["src/tagstudio"]
|
||||
disable_error_code = [
|
||||
"annotation-unchecked",
|
||||
"func-returns-value",
|
||||
"import-untyped",
|
||||
]
|
||||
explicit_package_bases = true
|
||||
ignore_missing_imports = true
|
||||
implicit_optional = true
|
||||
strict_optional = false
|
||||
warn_unused_ignores = true
|
||||
exclude = ["build", "dist"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tagstudio.qt.main_window"
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tagstudio.qt.ui.home_ui"
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tagstudio.core.ts_core"
|
||||
ignore_errors = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
#addopts = "-m 'not qt'"
|
||||
qt_api = "pyside6"
|
||||
pythonpath = ["src"]
|
||||
|
||||
[tool.pyright]
|
||||
ignore = [
|
||||
@@ -99,17 +76,20 @@ ignore = [
|
||||
"src/tagstudio/qt/previews/vendored/pydub/",
|
||||
]
|
||||
include = ["src/tagstudio", "tests"]
|
||||
# reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
||||
reportAny = false
|
||||
reportIgnoreCommentWithoutRule = false
|
||||
reportImplicitStringConcatenation = false
|
||||
reportImportCycles = false
|
||||
reportMissingTypeArgument = false
|
||||
reportMissingTypeStubs = false
|
||||
# reportOptionalMemberAccess = false
|
||||
reportUnannotatedClassAttribute = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownLambdaType = false
|
||||
reportUnknownMemberType = false
|
||||
reportUnusedCallResult = false
|
||||
reportUnannotatedClassAttribute = false
|
||||
reportUninitializedInstanceVariable = false
|
||||
|
||||
[tool.ruff]
|
||||
exclude = ["home_ui.py", "resources.py", "resources_rc.py"]
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
VERSION: str = "9.5.7" # Major.Minor.Patch
|
||||
VERSION: str = "9.6.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
COPYRIGHT_YEARS: str = "2021-2026"
|
||||
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
|
||||
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
|
||||
|
||||
GITHUB_REPO_URL = "https://github.com/TagStudioDev/TagStudio"
|
||||
GITHUB_RELEASE_URL = "https://github.com/TagStudioDev/TagStudio/releases/latest"
|
||||
DOCS_URL = "https://docs.tagstud.io"
|
||||
FFMPEG_HELP_URL = "https://docs.tagstud.io/help/ffmpeg"
|
||||
DISCORD_URL = "https://discord.com/invite/hRNnVKhF2G"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
@@ -13,9 +21,7 @@ COLLAGE_FOLDER_NAME: str = "collages"
|
||||
IGNORE_NAME: str = ".ts_ignore"
|
||||
THUMB_CACHE_NAME: str = "thumbs"
|
||||
|
||||
FONT_SAMPLE_TEXT: str = (
|
||||
"""ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"""
|
||||
)
|
||||
FONT_SAMPLE_TEXT: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]"
|
||||
FONT_SAMPLE_SIZES: list[int] = [10, 15, 20]
|
||||
|
||||
# NOTE: These were the field IDs used for the "Tags", "Content Tags", and "Meta Tags" fields inside
|
||||
@@ -27,5 +33,4 @@ TAG_FAVORITE = 1
|
||||
TAG_META = 2
|
||||
RESERVED_TAG_START = 0
|
||||
RESERVED_TAG_END = 999
|
||||
|
||||
RESERVED_NAMESPACE_PREFIX = "tagstudio"
|
||||
|
||||
@@ -8,7 +8,7 @@ import structlog
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
from tagstudio.core.constants import TS_FOLDER_NAME
|
||||
from tagstudio.core.enums import SettingItems
|
||||
from tagstudio.core.enums import AppCacheItems
|
||||
from tagstudio.core.library.alchemy.library import LibraryStatus
|
||||
from tagstudio.qt.global_settings import GlobalSettings
|
||||
|
||||
@@ -30,16 +30,16 @@ class DriverMixin:
|
||||
logger.error("Path does not exist.", open_path=open_path)
|
||||
return LibraryStatus(success=False, message="Path does not exist.")
|
||||
elif self.settings.open_last_loaded_on_startup and self.cached_values.value(
|
||||
SettingItems.LAST_LIBRARY
|
||||
AppCacheItems.LAST_LIBRARY
|
||||
):
|
||||
library_path = Path(str(self.cached_values.value(SettingItems.LAST_LIBRARY)))
|
||||
library_path = Path(str(self.cached_values.value(AppCacheItems.LAST_LIBRARY)))
|
||||
if not (library_path / TS_FOLDER_NAME).exists():
|
||||
logger.error(
|
||||
"TagStudio folder does not exist.",
|
||||
library_path=library_path,
|
||||
ts_folder=TS_FOLDER_NAME,
|
||||
)
|
||||
self.cached_values.setValue(SettingItems.LAST_LIBRARY, "")
|
||||
self.cached_values.setValue(AppCacheItems.LAST_LIBRARY, "")
|
||||
# dont consider this a fatal error, just skip opening the library
|
||||
library_path = None
|
||||
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
import enum
|
||||
|
||||
|
||||
class SettingItems(str, enum.Enum):
|
||||
class AppCacheItems(enum.StrEnum):
|
||||
"""List of setting item names."""
|
||||
|
||||
LAST_LIBRARY = "last_library"
|
||||
LIBS_LIST = "libs_list"
|
||||
DISMISSED_UPDATE = "dismissed_update"
|
||||
|
||||
|
||||
class ShowFilepathOption(int, enum.Enum):
|
||||
class ShowFilepathOption(enum.IntEnum):
|
||||
"""Values representing the options for the "show_filenames" setting."""
|
||||
|
||||
SHOW_FULL_PATHS = 0
|
||||
@@ -21,7 +22,7 @@ class ShowFilepathOption(int, enum.Enum):
|
||||
DEFAULT = SHOW_RELATIVE_PATHS
|
||||
|
||||
|
||||
class TagClickActionOption(int, enum.Enum):
|
||||
class TagClickActionOption(enum.IntEnum):
|
||||
"""Values representing the options for the "tag_click_action" setting."""
|
||||
|
||||
OPEN_EDIT = 0
|
||||
@@ -30,7 +31,7 @@ class TagClickActionOption(int, enum.Enum):
|
||||
DEFAULT = OPEN_EDIT
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
class Theme(enum.StrEnum):
|
||||
COLOR_BG_DARK = "#65000000"
|
||||
COLOR_BG_LIGHT = "#22000000"
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
@@ -49,7 +50,7 @@ class OpenStatus(enum.IntEnum):
|
||||
CORRUPTED = 2
|
||||
|
||||
|
||||
class MacroID(enum.Enum):
|
||||
class MacroID(enum.StrEnum):
|
||||
AUTOFILL = "autofill"
|
||||
SIDECAR = "sidecar"
|
||||
BUILD_URL = "build_url"
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
|
||||
|
||||
DB_VERSION_CURRENT_KEY: str = "CURRENT"
|
||||
DB_VERSION_INITIAL_KEY: str = "INITIAL"
|
||||
DB_VERSION: int = 200
|
||||
DB_VERSION: int = 201
|
||||
|
||||
TAG_CHILDREN_QUERY = text("""
|
||||
WITH RECURSIVE ChildTags AS (
|
||||
|
||||
@@ -66,7 +66,7 @@ class TagColorEnum(enum.IntEnum):
|
||||
|
||||
class ItemType(enum.Enum):
|
||||
ENTRY = 0
|
||||
COLLATION = 1
|
||||
ENTRY_GROUP = 1
|
||||
TAG_GROUP = 2
|
||||
|
||||
|
||||
|
||||
@@ -20,19 +20,19 @@ class BaseField(Base):
|
||||
|
||||
@declared_attr
|
||||
def id(self) -> Mapped[int]:
|
||||
return mapped_column(primary_key=True, autoincrement=True)
|
||||
return mapped_column(primary_key=True, autoincrement=True, sort_order=1)
|
||||
|
||||
@declared_attr
|
||||
def name(self) -> Mapped[str]:
|
||||
return mapped_column(nullable=False, default="")
|
||||
return mapped_column(nullable=False, default="", sort_order=2)
|
||||
|
||||
@declared_attr
|
||||
def entry_id(self) -> Mapped[int]:
|
||||
return mapped_column(ForeignKey("entries.id"))
|
||||
return mapped_column(ForeignKey("entries.id"), sort_order=3)
|
||||
|
||||
@declared_attr
|
||||
def entry(self) -> Mapped[Entry]:
|
||||
return relationship(foreign_keys=[self.entry_id]) # type: ignore # pyright: ignore[reportArgumentType]
|
||||
return relationship(foreign_keys=[self.entry_id]) # pyright: ignore[reportArgumentType]
|
||||
|
||||
@property
|
||||
def class_name(self) -> str:
|
||||
@@ -47,7 +47,7 @@ class BaseField(Base):
|
||||
class TextField(BaseField):
|
||||
__tablename__ = "text_fields"
|
||||
|
||||
value: Mapped[str | None]
|
||||
value: Mapped[str | None] = mapped_column(sort_order=4)
|
||||
is_multiline: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
@override
|
||||
@@ -75,7 +75,7 @@ class TextField(BaseField):
|
||||
class DatetimeField(BaseField):
|
||||
__tablename__ = "datetime_fields"
|
||||
|
||||
value: Mapped[str | None]
|
||||
value: Mapped[str | None] = mapped_column(sort_order=4)
|
||||
|
||||
@override
|
||||
def __eq__(self, other: object) -> bool:
|
||||
|
||||
@@ -30,6 +30,7 @@ from sqlalchemy import (
|
||||
Engine,
|
||||
NullPool,
|
||||
ScalarResult,
|
||||
Update,
|
||||
and_,
|
||||
asc,
|
||||
create_engine,
|
||||
@@ -419,6 +420,7 @@ class Library:
|
||||
# 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
|
||||
|
||||
logger.info(
|
||||
"[Library] Opening SQLite Library",
|
||||
@@ -430,6 +432,7 @@ class Library:
|
||||
# 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.
|
||||
@@ -452,7 +455,7 @@ class Library:
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(f"[Library] DB_VERSION: {loaded_db_version}")
|
||||
logger.info(f"[Library] Library DB version: {loaded_db_version}")
|
||||
make_tables(self.engine)
|
||||
|
||||
if is_new:
|
||||
@@ -571,6 +574,9 @@ class Library:
|
||||
self.__apply_db104_migrations(session, library_dir)
|
||||
if loaded_db_version < 200:
|
||||
self.__apply_db200_migrations(session)
|
||||
# changes: field tables
|
||||
if initial_db_version < 200 and loaded_db_version < 201:
|
||||
self.__apply_db201_migrations(session)
|
||||
|
||||
session.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_tags_name_shorthand ON tags (name, shorthand)")
|
||||
@@ -588,6 +594,7 @@ 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)
|
||||
|
||||
# everything is fine, set the library path
|
||||
@@ -808,10 +815,6 @@ class Library:
|
||||
session.execute(text("UPDATE datetime_fields SET name = type_key"))
|
||||
session.flush()
|
||||
|
||||
# TODO: Remove `type_key` columns from text_fields and datetime_fields tables.
|
||||
# See issue with dropping columns foreign keys in SQLite:
|
||||
# https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
|
||||
|
||||
# Change `name` values to title case
|
||||
logger.info("[Library][Migration][200] Normalizing TextField names...")
|
||||
for text_field in session.execute(select(TextField)).scalars():
|
||||
@@ -863,6 +866,57 @@ class Library:
|
||||
|
||||
session.commit()
|
||||
|
||||
def __apply_db201_migrations(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()
|
||||
|
||||
@property
|
||||
def field_templates(self) -> Sequence[BaseFieldTemplate]:
|
||||
with Session(self.engine) as session:
|
||||
@@ -957,7 +1011,7 @@ class Library:
|
||||
)
|
||||
statement = statement.distinct()
|
||||
entries: ScalarResult[Entry] | list[Entry] = session.execute(statement).scalars()
|
||||
entries = entries.unique() # type: ignore
|
||||
entries = entries.unique()
|
||||
|
||||
entry_order_dict = {e_id: order for order, e_id in enumerate(entry_ids)}
|
||||
entries = sorted(entries, key=lambda e: entry_order_dict[e.id])
|
||||
@@ -1198,32 +1252,33 @@ class Library:
|
||||
if limit <= 0:
|
||||
limit = sys.maxsize
|
||||
|
||||
name = name or ""
|
||||
name = name.lower()
|
||||
search_query: str = name.lower() if name else ""
|
||||
|
||||
def sort_key(text: str):
|
||||
priority = text.startswith(name)
|
||||
priority = text.startswith(search_query)
|
||||
p_ordering = len(text) if priority else sys.maxsize
|
||||
return (not priority, p_ordering, text)
|
||||
return not priority, p_ordering, text
|
||||
|
||||
with Session(self.engine) as session:
|
||||
query = select(Tag.id, Tag.name)
|
||||
|
||||
if limit > 0 and not name:
|
||||
if limit > 0 and not search_query:
|
||||
query = query.order_by(Tag.name).limit(limit)
|
||||
|
||||
if name:
|
||||
if search_query:
|
||||
query = query.where(
|
||||
or_(
|
||||
Tag.name.icontains(name),
|
||||
Tag.shorthand.icontains(name),
|
||||
Tag.name.icontains(search_query),
|
||||
Tag.shorthand.icontains(search_query),
|
||||
)
|
||||
)
|
||||
|
||||
tags = list(session.execute(query))
|
||||
|
||||
if name:
|
||||
query = select(TagAlias.tag_id, TagAlias.name).where(TagAlias.name.icontains(name))
|
||||
if search_query:
|
||||
query = select(TagAlias.tag_id, TagAlias.name).where(
|
||||
TagAlias.name.icontains(search_query)
|
||||
)
|
||||
tags.extend(session.execute(query))
|
||||
|
||||
tags.sort(key=lambda t: sort_key(t[1]))
|
||||
@@ -1233,7 +1288,7 @@ class Library:
|
||||
|
||||
logger.info(
|
||||
"searching tags",
|
||||
search=name,
|
||||
search=search_query,
|
||||
limit=limit,
|
||||
statement=str(query),
|
||||
results=len(tag_ids),
|
||||
@@ -1259,6 +1314,157 @@ class Library:
|
||||
|
||||
return direct_tags, descendant_tags
|
||||
|
||||
def add_field_template(self, field_template: BaseFieldTemplate) -> BaseFieldTemplate | None:
|
||||
"""Add a new field template to the library."""
|
||||
if not (isinstance(field_template, (TextFieldTemplate, DatetimeFieldTemplate))):
|
||||
logger.error("[Library] BaseFieldTemplate attempted to be added to the library.")
|
||||
return None
|
||||
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
session.add(field_template)
|
||||
session.flush()
|
||||
make_transient(field_template)
|
||||
session.commit()
|
||||
return field_template
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
def update_field_template(self, old_field_type: str, field_template: BaseFieldTemplate) -> bool:
|
||||
"""Update a field template in the library.
|
||||
|
||||
old_field_class:str
|
||||
field_template: BaseFieldTemplate
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
logger.warning(f"Updating old type {old_field_type} to new {field_template.class_name}")
|
||||
is_same_type: bool = old_field_type == field_template.class_name
|
||||
try:
|
||||
update_stmt: Update | None = None
|
||||
# If the template is changing type, remove the old one and add the updated
|
||||
# template to the proper table.
|
||||
if not is_same_type:
|
||||
old_template: BaseFieldTemplate | None = None
|
||||
if old_field_type == "TextFieldTemplate":
|
||||
old_template = session.scalar(
|
||||
select(TextFieldTemplate)
|
||||
.where(TextFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
elif old_field_type == "DatetimeFieldTemplate":
|
||||
old_template = session.scalar(
|
||||
select(DatetimeFieldTemplate)
|
||||
.where(DatetimeFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
if old_template is None:
|
||||
logger.error("[Library] old_template is None")
|
||||
return False
|
||||
session.delete(old_template)
|
||||
session.flush()
|
||||
field_template.id = None # The id should not transfer between tables
|
||||
session.add(field_template)
|
||||
session.commit()
|
||||
# Otherwise, update the existing template in-place
|
||||
elif isinstance(field_template, TextFieldTemplate):
|
||||
update_stmt = (
|
||||
update(TextFieldTemplate)
|
||||
.where(TextFieldTemplate.id == field_template.id)
|
||||
.values(name=field_template.name, is_multiline=field_template.is_multiline)
|
||||
)
|
||||
elif isinstance(field_template, DatetimeFieldTemplate):
|
||||
update_stmt = (
|
||||
update(DatetimeFieldTemplate)
|
||||
.where(DatetimeFieldTemplate.id == field_template.id)
|
||||
.values(name=field_template.name)
|
||||
)
|
||||
if is_same_type:
|
||||
if update_stmt is None:
|
||||
return False
|
||||
session.execute(update_stmt)
|
||||
session.commit()
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def remove_field_template(self, field_template: BaseFieldTemplate) -> bool:
|
||||
"""Remove a field template from the library."""
|
||||
with Session(self.engine) as session:
|
||||
try:
|
||||
session_item: BaseFieldTemplate | None = None
|
||||
if isinstance(field_template, TextFieldTemplate):
|
||||
session_item = session.scalar(
|
||||
select(TextFieldTemplate)
|
||||
.where(TextFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
elif isinstance(field_template, DatetimeFieldTemplate):
|
||||
session_item = session.scalar(
|
||||
select(DatetimeFieldTemplate)
|
||||
.where(DatetimeFieldTemplate.id == field_template.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if session_item is not None:
|
||||
session.delete(session_item)
|
||||
session.commit()
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def search_field_templates(self, name: str | None, limit: int = 100) -> list[BaseFieldTemplate]:
|
||||
"""Return field template rows matching the query, detached from the session."""
|
||||
if limit <= 0:
|
||||
limit = sys.maxsize
|
||||
|
||||
search_query: str = name.lower() if name else ""
|
||||
|
||||
def sort_key(template: BaseFieldTemplate) -> tuple[str] | tuple[bool, int, str]:
|
||||
text = template.name.lower()
|
||||
if not search_query:
|
||||
return (text,)
|
||||
priority = text.startswith(search_query)
|
||||
p_ordering = len(text) if priority else sys.maxsize
|
||||
return (not priority, p_ordering, text)
|
||||
|
||||
with Session(self.engine) as session:
|
||||
text_stmt = select(TextFieldTemplate)
|
||||
datetime_stmt = select(DatetimeFieldTemplate)
|
||||
if search_query:
|
||||
text_stmt = text_stmt.where(TextFieldTemplate.name.icontains(search_query))
|
||||
datetime_stmt = datetime_stmt.where(
|
||||
DatetimeFieldTemplate.name.icontains(search_query)
|
||||
)
|
||||
|
||||
field_templates: list[BaseFieldTemplate] = [
|
||||
*session.scalars(text_stmt),
|
||||
*session.scalars(datetime_stmt),
|
||||
]
|
||||
field_templates.sort(key=sort_key)
|
||||
field_templates = field_templates[:limit]
|
||||
|
||||
for ft in field_templates:
|
||||
session.expunge(ft)
|
||||
make_transient(ft)
|
||||
|
||||
logger.info(
|
||||
"Searching field templates",
|
||||
search=search_query,
|
||||
limit=limit,
|
||||
results=len(field_templates),
|
||||
)
|
||||
return field_templates
|
||||
|
||||
def update_entry_path(self, entry_id: int | Entry, path: Path) -> bool:
|
||||
"""Set the path field of an entry.
|
||||
|
||||
@@ -1334,7 +1540,12 @@ class Library:
|
||||
session.commit()
|
||||
|
||||
def update_text_field(
|
||||
self, entry_ids: list[int] | int, field: TextField, value: str, is_multiline: bool
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: TextField,
|
||||
name: str,
|
||||
value: str,
|
||||
is_multiline: bool,
|
||||
):
|
||||
"""Update a TextField field on one or more Entries."""
|
||||
if isinstance(entry_ids, int):
|
||||
@@ -1346,7 +1557,7 @@ class Library:
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value, is_multiline=is_multiline)
|
||||
.values(name=name, value=value, is_multiline=is_multiline)
|
||||
)
|
||||
|
||||
session.execute(update_stmt)
|
||||
@@ -1356,6 +1567,7 @@ class Library:
|
||||
self,
|
||||
entry_ids: list[int] | int,
|
||||
field: DatetimeField,
|
||||
name: str,
|
||||
value: datetime,
|
||||
):
|
||||
"""Update a DatetimeField field on one or more Entries."""
|
||||
@@ -1368,7 +1580,7 @@ class Library:
|
||||
update_stmt = (
|
||||
update(field_type)
|
||||
.where(and_(field_type.id == field.id, field_type.entry_id.in_(entry_ids)))
|
||||
.values(value=value)
|
||||
.values(name=name, value=value)
|
||||
)
|
||||
|
||||
session.execute(update_stmt)
|
||||
@@ -1414,7 +1626,7 @@ class Library:
|
||||
if tag:
|
||||
tags.append(tag.id)
|
||||
else:
|
||||
new = session.add(Tag(name=string)) # type: ignore
|
||||
new = session.add(Tag(name=string))
|
||||
if new:
|
||||
tags.append(new.id)
|
||||
session.flush()
|
||||
@@ -1498,8 +1710,7 @@ class Library:
|
||||
self,
|
||||
tag: Tag,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
alias_names: list[str] | set[str] | None = None,
|
||||
alias_ids: list[int] | set[int] | None = None,
|
||||
aliases: Iterable[TagAlias] | None = None,
|
||||
) -> Tag | None:
|
||||
with Session(self.engine, expire_on_commit=False) as session:
|
||||
try:
|
||||
@@ -1509,8 +1720,8 @@ class Library:
|
||||
if parent_ids is not None:
|
||||
self.update_parent_tags(tag, parent_ids, session)
|
||||
|
||||
if alias_ids is not None and alias_names is not None:
|
||||
self.update_aliases(tag, alias_ids, alias_names, session)
|
||||
if aliases is not None:
|
||||
self.update_aliases(tag, aliases)
|
||||
|
||||
session.commit()
|
||||
session.expunge(tag)
|
||||
@@ -1788,11 +1999,10 @@ class Library:
|
||||
self,
|
||||
tag: Tag,
|
||||
parent_ids: list[int] | set[int] | None = None,
|
||||
alias_names: list[str] | set[str] | None = None,
|
||||
alias_ids: list[int] | set[int] | None = None,
|
||||
aliases: Iterable[TagAlias] | None = None,
|
||||
) -> None:
|
||||
"""Edit a Tag in the Library."""
|
||||
self.add_tag(tag, parent_ids, alias_names, alias_ids)
|
||||
self.add_tag(tag, parent_ids, aliases)
|
||||
|
||||
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."""
|
||||
@@ -1843,25 +2053,50 @@ class Library:
|
||||
else:
|
||||
self.add_color(new_color_group)
|
||||
|
||||
def update_aliases(
|
||||
self,
|
||||
tag: Tag,
|
||||
alias_ids: list[int] | set[int],
|
||||
alias_names: list[str] | set[str],
|
||||
session: Session,
|
||||
):
|
||||
prev_aliases = session.scalars(select(TagAlias).where(TagAlias.tag_id == tag.id)).all()
|
||||
def update_aliases(self, tag: Tag, aliases: Iterable[TagAlias]) -> bool:
|
||||
"""Update TagAliases for a given Tag."""
|
||||
with Session(self.engine) as session:
|
||||
# Remove aliases that are no longer on the Tag
|
||||
try:
|
||||
old_aliases = session.scalars(
|
||||
select(TagAlias).where(TagAlias.tag_id == tag.id)
|
||||
).all()
|
||||
old_alias_ids: list[int] = [a.id for a in old_aliases]
|
||||
for old_alias in old_aliases:
|
||||
if old_alias.id not in [a.id for a in aliases] or not old_alias.name:
|
||||
logger.warning(
|
||||
"[Library] Deleting removed alias", id=old_alias.id, name=old_alias.name
|
||||
)
|
||||
session.delete(old_alias)
|
||||
session.commit()
|
||||
except IntegrityError as e:
|
||||
session.rollback()
|
||||
logger.error("[Library] Could not update aliases", error=e)
|
||||
return False
|
||||
|
||||
for alias in prev_aliases:
|
||||
if alias.id not in alias_ids or alias.name not in alias_names:
|
||||
session.delete(alias)
|
||||
else:
|
||||
alias_ids.remove(alias.id)
|
||||
alias_names.remove(alias.name)
|
||||
# Update or Add aliases
|
||||
for alias in aliases:
|
||||
# Sanitize alias names
|
||||
alias.name = alias.name.strip()
|
||||
if not alias.name:
|
||||
continue
|
||||
|
||||
for alias_name in alias_names:
|
||||
alias = TagAlias(alias_name, tag.id)
|
||||
session.add(alias)
|
||||
try:
|
||||
if alias.id in old_alias_ids:
|
||||
stmt = (
|
||||
update(TagAlias).where(TagAlias.id == alias.id).values(name=alias.name)
|
||||
)
|
||||
session.execute(stmt)
|
||||
else:
|
||||
session.add(alias)
|
||||
except IntegrityError as e:
|
||||
session.rollback()
|
||||
logger.error("[Library] Could not update or add alias", error=e)
|
||||
return False
|
||||
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session: Session):
|
||||
if tag.id in parent_ids:
|
||||
|
||||
@@ -163,6 +163,7 @@ class Tag(Base):
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
@override
|
||||
def __eq__(self, value: object) -> bool:
|
||||
if not isinstance(value, Tag):
|
||||
return False
|
||||
|
||||
@@ -80,7 +80,7 @@ class DupeFilesRegistry:
|
||||
)
|
||||
|
||||
for i, entries in enumerate(self.groups):
|
||||
yield i
|
||||
remove_ids = entries[1:]
|
||||
logger.info("Removing entries group", ids=remove_ids)
|
||||
self.library.remove_entries([e.id for e in remove_ids])
|
||||
yield i - 1 # The -1 waits for the next step to finish
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -35,15 +33,14 @@ class IgnoredRegistry:
|
||||
logger.info("[IgnoredRegistry] Refreshing ignored entries...")
|
||||
|
||||
self.ignored_entries = []
|
||||
library_dir: Path = unwrap(self.lib.library_dir)
|
||||
|
||||
for i, entry in enumerate(self.lib.all_entries()):
|
||||
yield i
|
||||
if not Ignore.compiled_patterns:
|
||||
# If the compiled_patterns has malfunctioned, don't consider that a false positive
|
||||
yield i
|
||||
elif Ignore.compiled_patterns.match(library_dir / entry.path):
|
||||
elif Ignore.compiled_patterns.match(entry.path):
|
||||
self.ignored_entries.append(entry)
|
||||
yield i
|
||||
|
||||
def remove_ignored_entries(self) -> None:
|
||||
self.lib.remove_entries(list(map(lambda ignored: ignored.id, self.ignored_entries)))
|
||||
|
||||
@@ -38,10 +38,10 @@ class UnlinkedRegistry:
|
||||
|
||||
self.unlinked_entries = []
|
||||
for i, entry in enumerate(self.lib.all_entries()):
|
||||
yield i
|
||||
full_path = unwrap(self.lib.library_dir) / entry.path
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
self.unlinked_entries.append(entry)
|
||||
yield i
|
||||
|
||||
def match_unlinked_file_entry(self, match_entry: Entry) -> list[Path]:
|
||||
"""Try and match unlinked file entries with matching results in the library directory.
|
||||
@@ -72,6 +72,7 @@ class UnlinkedRegistry:
|
||||
self.files_fixed_count = 0
|
||||
matched_entries: list[Entry] = []
|
||||
for i, entry in enumerate(self.unlinked_entries):
|
||||
yield i
|
||||
item_matches = self.match_unlinked_file_entry(entry)
|
||||
if len(item_matches) == 1:
|
||||
logger.info(
|
||||
@@ -88,7 +89,6 @@ class UnlinkedRegistry:
|
||||
continue
|
||||
self.files_fixed_count += 1
|
||||
matched_entries.append(entry)
|
||||
yield i
|
||||
|
||||
for entry in matched_entries:
|
||||
self.unlinked_entries.remove(entry)
|
||||
|
||||
@@ -47,21 +47,21 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
|
||||
self.lib = lib
|
||||
|
||||
@override
|
||||
def visit_or_list(self, node: ORList) -> ColumnElement[bool]: # type: ignore
|
||||
def visit_or_list(self, node: ORList) -> ColumnElement[bool]:
|
||||
tag_ids, bool_expressions = self.__separate_tags(node.elements, only_single=False)
|
||||
if len(tag_ids) > 0:
|
||||
bool_expressions.append(self.__entry_has_any_tags(tag_ids))
|
||||
return or_(*bool_expressions)
|
||||
|
||||
@override
|
||||
def visit_and_list(self, node: ANDList) -> ColumnElement[bool]: # type: ignore
|
||||
def visit_and_list(self, node: ANDList) -> ColumnElement[bool]:
|
||||
tag_ids, bool_expressions = self.__separate_tags(node.terms, only_single=True)
|
||||
if len(tag_ids) > 0:
|
||||
bool_expressions.append(self.__entry_has_all_tags(tag_ids))
|
||||
return and_(*bool_expressions)
|
||||
|
||||
@override
|
||||
def visit_constraint(self, node: Constraint) -> ColumnElement[bool]: # type: ignore
|
||||
def visit_constraint(self, node: Constraint) -> ColumnElement[bool]:
|
||||
"""Returns a Boolean Expression that is true, if the Entry satisfies the constraint."""
|
||||
if len(node.properties) != 0:
|
||||
raise NotImplementedError("Properties are not implemented yet") # TODO TSQLANG
|
||||
@@ -113,11 +113,11 @@ class SQLBoolExpressionBuilder(BaseVisitor[ColumnElement[bool]]):
|
||||
raise NotImplementedError("This type of constraint is not implemented yet")
|
||||
|
||||
@override
|
||||
def visit_property(self, node: Property) -> ColumnElement[bool]: # type: ignore
|
||||
def visit_property(self, node: Property) -> ColumnElement[bool]:
|
||||
raise NotImplementedError("This should never be reached!")
|
||||
|
||||
@override
|
||||
def visit_not(self, node: Not) -> ColumnElement[bool]: # type: ignore
|
||||
def visit_not(self, node: Not) -> ColumnElement[bool]:
|
||||
return ~self.visit(node.child)
|
||||
|
||||
def __get_tag_ids(self, tag_name: str, include_children: bool = True) -> list[int]:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import enum
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
@@ -23,7 +23,7 @@ FILETYPE_EQUIVALENTS = [
|
||||
]
|
||||
|
||||
|
||||
class MediaType(str, Enum):
|
||||
class MediaType(enum.StrEnum):
|
||||
"""Names of media types."""
|
||||
|
||||
ADOBE_PHOTOSHOP = "adobe_photoshop"
|
||||
|
||||
@@ -24,7 +24,7 @@ class ConstraintType(Enum):
|
||||
"filetype": ConstraintType.FileType,
|
||||
"path": ConstraintType.Path,
|
||||
"special": ConstraintType.Special,
|
||||
}.get(text.lower(), None)
|
||||
}.get(text.lower())
|
||||
|
||||
|
||||
class AST:
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def unwrap(optional: T | None, default: T | None = None) -> T:
|
||||
def unwrap[T](optional: T | None, default: T | None = None) -> T:
|
||||
if optional is not None:
|
||||
return optional
|
||||
if default is not 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_IMAGE_QUALITY, DEFAULT_THUMB_CACHE_SIZE
|
||||
from tagstudio.qt.global_settings import DEFAULT_CACHED_THUMB_QUALITY, DEFAULT_THUMB_CACHE_SIZE
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -31,7 +31,7 @@ class CacheManager:
|
||||
self,
|
||||
library_dir: Path,
|
||||
max_size: int | float = DEFAULT_THUMB_CACHE_SIZE,
|
||||
img_quality: int = DEFAULT_CACHED_IMAGE_QUALITY,
|
||||
img_quality: int = DEFAULT_CACHED_THUMB_QUALITY,
|
||||
):
|
||||
"""A class for managing frontend caches, such as for file thumbnails.
|
||||
|
||||
@@ -47,7 +47,7 @@ class CacheManager:
|
||||
math.floor(CacheManager.MAX_FOLDER_SIZE * CacheManager.STAT_MULTIPLIER),
|
||||
)
|
||||
self.img_quality = (
|
||||
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_IMAGE_QUALITY
|
||||
img_quality if img_quality >= 0 and img_quality <= 100 else DEFAULT_CACHED_THUMB_QUALITY
|
||||
)
|
||||
|
||||
self.folders: list[CacheFolder] = []
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
from typing import Any, override
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
@@ -14,8 +14,8 @@ class ClickableLabel(QLabel):
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def __init__(self, *args: Any, **kwarg: Any): # pyright: ignore[reportExplicitAny]
|
||||
super().__init__(*args, **kwarg)
|
||||
|
||||
@override
|
||||
def mousePressEvent(self, ev: QMouseEvent):
|
||||
@@ -0,0 +1,115 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import (
|
||||
BaseFieldTemplate,
|
||||
DatetimeFieldTemplate,
|
||||
TextFieldTemplate,
|
||||
)
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.edit_field_template_modal_view import EditFieldTemplateModalView
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EditFieldTemplateModal(EditFieldTemplateModalView):
|
||||
field_type_map: dict[str, str] = {
|
||||
"TextFieldTemplate": Translations["field_type.text"],
|
||||
"DatetimeFieldTemplate": Translations["field_type.datetime"],
|
||||
}
|
||||
DEFAULT_TYPE_INDEX = 0
|
||||
|
||||
def __init__(self, field_template: BaseFieldTemplate | None = None) -> None:
|
||||
super().__init__()
|
||||
self.__field_id: int | None = field_template.id if field_template else None
|
||||
self.__field_name: str = ""
|
||||
self.__field_type: str | None = field_template.class_name if field_template else None
|
||||
self.old_field_type: str = ""
|
||||
|
||||
for k, v in EditFieldTemplateModal.field_type_map.items():
|
||||
self._type_combobox.addItem(v, k)
|
||||
|
||||
self.__connect_callbacks()
|
||||
self.set_field_template(field_template)
|
||||
self.__on_type_changed(EditFieldTemplateModal.DEFAULT_TYPE_INDEX)
|
||||
|
||||
def __connect_callbacks(self) -> None:
|
||||
self.name_field.textChanged.connect(self.__on_name_changed)
|
||||
self._type_combobox.currentIndexChanged.connect(self.__on_type_changed)
|
||||
|
||||
def set_field_template(self, field_template: BaseFieldTemplate | None = None) -> None:
|
||||
"""Populate the modal with pre-existing field template values, or fallback to defaults."""
|
||||
logger.info("[EditFieldTemplate] Setting Field Template", field_template=field_template)
|
||||
|
||||
# Indicates a new template, set default values
|
||||
if field_template is None:
|
||||
self.__field_name = Translations["field_template.new"]
|
||||
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[
|
||||
EditFieldTemplateModal.DEFAULT_TYPE_INDEX
|
||||
]
|
||||
return
|
||||
# Populate common values for any field type
|
||||
else:
|
||||
self.__field_name = field_template.name
|
||||
self.__field_type = field_template.class_name
|
||||
self.old_field_type = field_template.class_name # Only set on init
|
||||
|
||||
# Update widgets
|
||||
self.name_field.setText(self.__field_name)
|
||||
self._type_combobox.setCurrentIndex(
|
||||
list(EditFieldTemplateModal.field_type_map.keys()).index(field_template.class_name)
|
||||
)
|
||||
|
||||
# Populate values for specific field types
|
||||
if isinstance(field_template, TextFieldTemplate):
|
||||
self._multiline_checkbox.setChecked(field_template.is_multiline)
|
||||
|
||||
def __on_name_changed(self):
|
||||
is_empty = not self.name_field.text().strip()
|
||||
|
||||
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)
|
||||
|
||||
def __on_type_changed(self, index: int):
|
||||
old_type = self.__field_type
|
||||
self.__field_type = list(EditFieldTemplateModal.field_type_map.keys())[index]
|
||||
|
||||
if old_type == self.__field_type:
|
||||
logger.info(f"old type {old_type}, new type {self.__field_type}")
|
||||
return
|
||||
|
||||
if old_type == "TextFieldTemplate":
|
||||
self._text_field_attributes_widget.hide()
|
||||
# NOTE: Future options specific to other type will go here.
|
||||
|
||||
if self.__field_type == "TextFieldTemplate":
|
||||
self._text_field_attributes_widget.show()
|
||||
|
||||
def build_field_template(self) -> BaseFieldTemplate:
|
||||
if self.__field_type == "TextFieldTemplate":
|
||||
return TextFieldTemplate(
|
||||
id=self.__field_id,
|
||||
name=self.name_field.text(),
|
||||
is_multiline=self._multiline_checkbox.isChecked(),
|
||||
)
|
||||
elif self.__field_type == "DatetimeFieldTemplate":
|
||||
return DatetimeFieldTemplate(
|
||||
id=self.__field_id,
|
||||
name=self.name_field.text(),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[EditFieldTemplateModal] Unknown field, falling back to TextFieldTemplate",
|
||||
field_type=self.__field_type,
|
||||
example=TextFieldTemplate,
|
||||
)
|
||||
return TextFieldTemplate(
|
||||
name=self.name_field.text(),
|
||||
is_multiline=self._multiline_checkbox.isChecked(),
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
|
||||
from tagstudio.qt.views.edit_text_view import EditTextView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EditText(EditTextView):
|
||||
def __init__(self, name: str, text: str | None, is_multiline: bool = False):
|
||||
super().__init__()
|
||||
self.name_field.setText(name)
|
||||
|
||||
self.text = text
|
||||
self.is_multiline: bool = is_multiline
|
||||
|
||||
self.multiline_checkbox.setChecked(is_multiline)
|
||||
self.multiline_checkbox.clicked.connect(lambda checked: self.on_multiline_checked(checked))
|
||||
|
||||
if self.is_multiline:
|
||||
self.text_line.hide()
|
||||
self.text_line_stretch.hide()
|
||||
self.text_box.setPlainText(self.text or "")
|
||||
else:
|
||||
self.text_box.hide()
|
||||
self.text_line.setText(self.text or "")
|
||||
|
||||
def on_multiline_checked(self, checked: bool):
|
||||
was_multiline = self.is_multiline
|
||||
self.is_multiline = checked
|
||||
|
||||
if was_multiline:
|
||||
self.text = self.text_box.toPlainText()
|
||||
self.text_box.hide()
|
||||
self.text_line.setText(self.text)
|
||||
self.text_line.show()
|
||||
self.text_line_stretch.show()
|
||||
else:
|
||||
self.text = self.text_line.text()
|
||||
self.text_line.hide()
|
||||
self.text_line_stretch.hide()
|
||||
self.text_box.setPlainText(self.text)
|
||||
self.text_box.show()
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
if self.is_multiline:
|
||||
self.text_box.setFocus()
|
||||
else:
|
||||
self.text_line.setFocus()
|
||||
|
||||
@override
|
||||
def saved_data(self) -> dict[str, str | bool]:
|
||||
return {
|
||||
"name": self.name_field.text(),
|
||||
"value": self.text_box.toPlainText() if self.is_multiline else self.text_line.text(),
|
||||
"is_multiline": self.is_multiline,
|
||||
}
|
||||
|
||||
@override
|
||||
def reset(self):
|
||||
self.text_box.setPlainText(self.text or "")
|
||||
@@ -1,57 +0,0 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from shutil import which
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FfmpegMissingMessageBox(QMessageBox):
|
||||
"""A warning dialog for if FFmpeg is missing."""
|
||||
|
||||
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
ffmpeg = "FFmpeg"
|
||||
ffprobe = "FFprobe"
|
||||
title = Translations.format("dependency.missing.title", dependency=ffmpeg)
|
||||
self.setWindowTitle(title)
|
||||
self.setIcon(QMessageBox.Icon.Warning)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
self.setStandardButtons(
|
||||
QMessageBox.StandardButton.Help
|
||||
| QMessageBox.StandardButton.Ignore
|
||||
| QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.StandardButton.Cancel).hide()
|
||||
self.button(QMessageBox.StandardButton.Help).clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
)
|
||||
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
missing = f"<span style='color:{red}'>{Translations['generic.missing']}</span>"
|
||||
found = f"<span style='color:{green}'>{Translations['about.module.found']}</span>"
|
||||
status = Translations.format(
|
||||
"ffmpeg.missing.status",
|
||||
ffmpeg=ffmpeg,
|
||||
ffmpeg_status=found if which(FFMPEG_CMD) else missing,
|
||||
ffprobe=ffprobe,
|
||||
ffprobe_status=found if which(FFPROBE_CMD) else missing,
|
||||
)
|
||||
self.setText(f"{Translations['ffmpeg.missing.description']}<br><br>{status}")
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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.QtCore import Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
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
|
||||
|
||||
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,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
view=view or FieldTemplateSearchPanelView(is_field_template_chooser),
|
||||
exclude=[],
|
||||
is_chooser=is_field_template_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"
|
||||
|
||||
@override
|
||||
def _get_max_limit(self) -> int:
|
||||
return len(self.__lib.field_templates)
|
||||
|
||||
@override
|
||||
def on_item_create(self, add_to_entry: bool = False) -> None:
|
||||
"""Opens panel to create a new field template and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
query: str = self.get_search_query()
|
||||
logger.info("[FieldTemplateSearch] Create and Add Field Template", name=query)
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal()
|
||||
modal: PanelModal = PanelModal(
|
||||
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(panel, choose_item=add_to_entry))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def on_item_edit(self, item: BaseFieldTemplate) -> None:
|
||||
|
||||
panel: EditFieldTemplateModal = EditFieldTemplateModal(item)
|
||||
modal: PanelModal = PanelModal(
|
||||
panel,
|
||||
item.name,
|
||||
Translations["field_template.edit"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
modal.saved.connect(lambda: self.edit_item(panel))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_remove(self, item: BaseFieldTemplate) -> None:
|
||||
if self.is_chooser:
|
||||
return
|
||||
|
||||
message_box = QMessageBox(
|
||||
QMessageBox.Icon.Question,
|
||||
Translations["field_template.delete"],
|
||||
Translations.format("field_template.confirm_delete", field_template_name=item.name),
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
result = message_box.exec()
|
||||
|
||||
if result != QMessageBox.StandardButton.Ok:
|
||||
return
|
||||
|
||||
self.__lib.remove_field_template(item)
|
||||
self.update_items(self.get_search_query())
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: BaseFieldTemplate) -> None:
|
||||
self.field_template_chosen.emit(item)
|
||||
|
||||
@override
|
||||
def search_items(self, query: str) -> tuple[list[BaseFieldTemplate], list[BaseFieldTemplate]]:
|
||||
return self.__lib.search_field_templates(name=query, limit=self._get_limit()[1]), []
|
||||
|
||||
@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."""
|
||||
field_template_widget: FieldTemplateWidget = self.get_item_widget(index, self.__lib)
|
||||
field_template_widget.set_field_template(item)
|
||||
field_template_widget.setHidden(item is None)
|
||||
|
||||
if item is None:
|
||||
return
|
||||
|
||||
field_template_widget.has_remove = not self.is_chooser
|
||||
|
||||
# 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_remove.connect(lambda item_=item: self._on_item_remove(item_))
|
||||
field_template_widget.on_click.connect(
|
||||
lambda checked=False, item_=item: self._on_item_chosen(item_)
|
||||
)
|
||||
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
|
||||
if isinstance(edit_item_panel, EditFieldTemplateModal):
|
||||
template: BaseFieldTemplate = edit_item_panel.build_field_template()
|
||||
self.__lib.add_field_template(template)
|
||||
|
||||
if choose_item:
|
||||
self._on_item_chosen(template)
|
||||
self.clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> 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())
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
from typing import override
|
||||
|
||||
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.qt.views.field_template_widget_view import FieldTemplateWidgetView
|
||||
|
||||
|
||||
class FieldTemplateWidget(FieldTemplateWidgetView):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.__field_template: BaseFieldTemplate | None = None
|
||||
self.has_remove: bool = False
|
||||
|
||||
# Add actions
|
||||
edit_action = QAction(self)
|
||||
edit_action.setText(Translations["generic.edit"])
|
||||
edit_action.triggered.connect(self.on_edit.emit)
|
||||
self.addAction(edit_action)
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
|
||||
def set_field_template(self, field_template: BaseFieldTemplate | None) -> None:
|
||||
self.__field_template = field_template
|
||||
|
||||
if field_template is None:
|
||||
return
|
||||
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field_template.class_name, "field_type.unknown")
|
||||
self._bg_button.setText(f"{field_template.name} ({Translations[field_name_key]})")
|
||||
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
self._delete_button.setHidden(False)
|
||||
self.update()
|
||||
return super().enterEvent(event)
|
||||
|
||||
@override
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
if self.has_remove:
|
||||
self._delete_button.setHidden(True)
|
||||
self.update()
|
||||
return super().leaveEvent(event)
|
||||
@@ -13,6 +13,7 @@ from tagstudio.qt.mixed.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
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -78,7 +79,7 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
|
||||
count_text: str = Translations.format(
|
||||
"entries.ignored.ignored_count", count=count if count >= 0 else "—"
|
||||
)
|
||||
self.ignored_count_label.setText(f"<h3>{count_text}</h3>")
|
||||
self.ignored_count_label.setText(header(count_text, 3))
|
||||
|
||||
def update_driver_widgets(self):
|
||||
if (
|
||||
@@ -88,6 +89,6 @@ class FixIgnoredEntriesModal(FixIgnoredEntriesModalView):
|
||||
self.driver.library_info_window.update_cleanup()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
self.update_ignored_count()
|
||||
return super().showEvent(event)
|
||||
|
||||
@@ -46,6 +46,6 @@ class IgnoreModal(IgnoreModalView):
|
||||
Ignore.write_ignore_file(self.lib.library_dir, lines)
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QShowEvent) -> None: # type: ignore
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
self.__load_file()
|
||||
return super().showEvent(event)
|
||||
|
||||
@@ -22,6 +22,7 @@ from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.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
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -61,7 +62,7 @@ class LibraryInfoWindow(LibraryInfoWindowView):
|
||||
title: str = Translations.format(
|
||||
"library_info.title", library_dir=self.lib.library_dir.stem
|
||||
)
|
||||
self.title_label.setText(f"<h2>{title}</h2>")
|
||||
self.title_label.setText(header(title, 2))
|
||||
|
||||
def update_stats(self):
|
||||
self.entry_count_label.setText(f"<b>{self.lib.entries_count}</b>")
|
||||
@@ -162,6 +163,6 @@ class LibraryInfoWindow(LibraryInfoWindowView):
|
||||
return size
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent): # type: ignore
|
||||
def showEvent(self, event: QtGui.QShowEvent):
|
||||
self.refresh()
|
||||
return super().showEvent(event)
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.qt.controllers.paged_panel_state import PagedPanelState
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -89,7 +90,7 @@ class PagedPanel(QWidget):
|
||||
|
||||
# Update Title
|
||||
self.setWindowTitle(frame.title)
|
||||
self.title_label.setText(f"<h1>{frame.title}</h1>")
|
||||
self.title_label.setText(header(frame.title, 1))
|
||||
|
||||
# Update Body Widget
|
||||
if self.body_layout.itemAt(0):
|
||||
@@ -107,7 +108,7 @@ class PagedPanel(QWidget):
|
||||
if isinstance(item, QWidget):
|
||||
self.button_nav_layout.addWidget(item)
|
||||
item.setHidden(False)
|
||||
elif isinstance(item, int):
|
||||
elif isinstance(item, int): # pyright: ignore[reportUnnecessaryIsInstance]
|
||||
self.button_nav_layout.addStretch(item)
|
||||
|
||||
@override
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
|
||||
|
||||
import typing
|
||||
from shutil import which
|
||||
from warnings import catch_warnings
|
||||
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
from tagstudio.core.library.alchemy.fields import BaseFieldTemplate
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.mixed.add_field import AddFieldModal
|
||||
from tagstudio.qt.mixed.tag_search import TagSearchModal
|
||||
from tagstudio.qt.controllers.field_template_search_panel_controller import FieldTemplateSearchModal
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.previews.vendored.ffmpeg import FFMPEG_CMD, FFPROBE_CMD
|
||||
from tagstudio.qt.views.preview_panel_view import PreviewPanelView
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -17,35 +18,45 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
|
||||
class PreviewPanel(PreviewPanelView):
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__add_field_modal = AddFieldModal(self.lib)
|
||||
self.__add_field_modal = FieldTemplateSearchModal(self.lib, is_field_template_chooser=True)
|
||||
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
|
||||
self._thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning)
|
||||
|
||||
@typing.override
|
||||
def _add_field_button_callback(self):
|
||||
def _add_field_button_callback(self) -> None:
|
||||
self.__add_field_modal.show()
|
||||
|
||||
@typing.override
|
||||
def _add_tag_button_callback(self):
|
||||
def _add_tag_button_callback(self) -> None:
|
||||
self.__add_tag_modal.show()
|
||||
|
||||
@typing.override
|
||||
def _set_selection_callback(self):
|
||||
def _set_selection_callback(self) -> None:
|
||||
with catch_warnings(record=True):
|
||||
self.__add_field_modal.done.disconnect()
|
||||
self.__add_tag_modal.tsp.tag_chosen.disconnect()
|
||||
self.__add_field_modal.search_panel.field_template_chosen.disconnect()
|
||||
self.__add_tag_modal.tsp.item_chosen.disconnect()
|
||||
|
||||
self.__add_field_modal.done.connect(self._add_field_to_selected)
|
||||
self.__add_tag_modal.tsp.tag_chosen.connect(self._add_tag_to_selected)
|
||||
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, field_list: list[QListWidgetItem]):
|
||||
self._fields.add_field_to_selected(field_list)
|
||||
def _add_field_to_selected(self, template: BaseFieldTemplate) -> None:
|
||||
self._containers.add_field_to_selected(template)
|
||||
if len(self._selected) == 1:
|
||||
self._fields.update_from_entry(self._selected[0])
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _add_tag_to_selected(self, tag_id: int):
|
||||
self._fields.add_tags_to_selected(tag_id)
|
||||
def _add_tag_to_selected(self, tag_id: int) -> None:
|
||||
self._containers.add_tags_to_selected(tag_id)
|
||||
if len(self._selected) == 1:
|
||||
self._fields.update_from_entry(self._selected[0])
|
||||
self._containers.update_from_entry(self._selected[0])
|
||||
|
||||
def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None:
|
||||
if enable_warning and (not which(FFMPEG_CMD) or not which(FFPROBE_CMD)):
|
||||
self._ffmpeg_warning_widget.show()
|
||||
return
|
||||
|
||||
self._ffmpeg_warning_widget.hide()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
@@ -12,6 +12,10 @@ import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QSize
|
||||
from rawpy import (
|
||||
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
|
||||
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
@@ -50,8 +54,8 @@ class PreviewThumb(PreviewThumbView):
|
||||
stats.width = image.width
|
||||
stats.height = image.height
|
||||
except (
|
||||
rawpy.LibRawIOError,
|
||||
rawpy.LibRawFileUnsupportedError,
|
||||
LibRawIOError,
|
||||
LibRawFileUnsupportedError,
|
||||
FileNotFoundError,
|
||||
):
|
||||
pass
|
||||
@@ -144,18 +148,22 @@ class PreviewThumb(PreviewThumbView):
|
||||
self._display_image(filepath)
|
||||
return self.__get_image_stats(filepath)
|
||||
|
||||
@override
|
||||
def _open_file_action_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
)
|
||||
|
||||
@override
|
||||
def _open_explorer_action_callback(self):
|
||||
open_file(self.__current_file, file_manager=True)
|
||||
|
||||
@override
|
||||
def _delete_action_callback(self):
|
||||
if bool(self.__current_file):
|
||||
self.__driver.delete_files_callback(self.__current_file)
|
||||
|
||||
@override
|
||||
def _button_wrapper_callback(self):
|
||||
open_file(
|
||||
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
import structlog
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtGui import QShowEvent
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.search_panel_view import SearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
def _item_id(item: object) -> int:
|
||||
item_id: Any = getattr(item, "id") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_id, int):
|
||||
return item_id
|
||||
else:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
def _item_name(item: object) -> str:
|
||||
item_name: Any = getattr(item, "name") # noqa: B009 # pyright: ignore[reportExplicitAny]
|
||||
|
||||
if isinstance(item_name, str):
|
||||
return item_name
|
||||
else:
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
class SearchPanel[T](PanelWidget):
|
||||
item_chosen = Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
view: SearchPanelView,
|
||||
exclude: list[int] | None = None,
|
||||
is_chooser: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.view = view
|
||||
self.is_chooser = is_chooser
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.addWidget(self.view)
|
||||
self.view.connect_callbacks(self)
|
||||
self._driver: QtDriver | None = None
|
||||
self.exclude: list[int] = exclude or []
|
||||
|
||||
# Limits
|
||||
self._unlimited_limit_item_label: str = "All Items"
|
||||
self.__limit_items: list[tuple[str, int]] = [
|
||||
("25", 25),
|
||||
("50", 50),
|
||||
("100", 100),
|
||||
("250", 250),
|
||||
("500", 500),
|
||||
(self._unlimited_limit_item_label, -1),
|
||||
]
|
||||
self.__default_limit_index: int = 0 # 25 Limit (Default)
|
||||
self.__previous_limit_index: int = self.__default_limit_index
|
||||
|
||||
self.view.set_limit_items(self.__limit_items)
|
||||
self.view.set_limit_index(self.__default_limit_index)
|
||||
|
||||
# Items
|
||||
self._search_results: list[T] = []
|
||||
|
||||
self._create_and_add_button_label_key: str = ""
|
||||
|
||||
@property
|
||||
def search_field(self):
|
||||
return self.view.search_field
|
||||
|
||||
@property
|
||||
def create_and_add_button(self):
|
||||
return self.view.create_and_add_button
|
||||
|
||||
def get_search_query(self) -> str:
|
||||
return self.view.get_search_query()
|
||||
|
||||
def clear_search_query(self) -> None:
|
||||
self.view.clear_search_query()
|
||||
|
||||
def get_item_widget(self, index: int, library: Library):
|
||||
return self.view.get_item_widget(index, library)
|
||||
|
||||
def set_driver(self, driver: "QtDriver") -> None:
|
||||
self._driver = driver
|
||||
|
||||
def on_limit_changed(self, index: int) -> None:
|
||||
# Method was called outside the limit_combobox callback
|
||||
if index != self.view.get_limit_index():
|
||||
self.view.set_limit_index(index)
|
||||
|
||||
if self.__previous_limit_index == index:
|
||||
return
|
||||
|
||||
self.update_items(self.search_field.text())
|
||||
|
||||
def _get_limit(self) -> tuple[str, int]:
|
||||
return self.__limit_items[self.view.get_limit_index()]
|
||||
|
||||
def _get_previous_limit(self) -> tuple[str, int]:
|
||||
return self.__limit_items[self.__previous_limit_index]
|
||||
|
||||
def _get_max_limit(self) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
def on_search_query_changed(self, query: str) -> None:
|
||||
self.create_and_add_button.setText(
|
||||
Translations.format(self._create_and_add_button_label_key, query=query)
|
||||
)
|
||||
self.update_items(query)
|
||||
|
||||
def on_search_query_submitted(self, query: str) -> None:
|
||||
# Focus search field if no query
|
||||
if not query:
|
||||
self.search_field.setFocus()
|
||||
parent: QWidget | None = self.parentWidget()
|
||||
if parent is not None: # pyright: ignore[reportUnnecessaryComparison]
|
||||
parent.hide()
|
||||
return
|
||||
|
||||
# Create and add item if no search results
|
||||
if len(self._search_results) <= 0:
|
||||
self.on_item_create(add_to_entry=True)
|
||||
elif self.is_chooser:
|
||||
self._on_item_chosen(self._search_results[0])
|
||||
|
||||
self.clear_search_query()
|
||||
self.update_items()
|
||||
|
||||
def on_item_create(self, add_to_entry: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def on_item_edit(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_remove(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _on_item_chosen(self, item: T) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def _is_excluded(self, item: T) -> bool:
|
||||
return _item_id(item) in self.exclude
|
||||
|
||||
def update_items(self, query: str | None = None) -> None:
|
||||
"""Update the item list given a search query."""
|
||||
logger.info("[SearchPanel] Updating items", limit=self._get_limit()[1])
|
||||
|
||||
# Remove the "Create & Add" button if one exists
|
||||
self.view.remove_create_and_add_button()
|
||||
|
||||
# Get results for the search query
|
||||
query_lower = "" if not query else query.lower()
|
||||
search_results: tuple[list[T], list[T]] = self.search_items(query_lower)
|
||||
|
||||
# Sort and prioritize the results
|
||||
direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
|
||||
direct_results.sort(key=lambda item: _item_name(item).lower())
|
||||
|
||||
ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
|
||||
ancestor_results.sort(key=lambda item: _item_name(item).lower())
|
||||
|
||||
raw_results = list(direct_results + ancestor_results)
|
||||
priority_results: set[T] = set()
|
||||
|
||||
if query and query.strip():
|
||||
for raw_item in raw_results:
|
||||
if _item_name(raw_item).lower().startswith(query_lower):
|
||||
priority_results.add(raw_item)
|
||||
|
||||
all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
|
||||
item for item in raw_results if item not in priority_results
|
||||
]
|
||||
if self._get_limit()[1] > 0:
|
||||
all_results = all_results[: self._get_limit()[1]]
|
||||
|
||||
self._search_results = all_results
|
||||
logger.info("[SearchPanel] Search results", results=self._search_results)
|
||||
|
||||
# Update every item widget with the new search result data
|
||||
previous_limit: int = (
|
||||
self._get_previous_limit()[1] > 0 and self._get_previous_limit()[1]
|
||||
) or self._get_max_limit()
|
||||
current_limit: int = (
|
||||
self._get_limit()[1] > 0 and self._get_limit()[1]
|
||||
) or self._get_max_limit()
|
||||
|
||||
for i in range(0, max(previous_limit, current_limit)):
|
||||
item: T | None = all_results[i] if i < len(all_results) else None
|
||||
self.set_item_widget(item=item, index=i)
|
||||
|
||||
self.__previous_limit_index = self.view.get_limit_index()
|
||||
|
||||
# Add back the "Create & Add" button
|
||||
if query and query.strip():
|
||||
self.view.add_create_and_add_button()
|
||||
|
||||
def search_items(self, query: str) -> tuple[list[T], list[T]]: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_item_widget(self, item: T | None, index: int) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa N802
|
||||
self.update_items()
|
||||
self.view.scroll_to(0)
|
||||
self.view.clear_search_query()
|
||||
return super().showEvent(event)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
# When Escape is pressed, focus back on the search box.
|
||||
# If focus is already on the search box, close the modal.
|
||||
if event.key() == QtCore.Qt.Key.Key_Escape:
|
||||
if self.search_field.hasFocus():
|
||||
super().keyPressEvent(event)
|
||||
else:
|
||||
self.view.focus_search_box(select_all=True)
|
||||
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None: # pyright: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError()
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import structlog
|
||||
@@ -34,7 +35,7 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
self.__entries = entries
|
||||
|
||||
@override
|
||||
def _on_click(self, tag: Tag) -> None: # type: ignore[misc]
|
||||
def _on_click(self, tag: Tag) -> None:
|
||||
match self.__driver.settings.tag_click_action:
|
||||
case TagClickActionOption.OPEN_EDIT:
|
||||
self._on_edit(tag)
|
||||
@@ -58,7 +59,7 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
)
|
||||
|
||||
@override
|
||||
def _on_remove(self, tag: Tag) -> None: # type: ignore[misc]
|
||||
def _on_remove(self, tag: Tag) -> None:
|
||||
logger.info(
|
||||
"[TagBoxWidget] remove_tag",
|
||||
selected=self.__entries,
|
||||
@@ -70,29 +71,28 @@ class TagBoxWidget(TagBoxWidgetView):
|
||||
self.on_update.emit()
|
||||
|
||||
@override
|
||||
def _on_edit(self, tag: Tag) -> None: # type: ignore[misc]
|
||||
def _on_edit(self, tag: Tag) -> None:
|
||||
build_tag_panel = BuildTagPanel(self.__driver.lib, tag=tag)
|
||||
|
||||
edit_modal = PanelModal(
|
||||
build_tag_panel,
|
||||
self.__driver.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
done_callback=self.on_update.emit,
|
||||
has_save=True,
|
||||
)
|
||||
# TODO - this was update_tag()
|
||||
edit_modal.saved.connect(
|
||||
lambda: self.__driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
alias_names=set(build_tag_panel.alias_names),
|
||||
alias_ids=set(build_tag_panel.alias_ids),
|
||||
)
|
||||
is_savable=True,
|
||||
)
|
||||
edit_modal.saved.connect(partial(self._update_tag_callback, build_tag_panel))
|
||||
edit_modal.show()
|
||||
|
||||
def _update_tag_callback(self, build_tag_panel: BuildTagPanel):
|
||||
self.__driver.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
)
|
||||
self.on_update.emit()
|
||||
|
||||
@override
|
||||
def _on_search(self, tag: Tag) -> None: # type: ignore[misc]
|
||||
def _on_search(self, tag: Tag) -> None:
|
||||
self.__driver.main_window.search_field.setText(f"tag_id:{tag.id}")
|
||||
self.__driver.update_browsing_state(
|
||||
BrowsingState.from_tag_id(tag.id, self.__driver.browsing_history.current)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPDX-FileCopyrightText: (c) TagStudio Contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.core.constants import RESERVED_TAG_END, RESERVED_TAG_START
|
||||
from tagstudio.core.library.alchemy.enums import BrowsingState
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag
|
||||
from tagstudio.qt.controllers.search_panel_controller import SearchPanel
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.tag_search_panel_view import TagSearchPanelView
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class TagSearchModal(PanelModal):
|
||||
tsp: "TagSearchPanel"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
exclude: list[int] | None = None,
|
||||
is_tag_chooser: bool = True,
|
||||
has_save: bool = False,
|
||||
):
|
||||
self.tsp = TagSearchPanel(
|
||||
library,
|
||||
exclude,
|
||||
is_tag_chooser,
|
||||
view=TagSearchPanelView(is_tag_chooser),
|
||||
)
|
||||
super().__init__(
|
||||
self.tsp,
|
||||
Translations["tag.add.plural"],
|
||||
is_savable=has_save,
|
||||
)
|
||||
|
||||
|
||||
class TagSearchPanel(SearchPanel[Tag]):
|
||||
def __init__(
|
||||
self,
|
||||
library: Library,
|
||||
exclude: list[int] | None = None,
|
||||
is_tag_chooser: bool = True,
|
||||
view: TagSearchPanelView | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
view=view or TagSearchPanelView(is_tag_chooser),
|
||||
exclude=exclude,
|
||||
is_chooser=is_tag_chooser,
|
||||
)
|
||||
self.__lib = library
|
||||
|
||||
self._unlimited_limit_item_label = Translations["tag.all_tags"]
|
||||
self._create_and_add_button_label_key = "tag.create_add"
|
||||
|
||||
@override
|
||||
def _get_max_limit(self) -> int:
|
||||
return len(self.__lib.tags)
|
||||
|
||||
@override
|
||||
def on_item_create(self, add_to_entry: bool = False) -> None:
|
||||
"""Opens panel to create a new tag and optionally add it to an entry.
|
||||
|
||||
Populates name field using current search query.
|
||||
|
||||
Args:
|
||||
add_to_entry (bool): Should this item be added to currently selected entries?
|
||||
"""
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
query: str = self.get_search_query()
|
||||
|
||||
panel: BuildTagPanel = BuildTagPanel(self.__lib)
|
||||
modal: PanelModal = PanelModal(
|
||||
panel,
|
||||
Translations["tag.new"],
|
||||
Translations["tag.add"] if add_to_entry else Translations["tag.new"],
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
if query.strip():
|
||||
panel.name_field.setText(query)
|
||||
|
||||
modal.saved.connect(lambda: self.create_item(panel, choose_item=add_to_entry))
|
||||
modal.show()
|
||||
|
||||
@override
|
||||
def on_item_edit(self, item: Tag) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
edit_tag_panel: BuildTagPanel = BuildTagPanel(self.__lib, tag=item)
|
||||
edit_tag_modal: PanelModal = PanelModal(
|
||||
edit_tag_panel,
|
||||
self.__lib.tag_display_name(item),
|
||||
Translations["tag.edit"],
|
||||
is_savable=True,
|
||||
)
|
||||
edit_tag_modal.saved.connect(lambda: self.edit_item(edit_tag_panel))
|
||||
edit_tag_modal.show()
|
||||
|
||||
@override
|
||||
def _on_item_remove(self, item: Tag) -> None:
|
||||
if self.is_chooser:
|
||||
return
|
||||
|
||||
if item.id in range(RESERVED_TAG_START, RESERVED_TAG_END):
|
||||
return
|
||||
|
||||
message_box = QMessageBox(
|
||||
QMessageBox.Icon.Question,
|
||||
Translations["tag.remove"],
|
||||
Translations.format("tag.confirm_delete", tag_name=self.__lib.tag_display_name(item)),
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
result = message_box.exec()
|
||||
|
||||
if result != QMessageBox.StandardButton.Ok:
|
||||
return
|
||||
|
||||
self.__lib.remove_tag(item.id)
|
||||
self.update_items(self.get_search_query())
|
||||
|
||||
@override
|
||||
def _on_item_chosen(self, item: Tag) -> None:
|
||||
self.item_chosen.emit(item.id)
|
||||
|
||||
@override
|
||||
def search_items(self, query: str) -> tuple[list[Tag], list[Tag]]:
|
||||
return self.__lib.search_tags(name=query, limit=self._get_limit()[1])
|
||||
|
||||
@override
|
||||
def set_item_widget(self, item: Tag | None, index: int) -> None:
|
||||
"""Set the tag of a tag widget at a specific index."""
|
||||
tag_widget: TagWidget = self.get_item_widget(index, self.__lib)
|
||||
tag_widget.set_tag(item)
|
||||
tag_widget.setHidden(item is None)
|
||||
|
||||
if item is None:
|
||||
return
|
||||
assert item is not None
|
||||
|
||||
tag_widget.has_remove = not self.is_chooser and item.id not in range(
|
||||
RESERVED_TAG_START, RESERVED_TAG_END
|
||||
)
|
||||
|
||||
# Disconnect previous callbacks
|
||||
with catch_warnings(record=True):
|
||||
tag_widget.on_edit.disconnect()
|
||||
tag_widget.on_remove.disconnect()
|
||||
tag_widget.bg_button.clicked.disconnect()
|
||||
tag_widget.search_for_tag_action.triggered.disconnect()
|
||||
|
||||
# Connect callbacks
|
||||
tag_widget.on_edit.connect(lambda edit_tag=item: self.on_item_edit(edit_tag))
|
||||
tag_widget.on_remove.connect(lambda remove_tag=item: self._on_item_remove(remove_tag))
|
||||
tag_widget.bg_button.clicked.connect(
|
||||
lambda checked=False, tag=item: self._on_item_chosen(tag)
|
||||
)
|
||||
|
||||
# Connect search action
|
||||
if self._driver is not None:
|
||||
tag_widget.search_for_tag_action.triggered.connect(
|
||||
lambda tag_id=item.id: self.search_for_tag(tag_id)
|
||||
)
|
||||
tag_widget.search_for_tag_action.setEnabled(True)
|
||||
else:
|
||||
tag_widget.search_for_tag_action.setEnabled(False)
|
||||
|
||||
@override
|
||||
def create_item(self, edit_item_panel: PanelWidget, choose_item: bool = False) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
if isinstance(edit_item_panel, BuildTagPanel):
|
||||
tag: Tag = edit_item_panel.build_tag()
|
||||
self.__lib.add_tag(
|
||||
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
|
||||
)
|
||||
|
||||
if choose_item:
|
||||
self._on_item_chosen(tag)
|
||||
self.clear_search_query()
|
||||
|
||||
edit_item_panel.hide()
|
||||
self.on_search_query_changed(self.get_search_query())
|
||||
|
||||
@override
|
||||
def edit_item(self, edit_item_panel: PanelWidget) -> None:
|
||||
# TODO: Move this to a top-level import
|
||||
from tagstudio.qt.mixed.build_tag import BuildTagPanel # here due to circular imports
|
||||
|
||||
if not isinstance(edit_item_panel, BuildTagPanel):
|
||||
return
|
||||
|
||||
self.__lib.update_tag(
|
||||
tag=edit_item_panel.build_tag(),
|
||||
parent_ids=edit_item_panel.parent_ids,
|
||||
aliases=edit_item_panel.aliases,
|
||||
)
|
||||
self.update_items(self.search_field.text())
|
||||
|
||||
def search_for_tag(self, tag_id: int) -> None:
|
||||
if self._driver is None:
|
||||
return
|
||||
|
||||
self._driver.main_window.search_field.setText(f"tag_id:{tag_id}")
|
||||
self._driver.update_browsing_state(
|
||||
BrowsingState.from_tag_id(tag_id, self._driver.browsing_history.current)
|
||||
)
|
||||
@@ -2,36 +2,57 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from functools import partial
|
||||
|
||||
import structlog
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QDesktopServices, QPixmap
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from tagstudio.core.constants import GITHUB_RELEASE_URL, VERSION
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OutOfDateMessageBox(QMessageBox):
|
||||
class UpdateAvailableMessageBox(QMessageBox):
|
||||
"""A warning dialog for if the TagStudio is not running under the latest release version."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
title = Translations.format("version_modal.title")
|
||||
rm = ResourceManager()
|
||||
title = Translations["version_modal.title"]
|
||||
self.setWindowTitle(title)
|
||||
self.setIcon(QMessageBox.Icon.Warning)
|
||||
pixel_ratio = self.devicePixelRatio()
|
||||
icon = QPixmap.fromImage(ImageQt.ImageQt(rm.icon)).scaled(
|
||||
math.floor(48 * pixel_ratio),
|
||||
math.floor(48 * pixel_ratio),
|
||||
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
icon.setDevicePixelRatio(pixel_ratio)
|
||||
self.setIconPixmap(icon)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setStyleSheet("QPushButton {padding: 3px 8px;}")
|
||||
|
||||
self.setStandardButtons(
|
||||
QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel
|
||||
QMessageBox.StandardButton.Close
|
||||
| QMessageBox.StandardButton.Ignore
|
||||
| QMessageBox.StandardButton.Ok
|
||||
)
|
||||
self.setDefaultButton(QMessageBox.StandardButton.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.StandardButton.Cancel).hide()
|
||||
self.setDefaultButton(QMessageBox.StandardButton.Ok)
|
||||
self.button(QMessageBox.StandardButton.Ok).setText(Translations["update.view_update"])
|
||||
self.button(QMessageBox.StandardButton.Ok).clicked.connect(
|
||||
partial(QDesktopServices.openUrl, GITHUB_RELEASE_URL)
|
||||
)
|
||||
self.button(QMessageBox.StandardButton.Ignore).setText(Translations["generic.dont_remind"])
|
||||
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
@@ -26,8 +26,10 @@ DEFAULT_THUMB_CACHE_SIZE = 500 # Number in MiB
|
||||
MIN_THUMB_CACHE_SIZE = 10 # Number in MiB
|
||||
|
||||
# See: https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp-saving
|
||||
DEFAULT_CACHED_IMAGE_QUALITY = 80
|
||||
DEFAULT_CACHED_IMAGE_RES = 256
|
||||
DEFAULT_CACHED_THUMB_QUALITY = 80 # WebP Compression Quality
|
||||
MIN_CACHED_THUMB_RES = 32 # Pixels
|
||||
MAX_CACHED_THUMB_RES = 1024 # Pixels
|
||||
DEFAULT_CACHED_THUMB_RES = 256 # Pixels
|
||||
|
||||
|
||||
class Theme(IntEnum):
|
||||
@@ -43,6 +45,7 @@ class Splash(StrEnum):
|
||||
CLASSIC = "classic"
|
||||
GOO_GEARS = "goo_gears"
|
||||
NINETY_FIVE = "95"
|
||||
AURORA = "aurora"
|
||||
|
||||
|
||||
class TomlEnumEncoder(toml.TomlEncoder):
|
||||
@@ -61,8 +64,8 @@ class GlobalSettings(BaseModel):
|
||||
open_last_loaded_on_startup: bool = Field(default=True)
|
||||
generate_thumbs: bool = Field(default=True)
|
||||
thumb_cache_size: float = Field(default=DEFAULT_THUMB_CACHE_SIZE)
|
||||
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_IMAGE_QUALITY)
|
||||
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_IMAGE_RES)
|
||||
cached_thumb_quality: int = Field(default=DEFAULT_CACHED_THUMB_QUALITY)
|
||||
cached_thumb_resolution: int = Field(default=DEFAULT_CACHED_THUMB_RES)
|
||||
autoplay: bool = Field(default=True)
|
||||
scan_files_on_open: bool = Field(default=True)
|
||||
loop: bool = Field(default=True)
|
||||
|
||||
@@ -16,11 +16,14 @@ _THEME_DARK_BG: str = "#000000DD"
|
||||
_THEME_LIGHT_BG: str = "#FFFFFF55"
|
||||
|
||||
|
||||
def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
"""Overlay the foreground theme color onto an image.
|
||||
def auto_theme_overlay(
|
||||
image: Image.Image, inverse: bool = False, use_alpha: bool = True
|
||||
) -> Image.Image:
|
||||
"""Overlay the current foreground theme color onto an image.
|
||||
|
||||
Args:
|
||||
image (Image): The PIL Image object to apply an overlay to.
|
||||
inverse (bool): Option inverse the overlay color relative to the current theme.
|
||||
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
|
||||
"""
|
||||
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
|
||||
@@ -29,11 +32,37 @@ def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
overlay_color = (
|
||||
dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg
|
||||
)
|
||||
if inverse:
|
||||
overlay_color = light_fg if overlay_color == dark_fg else dark_fg
|
||||
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
def light_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
"""Overlay the light theme foreground color onto an image.
|
||||
|
||||
Args:
|
||||
image (Image): The PIL Image object to apply an overlay to.
|
||||
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
|
||||
"""
|
||||
overlay_color: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
def dark_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
"""Overlay the dark theme foreground color onto an image.
|
||||
|
||||
Args:
|
||||
image (Image): The PIL Image object to apply an overlay to.
|
||||
use_alpha (bool): Option to retain the base image's alpha value when applying the overlay.
|
||||
"""
|
||||
overlay_color: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
def gradient_overlay(image: Image.Image, gradient: list[str]) -> Image.Image:
|
||||
"""Overlay a color gradient onto an image.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def wrap_line(
|
||||
text: str,
|
||||
font: ImageFont.ImageFont,
|
||||
font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
|
||||
width: int = 256,
|
||||
draw: ImageDraw.ImageDraw | None = None,
|
||||
) -> int:
|
||||
@@ -31,7 +31,7 @@ def wrap_line(
|
||||
|
||||
def wrap_full_text(
|
||||
text: str,
|
||||
font: ImageFont.ImageFont,
|
||||
font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
|
||||
width: int = 256,
|
||||
draw: ImageDraw.ImageDraw | None = None,
|
||||
) -> str:
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication, QPixmap
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QPalette, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
@@ -17,40 +19,45 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.constants import VERSION, VERSION_BRANCH
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.constants import (
|
||||
COPYRIGHT,
|
||||
DISCORD_URL,
|
||||
DOCS_URL,
|
||||
GITHUB_REPO_URL,
|
||||
VERSION,
|
||||
VERSION_BRANCH,
|
||||
)
|
||||
from tagstudio.core.ts_core import TagStudioCore
|
||||
from tagstudio.core.utils.str_formatting import is_version_outdated
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.clickable_label import ClickableLabel
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.previews.vendored import ffmpeg
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.file_opener import open_file
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import form_content_style
|
||||
|
||||
|
||||
class AboutModal(QWidget):
|
||||
def __init__(self, config_path):
|
||||
"""Modal window showing information about the TagStudio application."""
|
||||
|
||||
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
|
||||
|
||||
def __init__(self, config_path: Path | str):
|
||||
super().__init__()
|
||||
self.setWindowTitle(Translations["about.title"])
|
||||
|
||||
self.rm: ResourceManager = ResourceManager()
|
||||
|
||||
# TODO: There should be a global button theme somewhere.
|
||||
self.form_content_style = (
|
||||
f"background-color:{
|
||||
Theme.COLOR_BG.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
};"
|
||||
"border-radius:3px;"
|
||||
"font-weight: 500;"
|
||||
"padding: 2px;"
|
||||
)
|
||||
pixel_ratio = self.devicePixelRatio()
|
||||
self.setStyleSheet("QLabel {color: white}")
|
||||
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(360, 540)
|
||||
self.setMaximumSize(600, 600)
|
||||
self.setFixedWidth(600)
|
||||
self.setMinimumHeight(600)
|
||||
self.setMaximumHeight(900)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(0, 12, 0, 0)
|
||||
self.root_layout.setContentsMargins(0, 100, 0, 0)
|
||||
self.root_layout.setSpacing(0)
|
||||
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
@@ -59,24 +66,29 @@ class AboutModal(QWidget):
|
||||
self.content_layout.setContentsMargins(12, 12, 12, 12)
|
||||
self.content_layout.setSpacing(12)
|
||||
|
||||
# TagStudio Icon Logo --------------------------------------------------
|
||||
# TagStudio Logo -------------------------------------------------------
|
||||
self.logo_widget = QLabel()
|
||||
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.get("icon")))
|
||||
self.logo_pixmap.setDevicePixelRatio(self.devicePixelRatio())
|
||||
self.logo_pixmap = QPixmap.fromImage(ImageQt.ImageQt(self.rm.ts_logo_text_color))
|
||||
self.logo_pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
self.logo_pixmap = self.logo_pixmap.scaledToWidth(
|
||||
math.floor(128 * self.devicePixelRatio()), Qt.TransformationMode.SmoothTransformation
|
||||
math.floor(384 * self.devicePixelRatio()), Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.logo_widget.setPixmap(self.logo_pixmap)
|
||||
self.logo_widget.setContentsMargins(0, 0, 0, 0)
|
||||
self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Title ----------------------------------------------------------------
|
||||
branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
|
||||
self.title_label = QLabel(f"<h2>TagStudio Alpha {VERSION}{branch}</h2>")
|
||||
self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# Version --------------------------------------------------------------
|
||||
self.version_label = QLabel(f"<h3>{AboutModal.VERSION_STR}</h3>")
|
||||
self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Copyright ------------------------------------------------------------
|
||||
self.copyright_label = QLabel(COPYRIGHT)
|
||||
self.copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.copyright_label.setStyleSheet("QLabel {color: #809782ff}")
|
||||
|
||||
# Description ----------------------------------------------------------
|
||||
self.desc_label = QLabel(Translations["about.description"])
|
||||
self.desc_label.setMaximumWidth(500)
|
||||
self.desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.desc_label.setWordWrap(True)
|
||||
self.desc_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
@@ -85,6 +97,7 @@ class AboutModal(QWidget):
|
||||
ff_version = ffmpeg.version()
|
||||
red = get_ui_color(ColorType.PRIMARY, UiColor.RED)
|
||||
green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN)
|
||||
amber = get_ui_color(ColorType.PRIMARY, UiColor.AMBER)
|
||||
missing = Translations["generic.missing"]
|
||||
found = Translations["about.module.found"]
|
||||
|
||||
@@ -100,61 +113,91 @@ class AboutModal(QWidget):
|
||||
f'<span style="color:{green}">{found}</span> (' + ff_version["ffprobe"] + ")"
|
||||
)
|
||||
|
||||
ripgrep_status = f'<span style="color:{amber}">{missing}</span>'
|
||||
if which("rg") is not None:
|
||||
ripgrep_status = f'<span style="color:{green}">{found}</span>'
|
||||
|
||||
self.system_info_widget = QWidget()
|
||||
self.system_info_layout = QFormLayout(self.system_info_widget)
|
||||
self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
# Version
|
||||
version_title = QLabel("Version")
|
||||
most_recent_release = unwrap(TagStudioCore.get_most_recent_release_version(), "UNKNOWN")
|
||||
version_content_style = self.form_content_style
|
||||
if most_recent_release == VERSION:
|
||||
version_title = QLabel(Translations["about.version"])
|
||||
latest_version = unwrap(TagStudioCore.get_most_recent_release_version(), "0.0.0")
|
||||
version_content_style = form_content_style()
|
||||
if not is_version_outdated(VERSION, latest_version):
|
||||
version_content = QLabel(f"{VERSION}")
|
||||
else:
|
||||
version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})")
|
||||
version_content_style += "color: #d9534f;"
|
||||
version_content = QLabel(
|
||||
Translations.format(
|
||||
"about.version.latest", built_version=VERSION, latest_version=latest_version
|
||||
)
|
||||
)
|
||||
version_content_style += f"color: {red};"
|
||||
version_content.setStyleSheet(version_content_style)
|
||||
version_content.setMaximumWidth(version_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(version_title, version_content)
|
||||
|
||||
# License
|
||||
license_title = QLabel(f"{Translations['about.license']}")
|
||||
license_content = QLabel("GPLv3")
|
||||
license_content.setStyleSheet(self.form_content_style)
|
||||
license_content.setMaximumWidth(license_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(license_title, license_content)
|
||||
version_content.setMaximumWidth(version_content.sizeHint().width())
|
||||
|
||||
# Config Path
|
||||
config_path_title = QLabel(f"{Translations['about.config_path']}")
|
||||
config_path_content = QLabel(f"{config_path}")
|
||||
config_path_content.setStyleSheet(self.form_content_style)
|
||||
config_path_content = ClickableLabel(f"{config_path}")
|
||||
config_path_content.clicked.connect(lambda: open_file(config_path, file_manager=True))
|
||||
config_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
config_path_content.setWordWrap(True)
|
||||
config_path_content.setStyleSheet(form_content_style())
|
||||
self.system_info_layout.addRow(config_path_title, config_path_content)
|
||||
|
||||
# TODO: Add row for "App Cache Path" (currently that TagStudio.ini file)
|
||||
|
||||
# FFmpeg Status
|
||||
ffmpeg_path_title = QLabel("FFmpeg")
|
||||
ffmpeg_path_content = QLabel(f"{ffmpeg_status}")
|
||||
ffmpeg_path_content.setStyleSheet(self.form_content_style)
|
||||
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
|
||||
ffmpeg_path_content = ClickableLabel(f"{ffmpeg_status}")
|
||||
ffmpeg_location = which(ffmpeg._get_ffmpeg_location()) # pyright: ignore[reportPrivateUsage]
|
||||
if ffmpeg_location:
|
||||
ffmpeg_path_content.clicked.connect(
|
||||
lambda: open_file(ffmpeg_location, file_manager=True)
|
||||
)
|
||||
ffmpeg_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ffmpeg_path_content.setStyleSheet(form_content_style())
|
||||
self.system_info_layout.addRow(ffmpeg_path_title, ffmpeg_path_content)
|
||||
ffmpeg_path_content.setMaximumWidth(ffmpeg_path_content.sizeHint().width())
|
||||
|
||||
# FFprobe Status
|
||||
ffprobe_path_title = QLabel("FFprobe")
|
||||
ffprobe_path_content = QLabel(f"{ffprobe_status}")
|
||||
ffprobe_path_content.setStyleSheet(self.form_content_style)
|
||||
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
|
||||
ffprobe_path_content = ClickableLabel(f"{ffprobe_status}")
|
||||
ffprobe_location = which(ffmpeg._get_ffprobe_location()) # pyright: ignore[reportPrivateUsage]
|
||||
if ffprobe_location:
|
||||
ffprobe_path_content.clicked.connect(
|
||||
lambda: open_file(ffprobe_location, file_manager=True)
|
||||
)
|
||||
ffprobe_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ffprobe_path_content.setStyleSheet(form_content_style())
|
||||
self.system_info_layout.addRow(ffprobe_path_title, ffprobe_path_content)
|
||||
ffprobe_path_content.setMaximumWidth(ffprobe_path_content.sizeHint().width())
|
||||
|
||||
# ripgrep Status
|
||||
# TODO: Add a central class to find ripgrep info, similar to ffmpeg
|
||||
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
|
||||
ripgrep_path_content = ClickableLabel()
|
||||
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
|
||||
ripgrep_location = which("rg")
|
||||
if ripgrep_location:
|
||||
ripgrep_path_content.clicked.connect(
|
||||
lambda: open_file(ripgrep_location, file_manager=True)
|
||||
)
|
||||
ripgrep_path_content.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
ripgrep_path_content.setStyleSheet(form_content_style())
|
||||
ripgrep_path_content.setMaximumWidth(ripgrep_path_content.sizeHint().width())
|
||||
self.system_info_layout.addRow(ripgrep_path_title, ripgrep_path_content)
|
||||
|
||||
# Links ----------------------------------------------------------------
|
||||
repo_link = "https://github.com/TagStudioDev/TagStudio"
|
||||
docs_link = "https://docs.tagstud.io"
|
||||
discord_link = "https://discord.com/invite/hRNnVKhF2G"
|
||||
|
||||
self.links_label = QLabel(
|
||||
f'<p><a href="{repo_link}">GitHub</a> | '
|
||||
f'<a href="{docs_link}">{Translations["about.documentation"]}</a> | '
|
||||
f'<a href="{discord_link}">Discord</a></p>'
|
||||
f'<p><a href="{GITHUB_REPO_URL}">GitHub</a> | '
|
||||
f'<a href="{DOCS_URL}">{Translations["about.documentation"]}</a> | '
|
||||
f'<a href="{DISCORD_URL}">Discord</a></p>'
|
||||
)
|
||||
self.links_label.setStyleSheet("QLabel {color: #809782ff}")
|
||||
self.links_label.setWordWrap(True)
|
||||
self.links_label.setOpenExternalLinks(True)
|
||||
self.links_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -171,12 +214,27 @@ class AboutModal(QWidget):
|
||||
|
||||
# Add Widgets to Layouts -----------------------------------------------
|
||||
self.content_layout.addWidget(self.logo_widget)
|
||||
self.content_layout.addWidget(self.title_label)
|
||||
self.content_layout.addWidget(self.version_label)
|
||||
self.content_layout.addWidget(self.desc_label)
|
||||
self.content_layout.addWidget(self.system_info_widget)
|
||||
self.content_layout.addWidget(self.links_label)
|
||||
self.content_layout.addStretch(1)
|
||||
self.content_layout.addWidget(self.links_label)
|
||||
self.content_layout.addWidget(self.copyright_label)
|
||||
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.bg_image = self.rm.about_bg
|
||||
self.bg_image.setDevicePixelRatio(pixel_ratio)
|
||||
self.bg_image = self.bg_image.scaled(
|
||||
QSize(
|
||||
math.floor(self.width() * pixel_ratio),
|
||||
math.floor(self.maximumHeight() * pixel_ratio),
|
||||
),
|
||||
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
palette = QPalette()
|
||||
palette.setBrush(QPalette.ColorRole.Window, self.bg_image)
|
||||
self.setPalette(palette)
|
||||
|
||||
self.root_layout.addWidget(self.content_widget)
|
||||
self.root_layout.addWidget(self.button_widget)
|
||||
|
||||
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -33,16 +34,15 @@ class AddFieldModal(QWidget):
|
||||
# [Cancel] [Save]
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.setWindowTitle(Translations["library.field.add"])
|
||||
self.setWindowTitle(Translations["field.add"])
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(400, 300)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.title_widget = QLabel(Translations["library.field.add"])
|
||||
self.title_widget = QLabel(header(Translations["field.add"], 3))
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px;")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
@@ -24,14 +25,14 @@ from tagstudio.core.library.alchemy.library import Library, slugify
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_text_color,
|
||||
)
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
line_edit_style,
|
||||
list_button_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -129,43 +130,12 @@ class BuildColorPanel(PanelWidget):
|
||||
color=QColor(unwrap(self.preview_button.tag_color_group).secondary)
|
||||
if unwrap(self.preview_button.tag_color_group).secondary
|
||||
else None,
|
||||
color_border=checked,
|
||||
)
|
||||
)
|
||||
self.border_layout.addWidget(self.border_checkbox)
|
||||
self.border_label = QLabel(Translations["color.color_border"])
|
||||
self.border_layout.addWidget(self.border_label)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
self.border_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.border_checkbox.setStyleSheet(checkbox_style())
|
||||
|
||||
# Add Widgets to Layout ================================================
|
||||
self.root_layout.addWidget(self.preview_widget)
|
||||
@@ -222,89 +192,20 @@ class BuildColorPanel(PanelWidget):
|
||||
def update_primary(self, color: QColor):
|
||||
logger.info("[BuildColorPanel] Updating Primary", primary_color=color)
|
||||
|
||||
highlight_color = get_highlight_color(color)
|
||||
text_color = get_text_color(color, highlight_color)
|
||||
border_color = get_border_color(color)
|
||||
|
||||
hex_code = color.name().upper()
|
||||
|
||||
self.primary_button.setText(hex_code)
|
||||
self.primary_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{color.toTuple()};"
|
||||
f"border-color: rgba{color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
self.primary_button.setStyleSheet(list_button_style(color))
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
|
||||
def update_secondary(self, color: QColor | None = None, color_border: bool = False):
|
||||
def update_secondary(self, color: QColor | None = None):
|
||||
logger.info("[BuildColorPanel] Updating Secondary", color=color)
|
||||
|
||||
color_ = color or QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
|
||||
highlight_color = get_highlight_color(color_)
|
||||
text_color = get_text_color(color_, highlight_color)
|
||||
border_color = get_border_color(color_)
|
||||
|
||||
hex_code = "" if not color else color.name().upper()
|
||||
self.secondary_button.setText(
|
||||
Translations["color.title.no_color"] if not color else hex_code
|
||||
)
|
||||
self.secondary_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: rgba{color_.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: rgba{highlight_color.toTuple()};"
|
||||
f"color: rgba{color_.toTuple()};"
|
||||
f"border-color: rgba{color_.toTuple()};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"padding-right: 0px;"
|
||||
f"padding-left: 0px;"
|
||||
f"outline-style: solid;"
|
||||
f"outline-width: 1px;"
|
||||
f"outline-radius: 4px;"
|
||||
f"outline-color: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.secondary_button.setText(hex_code if color else Translations["color.title.no_color"])
|
||||
self.secondary_button.setStyleSheet(list_button_style(color_))
|
||||
self.preview_button.set_tag_color_group(self.build_color()[1])
|
||||
|
||||
def update_known_colors(self):
|
||||
@@ -346,17 +247,9 @@ class BuildColorPanel(PanelWidget):
|
||||
is_slug_empty = not slug
|
||||
is_invalid = False
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_name_empty
|
||||
else ""
|
||||
)
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
|
||||
|
||||
self.slug_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_slug_empty or is_invalid
|
||||
else ""
|
||||
)
|
||||
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
self.update_preview_text()
|
||||
@@ -393,13 +286,7 @@ class BuildColorPanel(PanelWidget):
|
||||
)
|
||||
return (self.color_group, new_color)
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
# self.setTabOrder(self.name_field, self.shorthand_field)
|
||||
# self.setTabOrder(self.shorthand_field, self.aliases_add_button)
|
||||
# self.setTabOrder(self.aliases_add_button, self.parent_tags_add_button)
|
||||
# self.setTabOrder(self.parent_tags_add_button, self.color_button)
|
||||
# self.setTabOrder(self.color_button, self.panel_cancel_button)
|
||||
# self.setTabOrder(self.panel_cancel_button, self.panel_save_button)
|
||||
# self.setTabOrder(self.panel_save_button, self.aliases_table.cellWidget(0, 1))
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import override
|
||||
from uuid import uuid4
|
||||
|
||||
import structlog
|
||||
@@ -12,9 +13,9 @@ from PySide6.QtWidgets import QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.library import Library, ReservedNamespaceError, slugify
|
||||
from tagstudio.core.library.alchemy.models import Namespace
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import line_edit_style
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -111,17 +112,9 @@ class BuildNamespacePanel(PanelWidget):
|
||||
is_slug_empty = not slug
|
||||
is_invalid = False
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_name_empty
|
||||
else ""
|
||||
)
|
||||
self.name_field.setStyleSheet(line_edit_style() if is_name_empty else "")
|
||||
|
||||
self.slug_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_slug_empty or is_invalid
|
||||
else ""
|
||||
)
|
||||
self.slug_field.setStyleSheet(line_edit_style() if is_slug_empty or is_invalid else "")
|
||||
|
||||
self.slug_field.setText(slug)
|
||||
|
||||
@@ -156,6 +149,7 @@ class BuildNamespacePanel(PanelWidget):
|
||||
logger.info("[BuildNamespacePanel] Built Namespace", slug=slug, name=name)
|
||||
return namespace
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
self.setTabOrder(self.name_field, self.slug_field)
|
||||
self.name_field.selectAll()
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import cast, override
|
||||
|
||||
import structlog
|
||||
@@ -24,39 +25,46 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagColorGroup
|
||||
from tagstudio.core.library.alchemy.models import Tag, TagAlias, TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.tag_search_panel_controller import TagSearchModal
|
||||
from tagstudio.qt.mixed.tag_color_preview import TagColorPreview
|
||||
from tagstudio.qt.mixed.tag_color_selection import TagColorSelection
|
||||
from tagstudio.qt.mixed.tag_search import TagSearchModal, TagSearchPanel
|
||||
from tagstudio.qt.mixed.tag_widget import (
|
||||
TagWidget,
|
||||
get_border_color,
|
||||
get_highlight_color,
|
||||
get_primary_color,
|
||||
get_text_color,
|
||||
)
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_tag_color, get_ui_color
|
||||
from tagstudio.qt.mixed.tag_widget import TagWidget
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.panel_modal import PanelModal, PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import (
|
||||
checkbox_style,
|
||||
colored_radio_button_style,
|
||||
get_tag_border_color,
|
||||
get_tag_highlight_color,
|
||||
get_tag_primary_color,
|
||||
get_tag_text_color,
|
||||
header,
|
||||
line_edit_style,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CustomTableItem(QLineEdit):
|
||||
def __init__(self, text, on_return, on_backspace, parent=None):
|
||||
# TODO: Look into using signals instead of callbacks
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
on_return: Callable[..., None],
|
||||
on_backspace: Callable[..., None],
|
||||
parent: QWidget | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setText(text)
|
||||
self.on_return = on_return
|
||||
self.on_backspace = on_backspace
|
||||
|
||||
def set_id(self, id):
|
||||
self.id = id
|
||||
self.on_return: Callable[..., None] = on_return
|
||||
self.on_backspace: Callable[..., None] = on_backspace
|
||||
self.alias: TagAlias
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, arg__1: QKeyEvent): # noqa: N802
|
||||
def keyPressEvent(self, arg__1: QKeyEvent):
|
||||
if arg__1.key() == Qt.Key.Key_Return or arg__1.key() == Qt.Key.Key_Enter:
|
||||
self.on_return()
|
||||
elif arg__1.key() == Qt.Key.Key_Backspace and self.text().strip() == "":
|
||||
@@ -75,6 +83,8 @@ class BuildTagPanel(PanelWidget):
|
||||
self.tag_color_namespace: str | None
|
||||
self.tag_color_slug: str | None
|
||||
self.disambiguation_id: int | None
|
||||
self.parent_ids: set[int] = set()
|
||||
self.aliases: list[TagAlias] = []
|
||||
|
||||
self.setMinimumSize(300, 460)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -92,7 +102,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.name_layout.addWidget(self.name_title)
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setFixedHeight(24)
|
||||
self.name_field.textChanged.connect(self.on_name_changed)
|
||||
self.name_field.textChanged.connect(self._on_name_change)
|
||||
self.name_field.setPlaceholderText(Translations["tag.tag_name_required"])
|
||||
self.name_layout.addWidget(self.name_field)
|
||||
|
||||
@@ -128,7 +138,7 @@ class BuildTagPanel(PanelWidget):
|
||||
|
||||
self.aliases_add_button = QPushButton()
|
||||
self.aliases_add_button.setText("+")
|
||||
self.aliases_add_button.clicked.connect(self.add_alias_callback)
|
||||
self.aliases_add_button.clicked.connect(self._create_alias_callback)
|
||||
|
||||
# Parent Tags ----------------------------------------------------------
|
||||
self.parent_tags_widget = QWidget()
|
||||
@@ -167,7 +177,7 @@ class BuildTagPanel(PanelWidget):
|
||||
exclude_ids.append(tag.id)
|
||||
|
||||
self.add_tag_modal = TagSearchModal(self.lib, exclude_ids)
|
||||
self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x))
|
||||
self.add_tag_modal.tsp.item_chosen.connect(lambda x: self._add_parent_tag_callback(x))
|
||||
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
|
||||
# Color ----------------------------------------------------------------
|
||||
@@ -190,12 +200,10 @@ class BuildTagPanel(PanelWidget):
|
||||
self.tag_color_selection = TagColorSelection(self.lib)
|
||||
chose_tag_color_title = Translations["tag.choose_color"]
|
||||
self.choose_color_modal = PanelModal(
|
||||
self.tag_color_selection,
|
||||
chose_tag_color_title,
|
||||
chose_tag_color_title,
|
||||
done_callback=lambda: self.choose_color_callback(
|
||||
self.tag_color_selection.selected_color
|
||||
),
|
||||
self.tag_color_selection, chose_tag_color_title, chose_tag_color_title
|
||||
)
|
||||
self.choose_color_modal.done.connect(
|
||||
lambda: self.choose_color_callback(self.tag_color_selection.selected_color)
|
||||
)
|
||||
self.color_button.button.clicked.connect(self.choose_color_modal.show)
|
||||
self.color_layout.addWidget(self.color_button)
|
||||
@@ -210,38 +218,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.cat_title = QLabel(Translations["tag.is_category"])
|
||||
self.cat_checkbox = QCheckBox()
|
||||
self.cat_checkbox.setFixedSize(22, 22)
|
||||
|
||||
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
|
||||
border_color = get_border_color(primary_color)
|
||||
highlight_color = get_highlight_color(primary_color)
|
||||
text_color: QColor = get_text_color(primary_color, highlight_color)
|
||||
|
||||
self.cat_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.cat_checkbox.setStyleSheet(checkbox_style())
|
||||
self.cat_layout.addWidget(self.cat_checkbox)
|
||||
self.cat_layout.addWidget(self.cat_title)
|
||||
|
||||
@@ -255,33 +232,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.hidden_title = QLabel(Translations["tag.is_hidden"])
|
||||
self.hidden_checkbox = QCheckBox()
|
||||
self.hidden_checkbox.setFixedSize(22, 22)
|
||||
|
||||
self.hidden_checkbox.setStyleSheet(
|
||||
f"QCheckBox{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QCheckBox::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QCheckBox::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
self.hidden_checkbox.setStyleSheet(checkbox_style())
|
||||
self.hidden_layout.addWidget(self.hidden_checkbox)
|
||||
self.hidden_layout.addWidget(self.hidden_title)
|
||||
|
||||
@@ -293,16 +244,10 @@ class BuildTagPanel(PanelWidget):
|
||||
self.root_layout.addWidget(self.aliases_add_button)
|
||||
self.root_layout.addWidget(self.parent_tags_widget)
|
||||
self.root_layout.addWidget(self.color_widget)
|
||||
self.root_layout.addWidget(QLabel("<h3>Properties</h3>"))
|
||||
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
|
||||
self.root_layout.addWidget(self.cat_widget)
|
||||
self.root_layout.addWidget(self.hidden_widget)
|
||||
|
||||
self.parent_ids: set[int] = set()
|
||||
self.alias_ids: list[int] = []
|
||||
self.alias_names: list[str] = []
|
||||
self.new_alias_names: dict = {}
|
||||
self.new_item_id = sys.maxsize
|
||||
|
||||
self.set_tag(tag or Tag(name=Translations["tag.new"]))
|
||||
|
||||
def backspace(self):
|
||||
@@ -314,10 +259,7 @@ class BuildTagPanel(PanelWidget):
|
||||
remove_row = 0
|
||||
for i in range(0, row):
|
||||
item = self.aliases_table.cellWidget(i, 1)
|
||||
if (
|
||||
isinstance(item, CustomTableItem)
|
||||
and cast(CustomTableItem, item).id == cast(CustomTableItem, focused_widget).id
|
||||
):
|
||||
if isinstance(item, CustomTableItem) and item == cast(CustomTableItem, focused_widget):
|
||||
cast(QPushButton, self.aliases_table.cellWidget(i, 0)).click()
|
||||
remove_row = i
|
||||
break
|
||||
@@ -331,41 +273,36 @@ class BuildTagPanel(PanelWidget):
|
||||
self.aliases_table.cellWidget(remove_row - 1, 1).setFocus()
|
||||
|
||||
def enter(self):
|
||||
"""When the Enter/Return key has been pressed."""
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(focused_widget, CustomTableItem):
|
||||
self.add_alias_callback()
|
||||
self._create_alias_callback()
|
||||
|
||||
def add_parent_tag_callback(self, tag_id: int):
|
||||
logger.info("add_parent_tag_callback", tag_id=tag_id)
|
||||
def _add_parent_tag_callback(self, tag_id: int):
|
||||
self.parent_ids.add(tag_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
def remove_parent_tag_callback(self, tag_id: int):
|
||||
logger.info("remove_parent_tag_callback", tag_id=tag_id)
|
||||
def _remove_parent_tag_callback(self, tag_id: int):
|
||||
self.parent_ids.remove(tag_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
def add_alias_callback(self):
|
||||
logger.info("add_alias_callback")
|
||||
def _create_alias_callback(self):
|
||||
alias = TagAlias("", tag_id=self.tag.id)
|
||||
self.aliases.append(alias)
|
||||
|
||||
id = self.new_item_id
|
||||
self.alias_ids.append(id)
|
||||
self.new_alias_names[id] = ""
|
||||
self.new_item_id -= 1
|
||||
self._set_aliases()
|
||||
|
||||
row = self.aliases_table.rowCount() - 1
|
||||
item = self.aliases_table.cellWidget(row, 1)
|
||||
item.setFocus()
|
||||
|
||||
def remove_alias_callback(self, alias_name: str, alias_id: int):
|
||||
logger.info("remove_alias_callback")
|
||||
|
||||
self.alias_ids.remove(alias_id)
|
||||
def remove_alias_callback(self, alias: TagAlias):
|
||||
for i, a in enumerate(self.aliases):
|
||||
if a.name == alias.name and a.id == alias.id:
|
||||
del self.aliases[i]
|
||||
continue
|
||||
self._set_aliases()
|
||||
|
||||
def choose_color_callback(self, tag_color_group: TagColorGroup | None):
|
||||
logger.info("choose_color_callback", tag_color_group=tag_color_group)
|
||||
if tag_color_group:
|
||||
self.tag_color_namespace = tag_color_group.namespace
|
||||
self.tag_color_slug = tag_color_group.slug
|
||||
@@ -406,13 +343,13 @@ class BuildTagPanel(PanelWidget):
|
||||
row.setSpacing(3)
|
||||
|
||||
# Init Colors
|
||||
primary_color = get_primary_color(tag)
|
||||
primary_color = get_tag_primary_color(tag)
|
||||
border_color = (
|
||||
get_border_color(primary_color)
|
||||
get_tag_border_color(primary_color)
|
||||
if not (tag.color and tag.color.secondary and tag.color.color_border)
|
||||
else (QColor(tag.color.secondary))
|
||||
)
|
||||
highlight_color = get_highlight_color(
|
||||
highlight_color = get_tag_highlight_color(
|
||||
primary_color
|
||||
if not (tag.color and tag.color.secondary)
|
||||
else QColor(tag.color.secondary)
|
||||
@@ -421,17 +358,32 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag.color and tag.color.secondary:
|
||||
text_color = QColor(tag.color.secondary)
|
||||
else:
|
||||
text_color = get_text_color(primary_color, highlight_color)
|
||||
text_color = get_tag_text_color(primary_color, highlight_color)
|
||||
|
||||
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
|
||||
self.lib.update_tag(
|
||||
build_tag_panel.build_tag(),
|
||||
parent_ids=set(build_tag_panel.parent_ids),
|
||||
aliases=set(build_tag_panel.aliases),
|
||||
)
|
||||
self.set_parent_tags()
|
||||
|
||||
def on_parent_tag_edit(tag: Tag) -> None:
|
||||
build_tag_panel = BuildTagPanel(self.lib, tag=tag)
|
||||
edit_modal = PanelModal(
|
||||
build_tag_panel,
|
||||
self.lib.tag_display_name(tag),
|
||||
"Edit Tag",
|
||||
is_savable=True,
|
||||
)
|
||||
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
|
||||
edit_modal.show()
|
||||
|
||||
# Add Tag Widget
|
||||
tag_widget = TagWidget(
|
||||
tag,
|
||||
library=self.lib,
|
||||
has_edit=True,
|
||||
has_remove=True,
|
||||
)
|
||||
tag_widget.on_remove.connect(lambda t=parent_id: self.remove_parent_tag_callback(t))
|
||||
tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).edit_tag(t))
|
||||
tag_widget = TagWidget(tag, library=self.lib, has_edit=True, has_remove=True)
|
||||
tag_widget.on_remove.connect(lambda t=parent_id: self._remove_parent_tag_callback(t))
|
||||
tag_widget.on_edit.connect(partial(on_parent_tag_edit, tag))
|
||||
|
||||
row.addWidget(tag_widget)
|
||||
|
||||
# Add Disambiguation Tag Button
|
||||
@@ -440,35 +392,7 @@ class BuildTagPanel(PanelWidget):
|
||||
disam_button.setFixedSize(22, 22)
|
||||
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
|
||||
disam_button.setStyleSheet(
|
||||
f"QRadioButton{{"
|
||||
f"background: rgba{primary_color.toTuple()};"
|
||||
f"color: rgba{text_color.toTuple()};"
|
||||
f"border-color: rgba{border_color.toTuple()};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator{{"
|
||||
f"width: 10px;"
|
||||
f"height: 10px;"
|
||||
f"border-radius: 2px;"
|
||||
f"margin: 4px;"
|
||||
f"}}"
|
||||
f"QRadioButton::indicator:checked{{"
|
||||
f"background: rgba{text_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::hover{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::pressed{{"
|
||||
f"background: rgba{border_color.toTuple()};"
|
||||
f"color: rgba{primary_color.toTuple()};"
|
||||
f"border-color: rgba{primary_color.toTuple()};"
|
||||
f"}}"
|
||||
f"QRadioButton::focus{{"
|
||||
f"border-color: rgba{highlight_color.toTuple()};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
|
||||
)
|
||||
|
||||
self.disam_button_group.addButton(disam_button)
|
||||
@@ -492,61 +416,34 @@ class BuildTagPanel(PanelWidget):
|
||||
else:
|
||||
button.setChecked(False)
|
||||
|
||||
def add_aliases(self):
|
||||
names: set[str] = set()
|
||||
for i in range(0, self.aliases_table.rowCount()):
|
||||
widget = self.aliases_table.cellWidget(i, 1)
|
||||
names.add(cast(CustomTableItem, widget).text())
|
||||
|
||||
remove: set[str] = set(self.alias_names) - names
|
||||
self.alias_names = list(set(self.alias_names) - remove)
|
||||
|
||||
for name in names:
|
||||
# add new aliases
|
||||
if name.strip() != "" and name not in set(self.alias_names):
|
||||
self.alias_names.append(name)
|
||||
elif name.strip() == "" and name in set(self.alias_names):
|
||||
self.alias_names.remove(name)
|
||||
|
||||
def _update_new_alias_name_dict(self):
|
||||
for i in range(0, self.aliases_table.rowCount()):
|
||||
widget = cast(CustomTableItem, self.aliases_table.cellWidget(i, 1))
|
||||
self.new_alias_names[widget.id] = widget.text()
|
||||
|
||||
def _set_aliases(self):
|
||||
self._update_new_alias_name_dict()
|
||||
|
||||
while self.aliases_table.rowCount() > 0:
|
||||
self.aliases_table.removeRow(0)
|
||||
|
||||
self.alias_names.clear()
|
||||
|
||||
last: QWidget | None = self.panel_save_button
|
||||
for alias_id in self.alias_ids:
|
||||
alias = self.lib.get_alias(self.tag.id, alias_id)
|
||||
aliases = list(self.aliases)
|
||||
alias_names = [a.name for a in aliases]
|
||||
sorted_aliases = sorted(aliases, key=lambda x: alias_names[aliases.index(x)])
|
||||
|
||||
alias_name = alias.name if alias else self.new_alias_names[alias_id]
|
||||
# Sort the TagAlias objects while keeping in-progress empty ones at the bottom
|
||||
empty_aliases: list[TagAlias] = []
|
||||
while sorted_aliases and sorted_aliases[0].name == "":
|
||||
empty_aliases.append(sorted_aliases.pop(0))
|
||||
for alias in empty_aliases:
|
||||
sorted_aliases.append(alias)
|
||||
|
||||
# handel when an alias name changes
|
||||
if alias_id in self.new_alias_names:
|
||||
alias_name = self.new_alias_names[alias_id]
|
||||
|
||||
self.alias_names.append(alias_name)
|
||||
|
||||
remove_btn = QPushButton("-")
|
||||
remove_btn.clicked.connect(
|
||||
lambda a=alias_name, id=alias_id: self.remove_alias_callback(a, id)
|
||||
)
|
||||
for alias in sorted_aliases:
|
||||
remove_button = QPushButton("-")
|
||||
remove_button.clicked.connect(partial(self.remove_alias_callback, alias))
|
||||
|
||||
row = self.aliases_table.rowCount()
|
||||
new_item = CustomTableItem(alias_name, self.enter, self.backspace)
|
||||
new_item.set_id(alias_id)
|
||||
|
||||
new_item.editingFinished.connect(lambda item=new_item: self._alias_name_change(item))
|
||||
new_item = CustomTableItem(alias.name, self.enter, self.backspace)
|
||||
new_item.alias = alias
|
||||
new_item.editingFinished.connect(partial(self._on_alias_change, new_item))
|
||||
|
||||
self.aliases_table.insertRow(row)
|
||||
self.aliases_table.setCellWidget(row, 1, new_item)
|
||||
self.aliases_table.setCellWidget(row, 0, remove_btn)
|
||||
self.aliases_table.setCellWidget(row, 0, remove_button)
|
||||
|
||||
if last is not None:
|
||||
self.setTabOrder(last, self.aliases_table.cellWidget(row, 1))
|
||||
@@ -555,22 +452,25 @@ class BuildTagPanel(PanelWidget):
|
||||
)
|
||||
last = self.aliases_table.cellWidget(row, 0)
|
||||
|
||||
def _alias_name_change(self, item: CustomTableItem):
|
||||
self.new_alias_names[item.id] = item.text()
|
||||
def _on_alias_change(self, item: CustomTableItem):
|
||||
for alias in self.aliases:
|
||||
if item.alias == alias:
|
||||
alias.name = item.text()
|
||||
item.alias.name = item.text()
|
||||
continue
|
||||
|
||||
def set_tag(self, tag: Tag):
|
||||
logger.info("[BuildTagPanel] Setting Tag", tag=tag)
|
||||
logger.info("[BuildTagPanel] Setting Tag", tag_id=tag.id)
|
||||
self.tag = tag
|
||||
|
||||
self.name_field.setText(tag.name)
|
||||
self.shorthand_field.setText(tag.shorthand or "")
|
||||
|
||||
for alias_id in tag.alias_ids:
|
||||
self.alias_ids.append(alias_id)
|
||||
for alias in tag.aliases:
|
||||
self.aliases.append(alias)
|
||||
self._set_aliases()
|
||||
|
||||
self.disambiguation_id = tag.disambiguation_id
|
||||
for parent_id in tag.parent_ids:
|
||||
for parent_id in self.tag.parent_ids:
|
||||
self.parent_ids.add(parent_id)
|
||||
self.set_parent_tags()
|
||||
|
||||
@@ -587,22 +487,15 @@ class BuildTagPanel(PanelWidget):
|
||||
self.cat_checkbox.setChecked(tag.is_category)
|
||||
self.hidden_checkbox.setChecked(tag.is_hidden)
|
||||
|
||||
def on_name_changed(self):
|
||||
def _on_name_change(self):
|
||||
is_empty = not self.name_field.text().strip()
|
||||
|
||||
self.name_field.setStyleSheet(
|
||||
f"border: 1px solid {get_ui_color(ColorType.PRIMARY, UiColor.RED)}; border-radius: 2px"
|
||||
if is_empty
|
||||
else ""
|
||||
)
|
||||
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)
|
||||
|
||||
def build_tag(self) -> Tag:
|
||||
tag = self.tag
|
||||
self.add_aliases()
|
||||
|
||||
tag.name = self.name_field.text()
|
||||
tag.shorthand = self.shorthand_field.text()
|
||||
tag.disambiguation_id = self.disambiguation_id
|
||||
@@ -611,9 +504,10 @@ class BuildTagPanel(PanelWidget):
|
||||
tag.is_category = self.cat_checkbox.isChecked()
|
||||
tag.is_hidden = self.hidden_checkbox.isChecked()
|
||||
|
||||
logger.info("built tag", tag=tag)
|
||||
logger.info("[BuildTag] Build Tag", tag_id=tag.id, tag_name=tag.name)
|
||||
return tag
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
self.setTabOrder(self.name_field, self.shorthand_field)
|
||||
self.setTabOrder(self.shorthand_field, self.aliases_add_button)
|
||||
@@ -624,4 +518,3 @@ class BuildTagPanel(PanelWidget):
|
||||
self.setTabOrder(unwrap(self.panel_save_button), self.aliases_table.cellWidget(0, 1))
|
||||
self.name_field.selectAll()
|
||||
self.name_field.setFocus()
|
||||
self._set_aliases()
|
||||
|
||||
@@ -10,16 +10,15 @@ from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QMessageBox, QPushButton
|
||||
|
||||
from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX
|
||||
from tagstudio.core.library.alchemy.enums import TagColorEnum
|
||||
from tagstudio.core.library.alchemy.models import TagColorGroup
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.mixed.build_color import BuildColorPanel
|
||||
from tagstudio.qt.mixed.field_widget import FieldWidget
|
||||
from tagstudio.qt.mixed.tag_color_label import TagColorLabel
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import add_button_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
@@ -43,34 +42,6 @@ class ColorBoxWidget(FieldWidget):
|
||||
title = "" if not self.lib.engine else self.lib.get_namespace_name(group)
|
||||
super().__init__(title)
|
||||
|
||||
self.add_button_stylesheet = (
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, TagColorEnum.DEFAULT)};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, TagColorEnum.DEFAULT)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: 2px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 2px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 15px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::pressed{{"
|
||||
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"border-color: {get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)};"
|
||||
f"}}"
|
||||
f"QPushButton::focus{{"
|
||||
f"border-color: {get_tag_color(ColorType.LIGHT_ACCENT, TagColorEnum.DEFAULT)};"
|
||||
f"outline:none;"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
self.setObjectName("colorBox")
|
||||
self.base_layout = FlowLayout()
|
||||
self.base_layout.enable_grid_optimizations(value=True)
|
||||
@@ -114,7 +85,7 @@ class ColorBoxWidget(FieldWidget):
|
||||
add_button.setText("+")
|
||||
add_button.setFlat(True)
|
||||
add_button.setFixedSize(22, 22)
|
||||
add_button.setStyleSheet(self.add_button_stylesheet)
|
||||
add_button.setStyleSheet(add_button_style())
|
||||
add_button.clicked.connect(
|
||||
lambda: self.edit_color(
|
||||
TagColorGroup(
|
||||
@@ -134,11 +105,11 @@ class ColorBoxWidget(FieldWidget):
|
||||
self.edit_modal = PanelModal(
|
||||
build_color_panel,
|
||||
"Edit Color",
|
||||
has_save=True,
|
||||
is_savable=True,
|
||||
)
|
||||
|
||||
self.edit_modal.saved.connect(
|
||||
lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit()) # type: ignore
|
||||
lambda: (self.lib.update_color(*build_color_panel.build_color()), self.updated.emit())
|
||||
)
|
||||
self.edit_modal.show()
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
|
||||
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from typing import cast
|
||||
from typing import cast, override
|
||||
|
||||
from PySide6.QtCore import QDateTime
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QVBoxLayout
|
||||
from PySide6.QtWidgets import QDateTimeEdit, QLineEdit, QVBoxLayout
|
||||
|
||||
from tagstudio.qt.views.panel_modal import PanelWidget
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import title_line_edit_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -40,11 +40,16 @@ def qdtf2dtf(dtf: str) -> str:
|
||||
|
||||
|
||||
class DatetimePicker(PanelWidget):
|
||||
def __init__(self, driver: "QtDriver", datetime: dt | str):
|
||||
def __init__(self, driver: "QtDriver", name: str, datetime: dt | str):
|
||||
super().__init__()
|
||||
self.setMinimumSize(300, 60)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setStyleSheet(title_line_edit_style())
|
||||
self.name_field.setText(name)
|
||||
|
||||
if isinstance(datetime, str):
|
||||
datetime = DatetimePicker.string2dt(datetime)
|
||||
self.datetime_edit = QDateTimeEdit()
|
||||
@@ -55,20 +60,24 @@ class DatetimePicker(PanelWidget):
|
||||
self.datetime_edit.setDisplayFormat(qdtf2dtf(driver.settings.datetime_format))
|
||||
|
||||
self.initial_value = datetime
|
||||
self.root_layout.addWidget(self.name_field)
|
||||
self.root_layout.addWidget(self.datetime_edit)
|
||||
|
||||
def get_content(self):
|
||||
return DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime()))
|
||||
@override
|
||||
def saved_data(self) -> dict[str, str]:
|
||||
return {
|
||||
"name": self.name_field.text(),
|
||||
"value": DatetimePicker.dt2string(DatetimePicker.qdt2dt(self.datetime_edit.dateTime())),
|
||||
}
|
||||
|
||||
@override
|
||||
def parent_post_init(self):
|
||||
self.datetime_edit.setFocus()
|
||||
|
||||
@override
|
||||
def reset(self):
|
||||
self.datetime_edit.setDateTime(DatetimePicker.dt2qdt(self.initial_value))
|
||||
|
||||
def add_callback(self, callback: Callable, event: str = "returnPressed"):
|
||||
if event == "returnPressed":
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f"unknown event type: {event}")
|
||||
|
||||
@staticmethod
|
||||
def qdt2dt(qdt: QDateTime) -> dt:
|
||||
return cast(dt, qdt.toPython())
|
||||
|
||||
@@ -167,9 +167,9 @@ class DropImportModal(QWidget):
|
||||
def displayed_text(x):
|
||||
return Translations.format(
|
||||
"drop_import.progress.label.singular"
|
||||
if x[0] + 1 == 1
|
||||
if x[0] == 1
|
||||
else "drop_import.progress.label.plural",
|
||||
count=x[0] + 1,
|
||||
count=x[0],
|
||||
suffix=f" {x[1]} {self.choice.value}" if self.choice else "",
|
||||
)
|
||||
|
||||
@@ -193,6 +193,7 @@ class DropImportModal(QWidget):
|
||||
file_count = 0
|
||||
duplicated_files_progress = 0
|
||||
for file in self.files:
|
||||
yield [file_count, duplicated_files_progress]
|
||||
if file.is_dir():
|
||||
continue
|
||||
|
||||
@@ -213,7 +214,6 @@ class DropImportModal(QWidget):
|
||||
shutil.copyfile(file, unwrap(self.driver.lib.library_dir) / dest_file)
|
||||
|
||||
file_count += 1
|
||||
yield [file_count, duplicated_files_progress]
|
||||
|
||||
def _get_relative_path(self, path: Path) -> Path:
|
||||
for dir in self.dirs_in_root:
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import sys
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime as dt
|
||||
from functools import partial
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
@@ -14,7 +14,6 @@ from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
@@ -32,13 +31,12 @@ from tagstudio.core.library.alchemy.fields import (
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry, Tag
|
||||
from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.controllers.edit_text_controller import EditText
|
||||
from tagstudio.qt.controllers.tag_box_controller import TagBoxWidget
|
||||
from tagstudio.qt.mixed.datetime_picker import DatetimePicker
|
||||
from tagstudio.qt.mixed.field_widget import FieldContainer
|
||||
from tagstudio.qt.mixed.text_field import TextWidget
|
||||
from tagstudio.qt.mixed.text_field import TextContainerWidget
|
||||
from tagstudio.qt.translations import FIELD_TYPE_KEYS, Translations
|
||||
from tagstudio.qt.views.edit_text_box_modal import EditTextBox
|
||||
from tagstudio.qt.views.edit_text_line_modal import EditTextLine
|
||||
from tagstudio.qt.views.panel_modal import PanelModal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -48,9 +46,9 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainers(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
"""Widget for the tag and field containers displayed inside the Preview Panel."""
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
|
||||
self.lib = library
|
||||
@@ -103,7 +101,12 @@ class FieldContainers(QWidget):
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.addWidget(self.scroll_area)
|
||||
|
||||
def update_from_entry(self, entry_id: int, update_badges: bool = True):
|
||||
@property
|
||||
def top_entry_id(self) -> int:
|
||||
"""Get the topmost entry ID in the (cached) selected entries."""
|
||||
return self.cached_entries[0].id
|
||||
|
||||
def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None:
|
||||
"""Update tags and fields from a single Entry source."""
|
||||
logger.warning("[FieldContainers] Updating Selection", entry_id=entry_id)
|
||||
|
||||
@@ -113,7 +116,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
def update_granular(
|
||||
self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True
|
||||
):
|
||||
) -> None:
|
||||
"""Individually update elements of the item preview."""
|
||||
container_len: int = len(entry_fields)
|
||||
container_index = 0
|
||||
@@ -131,7 +134,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
# Write field container(s)
|
||||
for index, field in enumerate(entry_fields, start=container_index):
|
||||
self.write_container(index, field, is_mixed=False)
|
||||
self.write_field_container(index, field, is_mixed=False)
|
||||
|
||||
# Hide leftover container(s)
|
||||
if len(self.containers) > container_len:
|
||||
@@ -139,7 +142,7 @@ class FieldContainers(QWidget):
|
||||
if i > (container_len - 1):
|
||||
c.setHidden(True)
|
||||
|
||||
def update_toggled_tag(self, tag_id: int, toggle_value: bool):
|
||||
def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None:
|
||||
"""Visually add or remove a tag from the item preview without needing to query the db."""
|
||||
entry = self.cached_entries[0]
|
||||
tag = self.lib.get_tag(tag_id)
|
||||
@@ -152,7 +155,7 @@ class FieldContainers(QWidget):
|
||||
|
||||
self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False)
|
||||
|
||||
def hide_containers(self):
|
||||
def hide_containers(self) -> None:
|
||||
"""Hide all field and tag containers."""
|
||||
for c in self.containers:
|
||||
c.setHidden(True)
|
||||
@@ -203,58 +206,173 @@ class FieldContainers(QWidget):
|
||||
return dict((c, d) for c, d in categories.items() if len(d) > 0)
|
||||
|
||||
def remove_field_prompt(self, name: str) -> str:
|
||||
return Translations.format("library.field.confirm_remove", name=name)
|
||||
return Translations.format("field.confirm_remove", name=name)
|
||||
|
||||
def add_field_to_selected(self, field_list: list[QListWidgetItem]):
|
||||
"""Add list of entry fields to one or more selected items.
|
||||
def add_field_to_selected(
|
||||
self, field_templates: BaseFieldTemplate | list[BaseFieldTemplate]
|
||||
) -> None:
|
||||
"""Add list of fields to one or more selected items.
|
||||
|
||||
Uses the current driver selection, NOT the field containers cache.
|
||||
"""
|
||||
if isinstance(field_templates, BaseFieldTemplate):
|
||||
field_templates = [field_templates]
|
||||
|
||||
assert isinstance(field_templates, list)
|
||||
|
||||
logger.info(
|
||||
"[FieldContainers][add_field_to_selected]",
|
||||
selected=self.driver.selected,
|
||||
fields=field_list,
|
||||
fields=[
|
||||
(field_template.class_name, field_template.id) for field_template in field_templates
|
||||
],
|
||||
)
|
||||
|
||||
for entry_id in self.driver.selected:
|
||||
for field in field_list:
|
||||
template: BaseFieldTemplate = field.data(Qt.ItemDataRole.UserRole)
|
||||
for field_template in field_templates:
|
||||
logger.info(
|
||||
"[FieldContainers][add_field_to_selected] Adding field",
|
||||
name=template.name,
|
||||
type=template.class_name,
|
||||
name=field_template.name,
|
||||
type=field_template.class_name,
|
||||
)
|
||||
self.lib.add_field_to_entries(entry_id, template.to_field())
|
||||
self.lib.add_field_to_entries(entry_id, field_template.to_field())
|
||||
|
||||
def add_tags_to_selected(self, tags: int | list[int]):
|
||||
def add_tags_to_selected(self, tag_ids: int | list[int]) -> None:
|
||||
"""Add list of tags to one or more selected items.
|
||||
|
||||
Uses the current driver selection, NOT the field containers cache.
|
||||
"""
|
||||
if isinstance(tags, int):
|
||||
tags = [tags]
|
||||
if isinstance(tag_ids, int):
|
||||
tag_ids = [tag_ids]
|
||||
logger.info(
|
||||
"[FieldContainers][add_tags_to_selected]",
|
||||
selected=self.driver.selected,
|
||||
tags=tags,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
self.driver.add_tags_to_selected_callback(tags)
|
||||
self.driver.add_tags_to_selected_callback(tag_ids)
|
||||
|
||||
def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
|
||||
"""Update/Create data for a FieldContainer.
|
||||
def write_field_container(self, index: int, field: BaseField, is_mixed: bool = False) -> None:
|
||||
"""Update/Create data for a field FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
field(BaseField): The type of field to write to.
|
||||
field(BaseField): The field to write in this container.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
|
||||
def update_text_field_callback(
|
||||
field: TextField, entry_id: int, content: dict[str, str | bool]
|
||||
) -> None:
|
||||
"""Callback called when a text field has updated data."""
|
||||
self._update_text_field(
|
||||
field, str(content["name"]), str(content["value"]), bool(content["is_multiline"])
|
||||
)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def update_datetime_field_callback(
|
||||
field: DatetimeField, entry_id: int, content: dict[str, str]
|
||||
) -> None:
|
||||
"""Callback called when a datetime field has updated data."""
|
||||
self.update_datetime_field(field, str(content["name"]), str(content["value"]))
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def remove_field_callback(field: BaseField, entry_id: int) -> None:
|
||||
"""Callback called when a field needs to be removed from an entry."""
|
||||
self._remove_field(field)
|
||||
self.update_from_entry(entry_id)
|
||||
|
||||
def write_text_container(
|
||||
container: FieldContainer, field: TextField, title: str, is_mixed: bool
|
||||
):
|
||||
container.set_title(field.name)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
|
||||
inner_widget = TextContainerWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
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(update_text_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
def write_datetime_container(
|
||||
container: FieldContainer, field: DatetimeField, title: str, is_mixed: bool
|
||||
):
|
||||
container.set_title(field.name)
|
||||
|
||||
if not is_mixed:
|
||||
try:
|
||||
assert field.value is not None
|
||||
text = self.driver.settings.format_datetime(
|
||||
DatetimePicker.string2dt(field.value)
|
||||
)
|
||||
except (ValueError, AssertionError):
|
||||
text = str(field.value)
|
||||
else:
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
|
||||
inner_widget = TextContainerWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
if not is_mixed:
|
||||
edit_modal = PanelModal(
|
||||
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(update_datetime_field_callback, field, self.top_entry_id)
|
||||
)
|
||||
|
||||
container.set_edit_callback(edit_modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
def write_unknown_container():
|
||||
container.set_title(field.name)
|
||||
inner_widget = TextContainerWidget(title, field.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=partial(remove_field_callback, field, self.top_entry_id),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[FieldContainers][write_container]",
|
||||
index=index,
|
||||
name=field.name,
|
||||
type=field.class_name,
|
||||
)
|
||||
|
||||
# Create new containers if necessary
|
||||
if len(self.containers) < (index + 1):
|
||||
container = FieldContainer()
|
||||
self.containers.append(container)
|
||||
@@ -266,156 +384,27 @@ class FieldContainers(QWidget):
|
||||
field_name_key: str = FIELD_TYPE_KEYS.get(field.class_name, "field_type.unknown")
|
||||
title = f"{field.name} ({Translations[field_name_key]})"
|
||||
|
||||
# Single-line Text
|
||||
if type(field) is TextField and not field.is_multiline:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = field.value or ""
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextLine(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.name}", # TODO: Localize this
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_text_field(field, content, is_multiline=False),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
if "pytest" in sys.modules:
|
||||
# for better testability
|
||||
container.modal = modal # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(title),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Multiline Text
|
||||
elif type(field) is TextField and field.is_multiline:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
# Normalize line endings in any text content.
|
||||
if not is_mixed:
|
||||
assert isinstance(field.value, str | type(None))
|
||||
text = (field.value or "").replace("\r", "\n")
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
if not is_mixed:
|
||||
modal = PanelModal(
|
||||
EditTextBox(field.value),
|
||||
title=title,
|
||||
window_title=f"Edit {field.name}", # TODO: Localize this
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_text_field(field, content, is_multiline=True),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Write containers
|
||||
if type(field) is TextField:
|
||||
write_text_container(container, field, title, is_mixed)
|
||||
elif type(field) is DatetimeField:
|
||||
logger.info("[FieldContainers][write_container] Datetime Field", field=field)
|
||||
if not is_mixed:
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
|
||||
try:
|
||||
assert field.value is not None
|
||||
text = self.driver.settings.format_datetime(
|
||||
DatetimePicker.string2dt(field.value)
|
||||
)
|
||||
except (ValueError, AssertionError):
|
||||
text = str(field.value)
|
||||
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
modal = PanelModal(
|
||||
DatetimePicker(self.driver, field.value or dt.now()),
|
||||
title=f"Edit {field.name}",
|
||||
save_callback=( # pyright: ignore[reportArgumentType]
|
||||
lambda content: (
|
||||
self.update_datetime_field(field, content),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
container.set_edit_callback(modal.show)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>" # TODO: Localize this
|
||||
inner_widget = TextWidget(title, text)
|
||||
container.set_inner_widget(inner_widget)
|
||||
write_datetime_container(container, field, title, is_mixed)
|
||||
else:
|
||||
logger.warning(
|
||||
"[FieldContainers][write_container] Unknown Field", field=field
|
||||
) # TODO: Localize this
|
||||
container.set_title(field.name)
|
||||
container.set_inline(False)
|
||||
inner_widget = TextWidget(title, field.name)
|
||||
container.set_inner_widget(inner_widget)
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(
|
||||
prompt=self.remove_field_prompt(field.name),
|
||||
callback=lambda: (
|
||||
self.remove_field(field),
|
||||
self.update_from_entry(self.cached_entries[0].id),
|
||||
),
|
||||
)
|
||||
)
|
||||
write_unknown_container()
|
||||
|
||||
container.setHidden(False)
|
||||
|
||||
def write_tag_container(
|
||||
self, index: int, tags: set[Tag], category_tag: Tag | None = None, is_mixed: bool = False
|
||||
):
|
||||
"""Update/Create tag data for a FieldContainer.
|
||||
) -> None:
|
||||
"""Update/Create tag data for a tag FieldContainer.
|
||||
|
||||
Args:
|
||||
index(int): The container index.
|
||||
tags(set[Tag]): The list of tags for this container.
|
||||
category_tag(Tag|None): The category tag this container represents.
|
||||
is_mixed(bool): Relevant when multiple items are selected.
|
||||
|
||||
If True, field is not present in all selected items.
|
||||
If True, field is not present in all selected items.
|
||||
"""
|
||||
logger.info("[FieldContainers][write_tag_container]", index=index)
|
||||
if len(self.containers) < (index + 1):
|
||||
@@ -425,10 +414,7 @@ class FieldContainers(QWidget):
|
||||
else:
|
||||
container = self.containers[index]
|
||||
|
||||
container.set_title(
|
||||
"Tags" if not category_tag else category_tag.name
|
||||
) # TODO: Localize this
|
||||
container.set_inline(False)
|
||||
container.set_title(Translations["entries.tags"] if not category_tag else category_tag.name)
|
||||
|
||||
if not is_mixed:
|
||||
inner_widget = container.get_inner_widget()
|
||||
@@ -438,27 +424,24 @@ class FieldContainers(QWidget):
|
||||
inner_widget.on_update.disconnect()
|
||||
|
||||
else:
|
||||
inner_widget = TagBoxWidget(
|
||||
"Tags", # TODO: Localize this
|
||||
self.driver,
|
||||
)
|
||||
inner_widget = TagBoxWidget(Translations["entries.tags"], self.driver)
|
||||
container.set_inner_widget(inner_widget)
|
||||
inner_widget.set_entries([e.id for e in self.cached_entries])
|
||||
inner_widget.set_tags(tags)
|
||||
|
||||
inner_widget.on_update.connect(
|
||||
lambda: (self.update_from_entry(self.cached_entries[0].id, update_badges=True))
|
||||
lambda: self.update_from_entry(self.cached_entries[0].id, update_badges=True)
|
||||
)
|
||||
else:
|
||||
text = "<i>Mixed Data</i>"
|
||||
inner_widget = TextWidget("Mixed Tags", text)
|
||||
text = f"<i>{Translations['field.mixed_data']}</i>"
|
||||
inner_widget = TextContainerWidget("Mixed Tags", text) # NOTE: Unlocalized but unused
|
||||
container.set_inner_widget(inner_widget)
|
||||
|
||||
container.set_edit_callback()
|
||||
container.set_remove_callback()
|
||||
container.setHidden(False)
|
||||
|
||||
def remove_field(self, field: BaseField):
|
||||
def _remove_field(self, field: BaseField) -> None:
|
||||
"""Remove a field from all selected Entries."""
|
||||
logger.info(
|
||||
"[FieldContainers] Removing Field",
|
||||
@@ -468,24 +451,26 @@ class FieldContainers(QWidget):
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
self.lib.remove_entry_field(field, entry_ids)
|
||||
|
||||
def update_text_field(self, field: TextField, value: str, is_multiline: bool):
|
||||
def _update_text_field(
|
||||
self, field: TextField, name: str, value: str, is_multiline: bool
|
||||
) -> None:
|
||||
"""Update a text field across selected entries."""
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
assert entry_ids, "No entries selected"
|
||||
|
||||
self.lib.update_text_field(entry_ids, field, value, is_multiline)
|
||||
self.lib.update_text_field(entry_ids, field, name, value, is_multiline)
|
||||
|
||||
def update_datetime_field(self, field: DatetimeField, value: str):
|
||||
def update_datetime_field(self, field: DatetimeField, name: str, value: str) -> None:
|
||||
"""Update a datetime field across selected entries."""
|
||||
entry_ids = [e.id for e in self.cached_entries]
|
||||
assert entry_ids, "No entries selected"
|
||||
|
||||
self.lib.update_datetime_field(entry_ids, field, dt.fromisoformat(value))
|
||||
self.lib.update_datetime_field(entry_ids, field, name, dt.fromisoformat(value))
|
||||
|
||||
def remove_message_box(self, prompt: str, callback: Callable) -> None:
|
||||
def remove_message_box(self, prompt: str, callback: Callable[..., None]) -> None:
|
||||
remove_mb = QMessageBox()
|
||||
remove_mb.setText(prompt)
|
||||
remove_mb.setWindowTitle("Remove Field") # TODO: Localize
|
||||
remove_mb.setWindowTitle(Translations["Remove Field"])
|
||||
remove_mb.setIcon(QMessageBox.Icon.Warning)
|
||||
cancel_button = remove_mb.addButton(
|
||||
Translations["generic.cancel_alt"], QMessageBox.ButtonRole.DestructiveRole
|
||||
|
||||
@@ -2,58 +2,35 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import structlog
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PIL import ImageQt
|
||||
from PySide6.QtCore import QEvent, QSize, Qt
|
||||
from PySide6.QtGui import QEnterEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.enums import Theme
|
||||
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
|
||||
from tagstudio.qt.resource_manager import ResourceManager
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import container_style, header
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
# TODO: reference a resources folder rather than path.parents[2]?
|
||||
clipboard_icon_128: Image.Image = Image.open(
|
||||
str(Path(__file__).parents[2] / "resources/qt/images/clipboard_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
clipboard_icon_128.load()
|
||||
|
||||
edit_icon_128: Image.Image = Image.open(
|
||||
str(Path(__file__).parents[2] / "resources/qt/images/edit_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
edit_icon_128.load()
|
||||
|
||||
trash_icon_128: Image.Image = Image.open(
|
||||
str(Path(__file__).parents[2] / "resources/qt/images/trash_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
trash_icon_128.load()
|
||||
rm: ResourceManager = ResourceManager()
|
||||
copy_icon = auto_theme_overlay(rm.copy, inverse=True)
|
||||
edit_icon = auto_theme_overlay(rm.edit, inverse=True)
|
||||
trash_icon = auto_theme_overlay(rm.trash, inverse=True)
|
||||
|
||||
# TODO: There should be a global button theme somewhere.
|
||||
container_style = (
|
||||
f"QWidget#fieldContainer{{"
|
||||
"border-radius:4px;"
|
||||
f"}}"
|
||||
f"QWidget#fieldContainer::hover{{"
|
||||
f"background-color:{Theme.COLOR_HOVER.value};"
|
||||
f"}}"
|
||||
f"QWidget#fieldContainer::pressed{{"
|
||||
f"background-color:{Theme.COLOR_PRESSED.value};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.setObjectName("fieldContainer")
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
self.copy_callback: Callable[[], None] | None = None
|
||||
self.edit_callback: Callable[[], None] | None = None
|
||||
self.remove_callback: Callable[[], None] | None = None
|
||||
@@ -93,7 +70,8 @@ class FieldContainer(QWidget):
|
||||
self.copy_button.setMinimumSize(button_size, button_size)
|
||||
self.copy_button.setMaximumSize(button_size, button_size)
|
||||
self.copy_button.setFlat(True)
|
||||
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128)))
|
||||
self.copy_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.copy_icon)))
|
||||
self.copy_button.setIconSize(QSize(20, 20))
|
||||
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.copy_button)
|
||||
self.copy_button.setHidden(True)
|
||||
@@ -103,7 +81,8 @@ class FieldContainer(QWidget):
|
||||
self.edit_button.setMinimumSize(button_size, button_size)
|
||||
self.edit_button.setMaximumSize(button_size, button_size)
|
||||
self.edit_button.setFlat(True)
|
||||
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
|
||||
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.edit_icon)))
|
||||
self.edit_button.setIconSize(QSize(20, 20))
|
||||
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.edit_button)
|
||||
self.edit_button.setHidden(True)
|
||||
@@ -113,7 +92,8 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.setMinimumSize(button_size, button_size)
|
||||
self.remove_button.setMaximumSize(button_size, button_size)
|
||||
self.remove_button.setFlat(True)
|
||||
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128)))
|
||||
self.remove_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(FieldContainer.trash_icon)))
|
||||
self.remove_button.setIconSize(QSize(20, 20))
|
||||
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.title_layout.addWidget(self.remove_button)
|
||||
self.remove_button.setHidden(True)
|
||||
@@ -127,7 +107,7 @@ class FieldContainer(QWidget):
|
||||
self.inner_layout.addWidget(self.field)
|
||||
|
||||
self.set_title(title)
|
||||
self.setStyleSheet(FieldContainer.container_style)
|
||||
self.setStyleSheet(container_style())
|
||||
|
||||
def set_copy_callback(self, callback: Callable[[], None] | None = None) -> None:
|
||||
with catch_warnings(record=True):
|
||||
@@ -167,12 +147,9 @@ class FieldContainer(QWidget):
|
||||
return None
|
||||
|
||||
def set_title(self, title: str) -> None:
|
||||
self.title = self.title = f"<h4>{title}</h4>"
|
||||
self.title = header(title, 4)
|
||||
self.title_widget.setText(self.title)
|
||||
|
||||
def set_inline(self, inline: bool) -> None:
|
||||
self.inline = inline
|
||||
|
||||
@override
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
|
||||
@@ -11,13 +11,12 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from humanfriendly import format_size
|
||||
from humanfriendly import format_size # pyright: ignore[reportUnknownVariableType]
|
||||
from PIL import ImageFont
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from tagstudio.core.enums import ShowFilepathOption, Theme
|
||||
from tagstudio.core.enums import ShowFilepathOption
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.ignore import Ignore
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
@@ -25,6 +24,7 @@ from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.utils.file_opener import FileOpenerHelper, FileOpenerLabel
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import properties_style
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -48,26 +48,8 @@ class FileAttributes(QWidget):
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
|
||||
label_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_DARK_LABEL.value
|
||||
)
|
||||
|
||||
self.date_style = "font-size:12px;"
|
||||
self.date_style = "font-size: 12px;"
|
||||
self.file_label_style = "font-size: 12px"
|
||||
self.properties_style = (
|
||||
f"background-color:{label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
self.file_label = FileOpenerLabel()
|
||||
self.file_label.setObjectName("filenameLabel")
|
||||
@@ -93,7 +75,7 @@ class FileAttributes(QWidget):
|
||||
self.dimensions_label = QLabel()
|
||||
self.dimensions_label.setObjectName("dimensionsLabel")
|
||||
self.dimensions_label.setWordWrap(True)
|
||||
self.dimensions_label.setStyleSheet(self.properties_style)
|
||||
self.dimensions_label.setStyleSheet(properties_style())
|
||||
self.dimensions_label.setHidden(True)
|
||||
|
||||
self.date_container = QWidget()
|
||||
@@ -114,6 +96,7 @@ class FileAttributes(QWidget):
|
||||
if filepath and filepath.is_file():
|
||||
created: dt
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
# NOTE: Accessing stat().st_birthtime causes linter checks to fail on some systems.
|
||||
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined, unused-ignore]
|
||||
else:
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
|
||||
@@ -19,6 +19,7 @@ from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
|
||||
from tagstudio.qt.mixed.mirror_entries_modal import MirrorEntriesModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -49,7 +50,6 @@ class FixDupeFilesModal(QWidget):
|
||||
|
||||
self.dupe_count = QLabel()
|
||||
self.dupe_count.setObjectName("dupeCountLabel")
|
||||
self.dupe_count.setStyleSheet("font-weight:bold;font-size:14px;")
|
||||
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.file_label = QLabel(Translations["file.duplicates.dupeguru.no_file"])
|
||||
@@ -119,13 +119,19 @@ class FixDupeFilesModal(QWidget):
|
||||
def set_dupe_count(self, count: int):
|
||||
if count < 0:
|
||||
self.mirror_button.setDisabled(True)
|
||||
self.dupe_count.setText(Translations["file.duplicates.matches_uninitialized"])
|
||||
self.dupe_count.setText(
|
||||
header(Translations["file.duplicates.matches_uninitialized"], 4)
|
||||
)
|
||||
elif count == 0:
|
||||
self.mirror_button.setDisabled(True)
|
||||
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
|
||||
self.dupe_count.setText(
|
||||
header(Translations.format("file.duplicates.matches", count=count), 4)
|
||||
)
|
||||
else:
|
||||
self.mirror_button.setDisabled(False)
|
||||
self.dupe_count.setText(Translations.format("file.duplicates.matches", count=count))
|
||||
self.dupe_count.setText(
|
||||
header(Translations.format("file.duplicates.matches", count=count), 4)
|
||||
)
|
||||
|
||||
@override
|
||||
def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # noqa N802
|
||||
|
||||
@@ -15,6 +15,7 @@ from tagstudio.qt.mixed.progress_bar import ProgressWidget
|
||||
from tagstudio.qt.mixed.relink_entries_modal import RelinkUnlinkedEntries
|
||||
from tagstudio.qt.mixed.remove_unlinked_modal import RemoveUnlinkedEntriesModal
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if TYPE_CHECKING:
|
||||
@@ -148,7 +149,7 @@ class FixUnlinkedEntriesModal(QWidget):
|
||||
count_text: str = Translations.format(
|
||||
"entries.unlinked.unlinked_count", count=count if count >= 0 else "—"
|
||||
)
|
||||
self.unlinked_count_label.setText(f"<h3>{count_text}</h3>")
|
||||
self.unlinked_count_label.setText(header(count_text, 3))
|
||||
|
||||
@override
|
||||
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
||||
|
||||
@@ -29,6 +29,7 @@ from tagstudio.core.utils.types import unwrap
|
||||
from tagstudio.qt.models.palette import ColorType, get_tag_color
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.views.layouts.flow_layout import FlowLayout
|
||||
from tagstudio.qt.views.stylesheets.stylesheets import header
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -56,7 +57,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, ..
|
||||
return branch
|
||||
|
||||
|
||||
@deprecated("Will be replaced with upcoming 'Macros' feature before v9.6")
|
||||
@deprecated("Will be replaced with upcoming 'Macros' feature.")
|
||||
def folders_to_tags(library: Library):
|
||||
logger.info("Converting folders to Tags")
|
||||
tree = BranchData()
|
||||
@@ -177,10 +178,9 @@ class FoldersToTagsModal(QWidget):
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.title_widget = QLabel(Translations["folders_to_tags.title"])
|
||||
self.title_widget = QLabel(header(Translations["folders_to_tags.title"], 3))
|
||||
self.title_widget.setObjectName("title")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight:bold;font-size:14px;padding-top: 6px")
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
|
||||