Compare commits

...

45 Commits

Author SHA1 Message Date
Travis Abendshien
c79086f715 Fix collation data not clearing on library close 2024-07-04 17:40:19 -07:00
Travis Abendshien
9ce07bd369 Bump version to 9.3.2 2024-07-03 17:41:21 -07:00
Theasacraft
33ee27a84f Fix small bug (#306) 2024-07-03 17:02:59 -07:00
Travis Abendshien
1204d2b7b5 Fix search ignoring case of extension list 2024-06-21 11:28:12 -07:00
Travis Abendshien
501ab1f977 Update issue templates to ask for title 2024-06-16 19:54:56 -07:00
Theasacraft
b3c01e180a Update to pyside6 version 6.7.1 (#223)
* Update to pyside6.7.1

* Fix Ruff

* Fix MyPy

* Update mypy job to also use PySide6 6.7.1

* Remove unused imports

* Add Description to class

* Ruff format

* Fix Warning in pagination.py

* Probably fix Pyside app test

* Rename CustomQPushButton to QPushButtonWrapper
also renamed custom_qbutton.py to qbutton_wrapper.py
2024-06-16 16:53:38 -07:00
Jiri
4c6ebec529 refactoring: centralize field IDs (#157)
* use enum with named fields instead of ad-hoc numbers

* move tag ids into constants file
2024-06-16 14:24:48 -07:00
Travis Abendshien
5c25666e67 Fix TypeError in folders_to_tags.py
- Additionally use proper comparison syntax
2024-06-15 13:19:28 -07:00
Lennart S
fae65bd9e9 docs: Fixed broken markdown doc link (#291) 2024-06-15 13:10:55 -07:00
Jiri
8e065ca8ac create testing library files ad-hoc (#292) 2024-06-14 10:11:00 -07:00
Travis Abendshien
82946cb0b8 Update CONTRIBUTING.md with PyTest info 2024-06-13 15:39:13 -07:00
Jiri
aa2925cde0 add pytest to CI pipeline (#286) 2024-06-13 15:29:22 -07:00
Travis Abendshien
5bc80a043f Update tagstudio.spec 2024-06-13 09:17:14 -07:00
Travis Abendshien
65d88b9987 Refactor video_player.py (Fix #270) (#274)
* Refactor video_player.py

- Move icons files to qt/images folder, some being renamed
- Reduce icon loading to single initial import
- Tweak icon dimensions and animation timings
- Remove unnecessary commented code
- Remove unused/duplicate imports
- Add license info to file

* Add basic ResourceManager, use in video_player.py

* Revert tagstudio.spec changes

* Change tuple usage to dicts

* Move ResourceManager initialization steps

* Fix errant list notation
2024-06-12 23:20:17 -07:00
Travis Abendshien
37ff35fcf6 Set mouse event transparency on ItemThumbs (#279) 2024-06-12 02:00:16 -07:00
Xarvex
9b13e338bb Use bug report and feature request forms for issues (#277)
* Initial bug report

* Images can be attached in description

* Enclose label in quotes

* Wikis are no longer being used

* Use footnote to clarify what is up-to-date

* Footnotes not supported in checklist

* Initial feature request form

* Fixup grammar/wording
2024-06-11 19:32:18 -07:00
Travis Abendshien
a47b0adb6e Update icon.ico 2024-06-11 17:29:30 -07:00
Travis Abendshien
9f39bf6fdc Update README: Correct version number in figure 2024-06-11 01:27:21 -07:00
Andrew Arneson
e375166bfe Raise error if video file has 0 frames or is in valid. (#275)
video.get(cv2.CAP_PROP_FRAME_COUNT) returns 0 or -1
2024-06-10 18:10:57 -07:00
Sean Krueger
7054ffd227 Separately pin QT nixpkg version (#244)
* Add missing libraries for video player

* Pin qt6 package version to 6.6.3

Currently, this succesfully launches the program. Pinning qt seperatly
allows the rest of unstable nixpkgs to be updated even after the qt
package version has been bumped. This fixes vim failing to launch in the
nix shell because of a bad gcc version. Bumping the package version to
qt6.7.1 also will require bumping PySide to 6.7.1, otherwise it will
fail to find qt. Qt 6.7.1 nixpkg commit is 47da0aee5616a063015f10ea593688646f2377e4

* fixup: Pin Qtcreator also

QtCreator was still against nixpkgs not the specific qt variant.
2024-06-10 14:52:13 -07:00
Travis Abendshien
6d283d1f2d Add CONTRIBUTING.md, update README 2024-06-10 02:30:16 -07:00
Travis Abendshien
a0baf015db Bump version to v9.3.1 Pre-Release 2024-06-08 15:34:29 -07:00
Travis Abendshien
58be4cdb4b Bump version to v9.3.0 2024-06-08 15:21:37 -07:00
Travis Abendshien
08761d5f8a Add Landing Page When No Library Is Opened (#258)
* Add landing page when no library is open

- Add landing page when no library is open
- Add linear_gradient method
- Reformat main_window.py with spaces instead of tabs because apparently it wasn't formatted already?

* Add color_overlay methods, ClickableLabel widget

- Add color_overlay helper methods
- Add clickable_label widget
- Add docstrings to landing.py methods
- Add logo easter egg
- Refactor landing.py content

* Fix redefinition

* Fix macOS shortcut text
2024-06-08 15:18:40 -07:00
Travis Abendshien
6a680ad3d1 Increase shown tag limit from 29 to 100 (#227) 2024-06-08 12:40:37 -07:00
PencilVoid
b5ec3598e1 Add "Clear Selection" button (#259)
* Add "Clear Selection" button

* Change clear select keybind to Esc
2024-06-08 10:51:39 -07:00
Theasacraft
926dfffebe Add option to use a allowed extensions instead of ignored extensions (#251)
* Add option to use a whitelist instead of a blacklist

* maybe fix mypy?

* Fix Mypy and rename ignored_extensions

* This should fix mypy

* Update checkbox text

* Update window title

* shorten if statment and update text

* update variable names

* Fix Mypy

* hopefully fix mypy

* Fix mypy

* deprecate ignored_extensions

Co-authored-by: Jiri <yedpodtrzitko@users.noreply.github.com>

* polishing

* polishing

* Fix mypy

* finishing touches

Co-authored-by: Jiri <yedpodtrzitko@users.noreply.github.com>

* Fix boolean loading

* UI/UX + ext list loading tweaks

- Change extension list mode setting from Checkbox to ComboBox to help better convey its purpose
- Change and simplify wording
- Add type hints to extension variables and change loading to use `get()` with default values
- Sanitize older extension lists that don't use extensions with a leading "."
- Misc. code organization and docstrings

---------

Co-authored-by: Jiri <yedpodtrzitko@users.noreply.github.com>
Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
2024-06-07 18:02:28 -07:00
Xarvex
461906c349 Workflows: attempt fix for mismatched hashes in pip and bump MacOS version (#255)
* Attempted fix at mismatched hashes
Due to the seemingly random nature of the bug, this cannot be tested

* macos-11 runner has been deprecated, bump to 12
2024-06-06 12:51:08 -07:00
Travis Abendshien
2dc5197fbd Add upcoming feature documentation 2024-06-04 15:59:39 -07:00
PossiblePanda
11f0c7f9b8 Added various file formats to constants.py (#231)
* Added various file formats to constants.py

* Update tagstudio/src/core/constants.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Update tagstudio/src/core/constants.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-06-04 14:13:57 -07:00
Andrew Arneson
fb445e6ab0 Fix Default Ignored File Extension (#245)
Add item delegate for Ignored File Extension to add leading `.` if left off extension
2024-06-03 21:47:56 -07:00
Giochino Danilo Ramos Silva
6e96a0ff61 Multi mode search system (#232)
* multi search mode system

A way to change the search from requiring all tags to and of the tags

* better wording

* Update start_win.bat

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Fix home_ui.py using PySide6 instead of PyQt5

* Refresh search on mode change

* Search mode selections naming fix

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* converted SearchMode from constants to enums
2024-06-03 15:37:56 -07:00
Travis Abendshien
c75aff4db3 Rename "Subtags" to "Parent Tags"
Mentioned change in #211
2024-06-03 13:30:15 -07:00
Travis Abendshien
84a4b2f0cf Merge pull request #240 from Loran425/bugfix/cancel_library_dialog
Bugfix Open Library Dialog
2024-06-02 22:54:51 -07:00
Andrew Arneson
10b90dcc74 Bugfix for recent library re-creating a library at the last library location. (#238)
* Prevent Automatic opening of a Library if the ".TagStudio" folder has been deleted.
If the library no longer has a `.TagStudio` folder clear the Last_Library value

* Add disabling recent libraries that are missing or have missing `.TagStudio` folders

* Fix bug where this would crash if an empty library was passed

* Grabbed the wrong color
2024-06-02 22:53:42 -07:00
Andrew Arneson
2d89df620e Fix Open Library Dialog
Resolve issues where the open library dialog will try to open `.` if no path is returned from the dialog
2024-06-02 22:43:10 -06:00
Travis Abendshien
0646508c24 Fix Raw Image Handling and Improve Text File Encoding Compatibility (#233)
* Fix text and RAW image handling

- Fix RAW images not being loaded correctly in the preview panel
- Fix trying to read size data from null images
- Refactor `os.stat` to `<Path object>.stat()`
- Remove unnecessary upper/lower conversions
- Improve encoding compatibility beyond UTF-8 when reading text files
- Code cleanup

* Use chardet for character encoding detection
2024-06-02 20:18:40 -07:00
Travis Abendshien
0137ed5be8 Update README.md 2024-06-01 21:09:55 -07:00
Theasacraft
779a251c09 Fix small bugs (#228) 2024-05-31 16:45:13 -07:00
Travis Abendshien
868b553670 Duplicate Entry Handling (Fixes #179) (#204)
* Reapply "Add duplicate entry handling (Fix #179)"

This reverts commit 66ec0913b6.

* Reapply "Fix create library + type checks"

This reverts commit 57e27bb51f.

* Type and hint changes

* Remove object cast

* MyPy wrestling

* Remove type: ignore, change __eq__ cast

- Remove `type: ignore` comments from `Entry`'s `__eq__` method
- Change the cast in this method from `__value = cast(Self, object)` to `__value = cast(Self, __value)`

Co-Authored-By: Jiri <yedpodtrzitko@users.noreply.github.com>

* Fix formatting + mypy

---------

Co-authored-by: Jiri <yedpodtrzitko@users.noreply.github.com>
2024-05-29 15:47:37 -07:00
DrRetro
9f630fe315 Video Player (#149)
* Basic Video Player

* Fixes and Comments

* Fixed Bug Where Video's Audio did not stop when switching to a Image.

* Redo on VideoPlayer. Now with rounded corners.

* Fixed size not being correct when first starting video player.

* Ruff Check Fix

* Fixed Sizing Issue, and added Autoplay option in right click menu.

* Autoplay Toggle and Fixed Issue with video not stoping after closing library.

* Ruff Format

* Suggested Changes Done

* Commented out useless code that cause first warning.

* Fixed Album Art Error

* Might have found solution to Autoplay Inconsistency

* Ruff Format

* Finally Fixed Autoplay Inconsistency

* Fixed Merge Conficts

* Requested Changes and Ruff Format

* Test for new check

* Fix for PySide App Test

* More typing fixes and a few other changes.

* Ruff Format

* MyPy Fix

* MyPy Fix

* Ruff Format

* MyPy Fix

* MyPy and Ruff Fix

* Code Clean-Up and Requests completed.

* Conflict Fixes

* MyPy Fix

* Confict Fix

It appears one of the commits from main fixed the autoplay issue.
2024-05-29 13:58:09 -07:00
Icosahunter
6798ffd0a7 Replace use of os.path with pathlib (#156)
* Replace usage of os.path with usage of pathlib.Path in ts_cli.py

* Replace use of os.path with pathlib in Library.py

* Replace use of os.path with pathlib in ts_core.py

* resolve entry path/filename on creation/update

* Fix errors and bugs related to move from os.path to pathlib.

* Remove most uses of '.resolve()' as it didn't do what I thought it did

* Fix filtering in refresh directories to not need to cast to string.

* Some work on ts_qt, thumbnails don't load...

* Fixed the thumbnail issue, things seem to be working.

* Fix some bugs

* Replace some isfile with is_file ts_cli.py

* Update tagstudio/src/core/library.py

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Update library.py

* Update library.py

* Update library.py

* Update ts_cli.py

* Update library.py

* Update ts_qt

* Fix path display in delete unlinked entries modal

* Ruff formatting

* Builds and opens/creates library now

* Fix errors

* Fix ruff and mypy issues (hopefully)

* Fixed some things, broke some things

* Fixed the thumbnails not working

* Fix some new os.path instances in qt files

* Fix MyPy issues

* Fix ruff and mypy issues

* Fix some issues

* Update tagstudio/src/qt/widgets/preview_panel.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Update tagstudio/src/qt/widgets/preview_panel.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Update tagstudio/src/qt/widgets/preview_panel.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Update tagstudio/src/qt/widgets/preview_panel.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Update tagstudio/src/qt/widgets/thumb_renderer.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Update tagstudio/src/qt/widgets/thumb_renderer.py

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* Fix refresh_dupe_files issue

* Ruff format

* Tweak filepaths

- Suffix comparisons are now case-insensitive
- Restore original thumbnail extension label behavior
- Fix preview panel trying to read file size from missing files

---------

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>
Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
2024-05-26 16:17:05 -07:00
Jiri
2e8678414b feat: add select all hotkey (#217)
* add select all hotkey

* add item to selected
2024-05-26 01:00:56 -07:00
Gawi
e1cd46d010 Wiki/Docs Updates (#194)
* split file + link fix

* Cleanup & Minimum Fill

* polish & link

* Update doc/Tag.md

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

---------
2024-05-25 13:49:12 -07:00
Travis Abendshien
9879697c95 Bump version to v9.2.2 2024-05-23 00:42:00 -07:00
83 changed files with 3080 additions and 1487 deletions

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Bug Report
description: File a bug or issue report.
title: '[Bug]: '
labels: ['Type: Bug']
body:
- type: markdown
attributes:
value: |
*Please add an appropriate title for this issue.*
Before reporting, read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md) and search existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
Validate that you are using an up-to-date version[^1], your issue might already be fixed!
Questions, guidance, and usage goes in [discussions](https://github.com/TagStudioDev/TagStudio/discussions). Invalid issues will be closed.
[^1]: This can mean latest release, pre-release, or git commit.
- type: checkboxes
attributes:
label: Checklist
description: Make sure you've checked (and actually did) all of the below.
options:
- label: I am using an up-to-date version.
required: true
- label: I have read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md).
required: true
- label: I have searched existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
required: true
- type: input
attributes:
label: TagStudio Version
placeholder: Alpha 9.1.0
validations:
required: true
- type: input
attributes:
label: Operating System & Version
placeholder: TagOS 3.14
validations:
required: true
- type: textarea
attributes:
label: Description
description: Clear and concise description of the problem. Attach screenshots if needed, and include any errors you see.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: Clear and concise description of what you think should happen.
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: Minimal steps neded for the problem to occur.
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: Logs
description: Provide any logs, if applicable.

View File

@@ -0,0 +1,40 @@
name: Feature Request
description: Suggest a new feature.
title: '[Feature Request]: '
labels: ['Type: Enhancement']
body:
- type: markdown
attributes:
value: |
*Please add an appropriate title for this feature request.*
Before suggesting, read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md) and search existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
Validate that you are using an up-to-date version[^1], your feature might already be implemented!
Questions, guidance, and usage goes in [discussions](https://github.com/TagStudioDev/TagStudio/discussions). Invalid issues will be closed.
[^1]: This can mean latest release, pre-release, or git commit.
- type: checkboxes
attributes:
label: Checklist
description: Make sure you've checked (and actually did) all of the below.
options:
- label: I am using an up-to-date version.
required: true
- label: I have read the [documentation](https://github.com/TagStudioDev/TagStudio/blob/main/doc/index.md).
required: true
- label: I have searched existing [issues](https://github.com/TagStudioDev/TagStudio/issues).
required: true
- type: textarea
attributes:
label: Description
description: Clear and concise description of the problem or missing capability. Attach screenshots if needed, and explain your own use case.
validations:
required: true
- type: textarea
attributes:
label: Solution
description: Clear and concise description of what you think should happen.
- type: textarea
attributes:
label: Alternatives
description: Any considered alternative solutions or workarounds. If undesirable, why?

View File

@@ -32,11 +32,12 @@ jobs:
libxcb-render-util0 \
libxcb-xinerama0 \
libopengl0 \
libxcb-cursor0
libxcb-cursor0 \
libpulse0
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -Ur requirements.txt
- name: Run TagStudio app and check exit code
run: |

View File

@@ -16,11 +16,13 @@ jobs:
with:
reviewdog_version: latest
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# pyside 6.6.3 has some issue in their .pyi files
pip install PySide6==6.6.2
pip install -r requirements.txt
pip install mypy==1.10.0
mkdir tagstudio/.mypy_cache

22
.github/workflows/pytest.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: pytest
on: [push, pull_request]
jobs:
pytest:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run tests
run: |
pytest tagstudio/tests/

View File

@@ -23,6 +23,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -Ur requirements.txt pyinstaller
- run: pyinstaller tagstudio.spec -- ${{ matrix.build-flag }}
- run: tar czfC dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz dist tagstudio
@@ -34,20 +35,22 @@ jobs:
macos:
strategy:
matrix:
os-version: ['11', '14']
os-version: ['12', '14']
include:
- os-version: '11'
- os-version: '12'
arch: x86_64
- os-version: '14'
arch: aarch64
runs-on: macos-${{ matrix.os-version }}
env:
# even though we run on 12, target towards compatibility
MACOSX_DEPLOYMENT_TARGET: '11.0'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -Ur requirements.txt pyinstaller
- run: pyinstaller tagstudio.spec
- run: tar czfC dist/tagstudio_macos_${{ matrix.arch }}.tar.gz dist TagStudio.app
@@ -75,6 +78,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -Ur requirements.txt pyinstaller
- run: PyInstaller tagstudio.spec -- ${{ matrix.build-flag }}
- run: Compress-Archive -Path dist/TagStudio${{ matrix.file-end }} -DestinationPath dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip

1
.gitignore vendored
View File

@@ -55,6 +55,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
tagstudio/tests/fixtures/library/*
# Translations
*.mo

170
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,170 @@
# Contributing to TagStudio
_Last Updated: June 10th, 2024_
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.
## Getting Started
- Check the [Planned Features](https://github.com/TagStudioDev/TagStudio/blob/main/doc/updates/planned_features.md) page, [FAQ](/README.md/#faq), as well as the open [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 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.
### Contribution Checklist
- I've read the [Planned Features](https://github.com/TagStudioDev/TagStudio/blob/main/doc/updates/planned_features.md) page
- I've read the [FAQ](/README.md/#faq), including the "[Features I Likely Won't Add/Pull](/README.md/#features-i-likely-wont-addpull)" section
- I've checked the open [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 _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 and Mypy
- I've read the [Code Guidelines](#code-guidelines) and/or [Documentation Guidelines](#documentation-guidelines)
- **_I mean it, I've found or created a new issue for my feature!_**
## Creating a Development Environment
### Prerequisites
- [Python](https://www.python.org/downloads/) 3.12
- [Ruff](https://github.com/astral-sh/ruff) (Included in `requirements-dev.txt`)
- [Mypy](https://github.com/python/mypy) (Included in `requirements-dev.txt`)
- [PyTest](https://docs.pytest.org) (Included in `requirements-dev.txt`)
### Creating a Python Virtual Environment
If you wish to launch the source version of TagStudio outside of your IDE:
> [!IMPORTANT]
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3` for consistency. You can check to see which alias your system uses and if it's for the correct Python version by typing `python3 --version` (or whichever alias) into your terminal.
> [!TIP]
> On Linux and macOS, you can launch the `tagstudio.sh` script to skip the following process, minus the `requirements-dev.txt` installation step. _Using the script is fine if you just want to launch the program from source._
1. In the root repository directory, create a python virtual environment:
`python3 -m venv .venv`
2. Activate your environment:
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
- Linux/macOS: `source .venv/bin/activate`
3. Install the required packages:
- `pip install -r requirements.txt`
- If developing (includes Ruff and Mypy): `pip install -r requirements-dev.txt`
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
### Manually Launching (Outside of an IDE)
- **Windows** (start_win.bat)
- To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.
- **Linux/macOS** (TagStudio.sh)
- Run the "TagStudio.sh" script and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`.
- **NixOS** (TagStudio.sh)
> [!WARNING]
> Support for NixOS is still a work in progress.
- Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the `TagStudio.sh` script.
- **Any** (No Scripts)
- Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tag_studio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`.
## 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.
> [!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](https://github.com/astral-sh/ruff)
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.
#### Running Locally
- Lint code with by moving into the `/tagstudio` directory with `cd tagstudio` and running `ruff --config ../pyproject.toml`.
- Format code with `ruff format` inside the repository directory
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:** Move into the `/tagstudio` directory with `cd tagstudio` and run the following:
- `mkdir -p .mypy_cache`
- `mypy --install-types --non-interactive`
- Check code by moving into the `/tagstudio` directory with `cd tagstudio` _(if you aren't already inside)_ and running `mypy --config-file ../pyproject.toml .`. _(Don't forget the `.` at the end!)_
> [!CAUTION]
> There's a known issue between PySide v6.6.3 and Mypy where Mypy will detect issues with the `.pyi` files inside of PySide and prematurely stop checking files. This issue is not present in PySide v6.6.2, which _should_ be compatible with everything else if you wish to try using that version in the meantime.
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 moving into the `/tagstudio` directory with `cd tagstudio` and running `pytest tests/`.
## Code Guidelines
### Style
Most of the style guidelines can be checked, fixed, and enforced via Ruff. Older code may not be adhering to all of these guidelines, in which case _"do as I say, not as I do"..._
- Do your best to write clear, concise, and modular code.
- Try to keep a maximum column with of no more than **100** characters.
- Code comments should be used to help describe sections of code that don't speak for themselves.
- Use [Google style](https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings) docstrings for any classes and functions you add.
- If you're modifying an existing function that does _not_ have docstrings, you don't _have_ to add docstrings to it... but it would be pretty cool if you did ;)
- Imports should be ordered alphabetically (in newly created python files).
- When writing text for window titles, form titles, or dropdown options, 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/)!
> [!WARNING]
> Column width limits, docstring formatting, and import sorting aren't currently checked in the Ruff workflow but likely will be in the near future.
### Implementations
- Avoid direct calls to `os`
- Use `Pathlib` library instead of `os.path`
- Use `sys.platform` instead of `os.name`
- Don't prepend local imports with `tagstudio`, stick to `src`
- Use `logging` instead of `print` statements
- Avoid nested `f-string`s
#### Runtime
- Code must function on supported versions of Windows, macOS, and Linux:
- Windows: 10, 11
- macOS: 12.0+
- Linux: TBD
- Avoid use of unnecessary logging statements in final submitted code.
- Code should not cause unreasonable slowdowns to the program outside of a progress-indicated task.
#### Git/GitHub Specifics
- 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.
- Use imperative-style present-tense commit messages. Examples:
- "Add feature foo"
- "Change method bar"
- "Fix function foobar"
- 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.
## Documentation Guidelines
Documentation contributions include anything inside of the `doc/` folder, as well as the `README.md` and `CONTRIBUTING.md` files.
- Use "[snake_case](https://developer.mozilla.org/en-US/docs/Glossary/Snake_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
- Use "[Title Case](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)" for title capitalization
## Translation Guidelines
_TBA_

View File

@@ -10,15 +10,18 @@
TagStudio is a photo & file organization application with an underlying 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.
<p align="center">
<img width="80%" src="screenshot.jpg">
</p>
<figure align="center">
<img width="80%" src="screenshot.jpg" alt="TagStudio Screenshot" align="center">
<figcaption><i>TagStudio Alpha v9.1.0 running on Windows 10.</i></figcaption>
</figure>
## Contents
- [Goals](#goals)
- [Priorities](#priorities)
- [Current Features](#current-features)
- [Contributing](#contributing)
- [Installation](#installation)
- [Usage](#usage)
- [FAQ](#faq)
@@ -50,13 +53,17 @@ TagStudio is a photo & file organization application with an underlying system t
- Special search conditions for entries that are: `untagged`/`no tags` and `empty`/`no fields`.
> [!NOTE]
> For more information on the project itself, please see the [FAQ](#faq) section and other docs.
> For more information on the project itself, please see the [FAQ](#faq) section as well as the [documentation](/doc/index.md).
## Contributing
If you're interested in contributing to TagStudio, please take a look at the [contribution guidelines](/CONTRIBUTING.md) for how to get started!
## Installation
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
> [!NOTE]
> [!IMPORTANT]
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ 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 _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
#### Optional Arguments
@@ -67,7 +74,7 @@ Optional arguments to pass to the program.
> Path to a TagStudio Library folder to open on start.
> `--config-file <path>` / `-c <path>`
> Path to a TagStudio Library folder to open on start.
> Path to the TagStudio config file to load.
## Usage
@@ -112,9 +119,6 @@ To create a new tag, click on Edit -> New Tag from the menu bar. From there, ent
To edit a tag, right-click the tag in the tag field of the preview pane and select “Edit Tag”
> [!WARNING]
> There is currently no method to view all tags that youve created in your library. This is a top priority for future releases.
### Relinking Renamed/Moved Files
Inevitably, some of the files inside your library will be renamed, moved, or deleted. If a file has been renamed or moved, TagStudio will display the thumbnail as a red tag with a cross through it _(this icon is also used for items with broken thumbnails)._ To relink moved files or delete these entries, go to Tools -> Manage Unlinked Entries. Click the “Refresh” button to scan your library for unlinked entries. Once complete, you can attempt to “Search & Relink” any unlinked entries to their respective files, or “Delete Unlinked Entries” in the event the original files have been deleted and you no longer wish to keep their metadata entries inside your library.
@@ -143,7 +147,7 @@ Load in a .dupeguru file generated by [dupeGuru](https://github.com/arsenetar/du
Create an image collage of your photos and videos.
> [!CAUTION]
> Collage sizes and options are hardcoded.
> Collage sizes and options are hardcoded, and there's no GUI indicating the process of the collage creation.
#### Macros
@@ -159,64 +163,21 @@ Import JSON sidecar data generated by [gallery-dl](https://github.com/mikf/galle
> [!CAUTION]
> This feature is not supported or documented in any official capacity whatsoever. It will likely be rolled-in to a larger and more generalized sidecar importing feature in the future.
## Creating a Development Environment
## Launching/Building From Source
If you're interested in contributing to TagStudio or just wish to poke around the live codebase, here are instructions for setting up the Python project.
### Prerequisites
- Python 3.12
### Creating a Python Virtual Environment
> [!NOTE]
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3`. You can check to see which alias you system uses and if it's for the correct Python version by typing `python3 --version` (or whichever alias) into your terminal.
_Skip these steps if launching from the .sh script on Linux/macOS._
1. In the root repository directory, create a python virtual environment:
`python3 -m venv .venv`
2. Activate your environment:
- Windows w/Powershell: `.venv\Scripts\Activate.ps1`
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
- Linux/macOS: `source .venv/bin/activate`
3. Install the required packages:
- required to run the app: `pip install -r requirements.txt`
- required to develop: `pip install -r requirements-dev.txt`
To run all the tests use `python -m pytest tests/` from the `tagstudio` folder.
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
### Launching Development Environment
- **Windows** (start_win.bat)
- To launch TagStudio, launch the `start_win.bat` file. You can modify this .bat file or create a shortcut and add one or more additional arguments if desired.
- **Linux/macOS** (TagStudio.sh)
- Run the "TagStudio.sh" script, and the program should launch! (Make sure that the script is marked as executable if on Linux). Note that launching from the script from outside of a terminal will not launch a terminal window with any debug or crash information. If you wish to see this information, just launch the shell script directly from your terminal with `./TagStudio.sh`.
- **NixOS** (TagStudio.sh)
- Use the provided `flake.nix` file to create and enter a working environment by running `nix develop`. Then, run the `TagStudio.sh` script.
- **Any** (No Scripts)
- Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tag_studio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tag_studio.py`.
See instructions in the "[Creating Development Environment](/CONTRIBUTING.md/#creating-a-development-environment)" section from the [contribution documentation](/CONTRIBUTING.md).
## FAQ
### What State Is the Project Currently In?
As of writing (Alpha v9.2.0) the project is in a useable state, however it lacks proper testing and quality of life features.
As of writing (Alpha v9.3.0) the project is in a useable state, however it lacks proper testing and quality of life features.
### What Features Are You Planning on Adding?
> [!IMPORTANT]
> See the [Planned Features](/doc/updates/planned_features.md) documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features.
Of the several features I have planned for the project, these are broken up into “priority” features and “future” features. Priority features were originally intended for the first public release, however are currently absent from the Alpha v9.x.x builds.
#### Priority Features
@@ -281,18 +242,3 @@ Ive been developing this project over several years in private, and have gone
### Wait, Is There a CLI Version?
As of right now, **no**. However, I _did_ have a CLI version in the recent past before dedicating my efforts to the Qt GUI version. Ive left in the currently-inoperable CLI code just in case anyone was curious about it. Also yes, its just a bunch of glorified print statements (_the outlook for some form of curses on Windows didnt look great at the time, and I just needed a driver for the newly refactored code...)._
### Can I Contribute?
**Yes!!** I recommend taking a look at the [Priority Features](#priority-features), [Future Features](#future-features), and [Features I Won't Pull](#features-i-likely-wont-addpull) lists, as well as the project issues to see whats currently being worked on. Please do not submit pull requests with new feature additions without opening up an issue with a feature request first.
Code formatting is automatically checked via [Ruff](https://docs.astral.sh/ruff/).
To format the code manually, install ruff via `pip install -r requirements-dev.txt` and then run `ruff format`
To format the code automatically before each commit, there's a configured action available for `pre-commit` hook. Install it by running `pre-commit install`. The hook will be executed each time on running `git commit`.
More structured documentation on contribution requirements is on its way, but for now:
- Use `pathlib` in favor of `os.path`
- Try to make new UI additions match the existing style of the application

BIN
doc/assets/db_schema.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,189 +0,0 @@
# TagStudio Documentation (Alpha v9.1.0)
## _A User-Focused Document Management System_
> [!WARNING]
> This documentation is still a work in progress, and is intended to aide with deconstructing and understanding of the core mechanics of TagStudio and how it operates.
## Contents
- [Library](#library)
- [Fields](#fields)
- [Entries](#entries)
- [Tags](#tags)
- [Retrieving Entries](#retrieving-entries-based-on-tag-cluster)
- [Missing File Resolution](#missing-file-resolution)
## Library
The Library is how TagStudio represents your chosen directory. In this Library or Vault system, all files within this directory are represented by Entries, which then contain metadata Fields. All TagStudio data for a Library is stored within a `.TagStudio` folder at the root of the Library's directory. Internal Library objects include:
- Fields (v9+)
- Text Line (Title, Author, Artist, URL)
- Text Box (Description, Notes)
- Tag Box (Tags, Content Tags, Meta Tags)
- Datetime (Date Created, Date Modified, Date Taken) [WIP]
- Collation (Collation) [WIP]
- `name: str`: Collation Name
- `page: int`: Page #
- Checkbox (Archive, Favorite) [WIP]
- Drop Down (Group of Tags to select one from) [WIP]
- Entries (v1+)
- Tags (v7+)
- Macros (v9/10+)
## Fields
Fields are the building blocks of metadata stored in Entires. Fields have several base types for representing different types of information, including:
- `text_line`
- A string of text, displayed as a single line.
- Useful for Titles, Authors, URLs, etc.
- `text_box`
- A long string of text displayed as a box of text.
- Useful for descriptions, notes, etc.
- `datetime` [WIP]
- A date and time value.
- `tag_box`
- A box of tags added by the user.
- Multiple tag boxes can be used to separate classifications of tags, ex. 'Content Tags' and 'Meta Tags'.
- `checkbox` [WIP]
- A two-state checkbox.
- Can be associated with a tag for quick organization.
- `collation` [WIP]
- A collation is a collection of files that are intended to be displayed and retrieved together. Examples may include pages of a book or document that are spread out across several individual files. If you're intention is to associate files across multiple 'collations', use Tags instead!
## Entries
Entries are the representations of your files within the Library. They consist of a reference to the file on your drive, as well as the metadata associated with it.
### Entry Object Structure (v9):
- `id`:
- ID for the Entry.
- Int, Unique, Required
- Used for internal processing
- `filename`:
- The filename with extension of the referenced media file.
- String, Required
- `path`:
- The folder path in which the media file is located in.
- String, Required, OS Agnostic
- `fields`:
- A list of Field ID/Value dicts.
- List of dicts, Optional
NOTE: _Entries currently have several unused optional fields intended for later features._
## Tags
**Tags** are small data objects that represent an attribute of something. A person, place, thing, concept, you name it! Tags in TagStudio allow for more sophisticated Entry organization and searching thanks to their ability to contain alternate names and spellings via `aliases`, relational organization thanks to inherent `subtags`, and more! Tags can be as simple or as powerful as you want to make them, and TagStudio aims to provide as much power to you as possible.
### Tag Object Structure (v9):
- `id`:
- ID for the Tag.
- Int, Unique, Required
- Used for internal processing
- `name`:
- The normal name of the Tag, with no shortening or specification.
- String, Required
- Doesn't have to be unique
- Each word analyzed individually
- Used for display, searching, and storing
- `shorthand`:
- The shorthand name for the Tag.
- String, Optional
- Doesn't have to be unique
- Entire string analyzed as-is
- Used for display and searching
- `aliases`:
- Alternate names for the Tag.
- List of Strings, Optional
- Recommended to be unique to this Tag
- Entire string analyzed as-is
- Used for searching
- `subtags`:
- Other Tags that make up properties of this Tag.
- List of Strings, Optional
- Used for display (first subtag only) and searching.
- `color`:
- A hex code value for customizing the Tag's display color
- String, Optional
- Used for display
### Tag Examples:
#### League of Legends
- `name`: "League of Legends"
- `shorthand`: "LoL"
- `aliases`: ["League"]
- `subtags`: ["Game", "Fantasy"]
#### Arcane
- `name`: "Arcane"
- `shorthand`: ""
- `aliases`: []
- `subtags`: ["League of Legends", "Cartoon"]
#### Jinx (LoL)
- `name`: "Jinx Piltover"
- `shorthand`: "Jinx"
- `aliases`: ["Jinxy", "Jinxy Poo"]
- `subtags`: ["League of Legends", "Arcane", "Character"]
#### Zander (Arcane)
- `name`: "Zander Zanderson"
- `shorthand`: "Zander"
- `aliases`: []
- `subtags`: ["Arcane", "Character"]
#### Mr. Legend (LoL)
- `name`: "Mr. Legend"
- `shorthand`: ""
- `aliases`: []
- `subtags`: ["League of Legends", "Character"]
### Query "League of Legends" returns results for:
- League of Legends [because of "League of Legend"'s name]
- Arcane [because of "Arcane"'s subtag]
- Jinx (LoL) [because of "Jinx Piltover"'s subtag]
- Mr. Legend (LoL) [because of "Mr. Legned (LoL)'s subtag"]
- Zander (Arcane) [because of "Zander Zanderson"'s subtag ("Arcane")'s subtag]
### Query "LoL" returns results for:
- League of Legends [because of "League of Legend"'s shorthand]
- LoL [because of "League of Legend"'s shorthand]
- Arcane [because of "Arcane"'s subtag]
- Jinx (LoL) [because of "Jinx Piltover"'s subtag]
- Mr. Legend (LoL) [because of "Mr. Legned (LoL)'s subtag"]
- Zander (Arcane) [because of "Zander Zanderson"'s subtag ("Arcane")'s subtag]
### Query "Arcane" returns results for:
- Arcane [because of "Arcane"'s name]
- Jinx (LoL) [because of "Jinx Piltover"'s subtag "Arcane"]
- Zander (Arcane) [because of "Zander Zanderson"'s subtag]
## Retrieving Entries based on Tag Cluster
By default when querying Entries, each Entry's `tags` list (stored in the form of Tag `id`s) is compared against the Tag `id`s in a given Tag cluster (list of Tag `id`s) or appended clusters in the case of multi-term queries. The type of comparison depends on the type of query and whether or not it is an inclusive or exclusive query, or a combination of both. This default searching behavior is done in _O(n)_ time, but can be sped up in the future by building indexes on certain search terms. These indexes can be stored on disk and loaded back into memory in future sessions. These indexes will also need to be updated as new Tags and Entries are added or edited.
## Missing File Resolution
1. Refresh missing file list (`refresh missing`) (Automatically run if library has few entries)
2. Fix missing files screen (`fix missing`)
### Fix Missing Files Screen
0. **Match Search** (Determines if entries can be fixed) Scans for filename in library directory
1. **Quick Fixes** (one match found, no existing entry)
2. **Match Selection** (multiple matches found)
3. **Merge Conflict Resolution** (match has existing entry)
Any remaining missing files can be listed, but they probably really are missing at this point. You can update the path and filename to point to new files if you know where they should actually be pointing to.

24
doc/index.md Normal file
View File

@@ -0,0 +1,24 @@
# Welcome to the TagStudio Documentation!
> [!WARNING]
> This documentation is still a work in progress, and is intended to aide with deconstructing and understanding of the core mechanics of TagStudio and how it operates.
<div align="center">
<img src="../github_header.png" alt="TagStudio Alpha" height="100">
<img src="https://i0.wp.com/www.bapl.org/wp-content/uploads/2019/02/old-under-construction-gif.gif" alt="Under Construction" height="100">
</div>
## Table of Contents
- [Library](/doc/library/library.md)
- [Entries](/doc/library/entry.md)
- [Fields](/doc/library/field.md)
- [Tags](/doc/library/tag.md)
- [Tools & Macros](/doc/utilities/macro.md)
- [Planned Features](/doc/updates/planned_features.md)
---
### [Database Migration](/doc/updates/db_migration.md)
The "Database Migration", "DB Migration", or "SQLite Migration" is an upcoming update to TagStudio which will replace the current JSON [library](/doc/library/library.md) with a SQL-based one, and will additionally include some fundamental changes to how some features such as [tags](/doc/library/tag.md) will work.

25
doc/library/entry.md Normal file
View File

@@ -0,0 +1,25 @@
# Entry
Entries are the units that fill a [library](/doc/library/library.md). Each one corresponds to a file, holding a reference to it along with the metadata associated with it.
### Entry Object Structure
1. `id`:
- Int, Unique, **Required**
- The ID for the Entry.
- Used for internal processing
2. `filename`:
- String, **Required**
- The filename with extension of the referenced media file.
3. `path`:
- String, **Required**, OS Agnostic
- The folder path in which the media file is located in.
4. [`fields`](/doc/library/field.md):
- List of dicts, Optional
- A list of Field ID/Value dicts.
NOTE: _Entries currently have several unused optional fields intended for later features._
## Retrieving Entries based on [Tag](/doc/library/tag.md) Cluster
By default when querying Entries, each Entry's `tags` list (stored in the form of Tag `id`s) is compared against the Tag `id`s in a given Tag cluster (list of Tag `id`s) or appended clusters in the case of multi-term queries. The type of comparison depends on the type of query and whether or not it is an inclusive or exclusive query, or a combination of both. This default searching behavior is done in _O(n)_ time, but can be sped up in the future by building indexes on certain search terms. These indexes can be stored on disk and loaded back into memory in future sessions. These indexes will also need to be updated as new Tags and Entries are added or edited.

View File

@@ -0,0 +1,3 @@
# Entry Groups (Upcoming Feature)
Entries can be grouped via tags marked as “groups” which when applied to different entries will signal TagStudio to treat those entries as a single group inside of searches and browsing.

34
doc/library/field.md Normal file
View File

@@ -0,0 +1,34 @@
# Field
Fields are the building blocks of metadata stored in [entries](/doc/library/entry.md). Fields have several base types for representing different kinds of information, including:
#### `text_line`
- A string of text, displayed as a single line.
- e.g: Title, Author, Artist, URL, etc.
#### `text_box`
- A long string of text displayed as a box of text.
- e.g: Description, Notes, etc.
#### `tag_box`
- A box of [tags](/doc/library/tag.md) defined and added by the user.
- Multiple tag boxes can be used to separate classifications of tags.
- e.g: Content Tags, Meta Tags, etc.
#### `datetime` [WIP]
- A date and time value.
- e.g: Date Created, Date Modified, Date Taken, etc.
#### `checkbox` [WIP]
- A simple two-state checkbox.
- Can be associated with a tag for quick organization.
- e.g: Archive, Favorite, etc.
#### `collation` [obsolete]
- Previously used for associating files to be used in a [collation](/doc/utilities/macro.md#create-collage), will be removed in favor of a more flexible feature in future updates.

11
doc/library/library.md Normal file
View File

@@ -0,0 +1,11 @@
# Library
The library is how TagStudio represents your chosen directory, with every file inside of it being displayed as an [entry](/doc/library/entry.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.
Note that this means [tags](/doc/library/tag.md) you create only exist _per-library_.
### Library Contents
- [Entries](/doc/library/entry.md)
- [Fields](/doc/library/field.md)
- [Tags](/doc/library/tag.md)
- [Macros](/doc/utilities/macro.md)

85
doc/library/tag.md Normal file
View File

@@ -0,0 +1,85 @@
# Tag
Tags are user-defined attributes made up of one or more keywords, aliases, and relationships to other tags. A person, place, thing, concept, you name it! Tags allow for a more sophisticated way to organize and search [entries](/doc/library/entry.md) thanks to their aliases, parent tags, and more.
Tags can be as simple or complex as wanted, so that any user can tune TagStudio to fit their needs.
Among the things that make tags so useful, aliases give the ability to contain alternate names and spellings, making searches intuitive and expansive. Furthermore, parent-tags/subtags offer relational organization capabilities for the structuring and connection of the [library's](/doc/library/library.md) contents.
## Tag Object Structure
#### `id`
ID for the tag.
- Int, Unique, Required
- Used for internal processing
#### `name`
The normal name of the tag, with no shortening or specification.
- String, Required
- Doesn't have to be unique
- Used for display, searching, and storing
#### `shorthand`
The shorthand name for the tag. Works like an alias but is used for specific display purposes.
- String, Optional
- Doesn't have to be unique
- Used for display and searching
#### `aliases`
Alternate names for the tag.
- List of Strings, Optional
- Recommended to be unique to this tag
- Used for searching
#### `subtags`
Other Tags that make up properties of this tag. Also called "parent tags".
- List of Strings, Optional
- Used for display (first parent tag only) and searching.
#### `color`
A color name string for customizing the tag's display color
- String, Optional
- Used for display
## Tag Search Examples:
Using for example, a library of files including some tagged with the following tags:
| Tag | `name` | `shorthand` | `aliases` | `parent tags` |
| ------------------- | ------------------- | ----------- | ---------------------- | -------------------------------------------- |
| _League of Legends_ | "League of Legends" | "LoL" | ["League"] | ["Game", "Fantasy"] |
| _Arcane_ | "Arcane" | "" | [] | ["League of Legends", "Cartoon"] |
| _Jinx (LoL)_ | "Jinx Piltover" | "Jinx" | ["Jinxy", "Jinxy Poo"] | ["League of Legends", "Arcane", "Character"] |
| _Zander (Arcane)_ | "Zander Zanderson" | "Zander" | [] | ["Arcane", "Character"] |
| _Mr. Legend (LoL)_ | "Mr. Legend" | "" | [] | ["League of Legends", "Character"] |
**The query "Arcane" will display results tagged with:**
| Tag | Cause of Inclusion | Tag Tree Lineage |
| --------------- | -------------------------------- | -------------------------- |
| Arcane | Direct match of tag name | "Arcane" |
| Jinx (LoL) | Search term is set as parent tag | "Jinx (LoL) > Arcane" |
| Zander (Arcane) | Search term is set as parent tag | "Zander (Arcane) > Arcane" |
**The query "League of Legends" will display results tagged with:**
| Tag | Cause of Inclusion | Tag Tree Lineage |
| ----------------- | ------------------------------------------------------ | ---------------------------------------------- |
| League of Legends | Direct match of tag name | "League of Legends" |
| Arcane | Search term is set as parent tag | "Arcane > League of Legends" |
| Jinx (LoL) | Search term is set as parent tag | "Jinx (LoL) > League of Legends" |
| Mr. Legend (LoL) | Search term is set as parent tag | "Mr. Legend (LoL) > League of Legends" |
| Zander (Arcane) | Search term is a parent tag of a tag set as parent tag | "Zander (Arcane) > Arcane > League of Legends" |
Note: The query "LoL" will display the same results as the above example since "LoL" is the shorthand for "League of Legends".

View File

@@ -0,0 +1,3 @@
# Tag Categories (Upcoming Feature)
Replaces [Tag Fields](/doc/library/field.md#tag_box). Tags are able to be marked as a “category” which then displays as tag fields currently do, with any tags inheriting from that category being displayed underneath.

View File

@@ -0,0 +1,16 @@
# Tag Overrides (Upcoming Feature)
Tag overrides are the ability to add or remove [parent tags](/doc/library/tag.md#subtags) from a [tag](/doc/library/tag.md) on a per- [entry](/doc/library/entry.md) basis. Relies on the [Database Migration](/doc/updates/db_migration.md) update being complete.
## Examples
<figure>
<img src="../assets/tag_override_ex-1.png" alt="Example 1" height="300">
<figcaption>Ex. 1 - Comparing standard tag composition vs additive and subtractive inheritance overrides.</figcaption>
</figure>
<figure>
<img src="../assets/tag_override_ex-2.png" alt="Example 2" height="300">
<figcaption>Ex. 2 - Parent tag swap using tag overrides.</figcaption>
</figure>

View File

@@ -0,0 +1,43 @@
# Database Migration
The database migration is an upcoming refactor to TagStudio's library data storage system. The database will be migrated from a JSON-based one to a SQLite-based one. Part of this migration will include a reworked schema, which will allow for several new features and changes to how [tags](/doc/library/tag.md) and [fields](/doc/library/field.md) operate.
## Schema
<img src="../assets/db_schema.png" alt="Database Schema" width="500">
### `alias` Table
_Description TBA_
### `entry` Table
_Description TBA_
### `entry_attribute` Table
_Description TBA_
### `entry_page` Table
_Description TBA_
### `location` Table
_Description TBA_
### `tag` Table
_Description TBA_
### `tag_relation` Table
_Description TBA_
## Resulting New Features and Changes
- Multiple Directory Support
- [Tag Categories](/doc/library/tag_categories.md) (Replaces [Tag Fields](/doc/library/field.md#tag_box))
- [Tag Overrides](/doc/library/tag_overrides.md)
- User-Defined [Fields](/doc/library/field.md)
- Tag Icons

View File

@@ -0,0 +1,59 @@
# Planned Features
The following lists outline the planned major and minor features for TagStudio, in no particular order.
# Major Features
- [SQL Database Migration](/doc/updates/db_migration.md)
- Multiple Directory Support
- [Tags Categories](/doc/library/tag_categories.md)
- [Entry Groups](/doc/library/entry_groups.md)
- [Tag Overrides](/doc/library/tag_overrides.md)
- Tagging Panel
- Top Tags
- Recent Tags
- Tag Search
- Pinned Tags
- Configurable Default Fields (May be part of [Macros](/doc/utilities/macro.md))
- Deep File Extension Control
- Settings Menu
- Custom User Colors
- Search Engine Rework
- Boolean Search
- Tag Objects In Search
- Search For Fields
- Sortable Search Results
- Automatic Entry Relinking
- Detect Renames
- Detect Moves
- Thumbnail Caching
- User-Defined Fields
- Exportable Library/Tag Data
- Exportable Human-Readable Library
- Exportable/Importable Human-Readable “Tag Packs”
- Exportable/Importable Color Palettes
- Configurable Thumbnail Labels
- Toggle Extension Label
- Toggle File Size Label
- Configurable Thumbnail Tag Badges
- Customize tags that appear instead of just “Archive” and “Favorite”
- OCR Search
## Minor Features
- Deleting Tags
- Merging Tags
- Tag Icons
- Tag/Field Copy + Paste
- Collage UI
- Resizable Thumbnail Grid
- Draggable Files Outside The Program
- File Property Caching
- 3D Previews
- Audio Waveform Previews
- Toggle Between Waveform And Album Artwork
- PDF Previews
- SVG Previews
- Full Video Player
- Duration Properties For Video + Audio Files
- Optional Starter Tag Packs

43
doc/utilities/macro.md Normal file
View File

@@ -0,0 +1,43 @@
# Tools & Macros
Tools and macros are features that serve to create a more fluid [library](/doc/library/library.md)-managing process, or provide some extra functionality. Please note that some are still in active development and will be fleshed out in future updates.
## Tools
### Fix Unlinked Entries
This tool displays the number of unlinked [entries](/doc/library/entry.md), and some options for their resolution.
1. Refresh
- Scans through the library and updates the unlinked entry count.
2. Search & Relink
- Attempts to automatically find and reassign missing files.
3. Delete Unlinked Entries
- Displays a confirmation prompt containing the list of all missing files to be deleted before committing to or cancelling the operation.
### Fix Duplicate Files
This tool allows for management of duplicate files in the library using a [DupeGuru](https://dupeguru.voltaicideas.net/) file.
1. Load DupeGuru File
- load the "results" file created from a DupeGuru scan
2. Mirror Entries
- Duplicate entries will have their contents mirrored across all instances. This allows for duplicate files to then be deleted with DupeGuru as desired, without losing the [field](/doc/library/field.md) data that has been assigned to either. (Once deleted, the "Fix Unlinked Entries" tool can be used to clean up the duplicates)
### Create Collage
This tool is a preview of an upcoming feature. When selected, TagStudio will generate a collage of all the contents in a Library, which can be found in the Library folder ("/your-folder/.TagStudio/collages/"). Note that this feature is still in early development, and doesn't yet offer any customization options.
## Macros
### Auto-fill [WIP]
Tool is in development and will be documented in future update.
### Sort fields
Tool is in development, will allow for user-defined sorting of [fields](/doc/library/field.md).
### Folders to Tags
Creates tags from the existing folder structure in the library, which are previewed in a hierarchy view for the user to confirm. A tag will be created for each folder and applied to all entries, with each subfolder being linked to the parent folder as a [parent tag](/doc/library/tag.md#subtags). Tags will initially be named after the folders, but can be fully edited and customized afterwards.

25
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1712473363,
"narHash": "sha256-TIScFAVdI2yuybMxxNjC4YZ/j++c64wwuKbpnZnGiyU=",
"lastModified": 1717602782,
"narHash": "sha256-pL9jeus5QpX5R+9rsp3hhZ+uplVHscNJh8n8VpqscM0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e89cf1c932006531f454de7d652163a9a5c86668",
"rev": "e8057b67ebf307f01bdcc8fba94d94f75039d1f6",
"type": "github"
},
"original": {
@@ -16,9 +16,26 @@
"type": "github"
}
},
"qt6Nixpkgs": {
"locked": {
"lastModified": 1711460435,
"narHash": "sha256-Qb/J9NFk2Qemg7vTl8EDCto6p3Uf/GGORkGhTQJLj9U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f862bd46d3020bcfe7195b3dad638329271b0524",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f862bd46d3020bcfe7195b3dad638329271b0524",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"qt6Nixpkgs": "qt6Nixpkgs"
}
}
},

View File

@@ -1,9 +1,18 @@
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs, }:
qt6Nixpkgs = {
# Commit bumping to qt6.6.3
url = "github:NixOS/nixpkgs/f862bd46d3020bcfe7195b3dad638329271b0524";
};
};
outputs = { self, nixpkgs, qt6Nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux;
in {
devShells.x86_64-linux.default = pkgs.mkShell {
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
@@ -17,19 +26,19 @@
pkgs.xorg.libxcb
pkgs.freetype
pkgs.dbus
pkgs.qt6.qtwayland
pkgs.qt6.full
pkgs.qt6.qtbase
pkgs.zstd
# For PySide6 Multimedia
pkgs.libpulseaudio
pkgs.libkrb5
qt6Pkgs.qt6.qtwayland
qt6Pkgs.qt6.full
qt6Pkgs.qt6.qtbase
];
buildInputs = with pkgs; [
cmake
gdb
zstd
qt6.qtbase
qt6.full
qt6.qtwayland
qtcreator
python312Packages.pip
python312Full
python312Packages.virtualenv # run virtualenv .
@@ -49,11 +58,17 @@
fontconfig
xorg.libxcb
# this is for the shellhook portion
qt6.wrapQtAppsHook
makeWrapper
bashInteractive
] ++ [
qt6Pkgs.qt6.qtbase
qt6Pkgs.qt6.full
qt6Pkgs.qt6.qtwayland
qt6Pkgs.qtcreator
# this is for the shellhook portion
qt6Pkgs.qt6.wrapQtAppsHook
];
# set the environment variables that Qt apps expect
shellHook = ''

View File

@@ -6,3 +6,4 @@ strict_optional = false
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
explicit_package_bases = true
warn_unused_ignores = true
exclude = ['tests']

View File

@@ -3,3 +3,4 @@ pre-commit==3.7.0
pytest==8.2.0
Pyinstaller==6.6.0
mypy==1.10.0
syrupy==4.6.1

View File

@@ -1,10 +1,12 @@
humanfriendly==10.0
opencv_python>=4.8.0.74,<=4.9.0.80
Pillow==10.3.0
PySide6>=6.5.1.1,<=6.6.3.1
PySide6_Addons>=6.5.1.1,<=6.6.3.1
PySide6_Essentials>=6.5.1.1,<=6.6.3.1
PySide6==6.7.1
PySide6_Addons==6.7.1
PySide6_Essentials==6.7.1
typing_extensions>=3.10.0.0,<=4.11.0
ujson>=5.8.0,<=5.9.0
numpy==1.26.4
rawpy==0.21.0
pillow-heif==0.16.0
chardet==5.2.0

View File

@@ -26,7 +26,7 @@ a = Analysis(
['tagstudio/tag_studio.py'],
pathex=[],
binaries=[],
datas=[('tagstudio/resources', 'resources')],
datas=[('tagstudio/resources', 'resources'), ('tagstudio/src', 'src')],
hiddenimports=[],
hookspath=[],
hooksconfig={},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 361 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M560-200v-560h160v560H560Zm-320 0v-560h160v560H240Z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M320-200v-560l440 280-440 280Z"/></svg>

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320Z"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M792-56 671-177q-25 16-53 27.5T560-131v-82q14-5 27.5-10t25.5-12L480-368v208L280-360H120v-240h128L56-792l56-56 736 736-56 56Zm-8-232-58-58q17-31 25.5-65t8.5-70q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 53-14.5 102T784-288ZM650-422l-90-90v-130q47 22 73.5 66t26.5 96q0 15-2.5 29.5T650-422ZM480-592 376-696l104-104v208Z"/></svg>

After

Width:  |  Height:  |  Size: 445 B

View File

@@ -88,21 +88,19 @@ class CliDriver:
self.is_dupe_file_count_init: bool = False
self.external_preview_size: tuple[int, int] = (960, 960)
epd_path = os.path.normpath(
f"{Path(__file__).parents[2]}/resources/cli/images/external_preview.png"
epd_path = (
Path(__file__).parents[2] / "resources/cli/images/external_preview.png"
)
self.external_preview_default: Image = (
Image.open(epd_path)
if os.path.exists(epd_path)
if epd_path.exists()
else Image.new(mode="RGB", size=(self.external_preview_size))
)
self.external_preview_default.thumbnail(self.external_preview_size)
epb_path = os.path.normpath(
f"{Path(__file__).parents[2]}/resources/cli/images/no_preview.png"
)
epb_path = Path(__file__).parents[3] / "resources/cli/images/no_preview.png"
self.external_preview_broken: Image = (
Image.open(epb_path)
if os.path.exists(epb_path)
if epb_path.exists()
else Image.new(mode="RGB", size=(self.external_preview_size))
)
self.external_preview_broken.thumbnail(self.external_preview_size)
@@ -361,45 +359,44 @@ class CliDriver:
def init_external_preview(self) -> None:
"""Initialized the external preview image file."""
if self.lib and self.lib.library_dir:
external_preview_path: str = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
external_preview_path: Path = (
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
)
if not os.path.isfile(external_preview_path):
if not external_preview_path.is_file():
temp = self.external_preview_default
temp.save(external_preview_path)
open_file(external_preview_path)
def set_external_preview_default(self) -> None:
"""Sets the external preview to its default image."""
if self.lib and self.lib.library_dir:
external_preview_path: str = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
external_preview_path: Path = (
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
)
if os.path.isfile(external_preview_path):
if external_preview_path.is_file():
temp = self.external_preview_default
temp.save(external_preview_path)
def set_external_preview_broken(self) -> None:
"""Sets the external preview image file to the 'broken' placeholder."""
if self.lib and self.lib.library_dir:
external_preview_path: str = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
external_preview_path: Path = (
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
)
if os.path.isfile(external_preview_path):
if external_preview_path.is_file():
temp = self.external_preview_broken
temp.save(external_preview_path)
def close_external_preview(self) -> None:
"""Destroys and closes the external preview image file."""
if self.lib and self.lib.library_dir:
external_preview_path: str = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
external_preview_path: Path = (
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
)
if os.path.isfile(external_preview_path):
if external_preview_path.is_file():
os.remove(external_preview_path)
def scr_create_library(self, path=""):
def scr_create_library(self, path=None):
"""Screen for creating a new TagStudio library."""
subtitle = "Create Library"
@@ -412,7 +409,10 @@ class CliDriver:
if not path:
print("Enter Library Folder Path: \n> ", end="")
path = input()
if os.path.exists(path):
path = Path(path)
if path.exists():
print("")
print(
f'{INFO} Are you sure you want to create a new Library at "{path}"? (Y/N)\n> ',
@@ -488,9 +488,7 @@ class CliDriver:
"""Saves a backup copy of the Library file to disk. Returns True if successful."""
if self.lib and self.lib.library_dir:
filename = self.lib.save_library_backup_to_disk()
location = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/backups/{filename}"
)
location = self.lib.library_dir / TS_FOLDER_NAME / "backups" / filename
if display_message:
print(f'{INFO} Backup of Library saved at "{location}".')
return True
@@ -617,13 +615,11 @@ class CliDriver:
"""
entry = None if index < 0 else self.lib.entries[index]
if entry:
filepath = os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
external_preview_path: str = ""
filepath = self.lib.library_dir / entry.path / entry.filename
external_preview_path: Path = None
if self.args.external_preview:
external_preview_path = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
external_preview_path = (
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
)
# thumb_width = min(
# os.get_terminal_size()[0]//2,
@@ -674,14 +670,9 @@ class CliDriver:
final_frame = Image.fromarray(frame)
w, h = final_frame.size
final_frame.save(
os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg"
),
quality=50,
)
final_img_path = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg"
self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg", quality=50
)
final_img_path = self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg"
# NOTE: Temporary way to hack a non-terminal preview.
if self.args.external_preview and entry:
final_frame.thumbnail(self.external_preview_size)
@@ -744,7 +735,7 @@ class CliDriver:
print(image.replace("\n", ("\n" + " " * spacing)))
if file_type in VIDEO_TYPES:
os.remove(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg")
os.remove(self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg")
except:
if not self.args.external_preview or not entry:
print(
@@ -901,7 +892,7 @@ class CliDriver:
"""Runs a specific Macro on an Entry given a Macro name."""
# entry: Entry = self.lib.get_entry_from_index(entry_id)
entry = self.lib.get_entry(entry_id)
path = os.path.normpath(f"{self.lib.library_dir}/{entry.path}/{entry.filename}")
path = self.lib.library_dir / entry.path / entry.filename
source = path.split(os.sep)[1].lower()
if name == "sidecar":
self.lib.add_generic_data_to_entry(
@@ -1054,8 +1045,11 @@ class CliDriver:
time.sleep(5)
collage = Image.new("RGB", (img_size, img_size))
filename = os.path.normpath(
f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png'
filename = (
elf.lib.library_dir
/ TS_FOLDER_NAME
/ COLLAGE_FOLDER_NAME
/ f'collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png'
)
i = 0
@@ -1065,10 +1059,7 @@ class CliDriver:
if i < len(self.lib.entries) and run:
# entry: Entry = self.lib.get_entry_from_index(i)
entry = self.lib.entries[i]
filepath = os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
file_type = os.path.splitext(filepath)[1].lower()[1:]
filepath = self.lib.library_dir / entry.path / entry.filename
color: str = ""
if data_tint_mode or data_only_mode:
@@ -1113,16 +1104,17 @@ class CliDriver:
collage.paste(pic, (y * thumb_size, x * thumb_size))
if not data_only_mode:
print(
f"\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}"
f"\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}{RESET}"
)
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
# sys.stdout.flush()
if file_type in IMAGE_TYPES:
if filepath.suffix.lower() in IMAGE_TYPES:
try:
with Image.open(
os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
self.lib.library_dir
/ entry.path
/ entry.filename
) as pic:
if keep_aspect:
pic.thumbnail((thumb_size, thumb_size))
@@ -1146,7 +1138,7 @@ class CliDriver:
f"[ERROR] One of the images was too big ({e})"
)
elif file_type in VIDEO_TYPES:
elif filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(
cv2.CAP_PROP_POS_FRAMES,
@@ -1168,9 +1160,7 @@ class CliDriver:
)
collage.paste(pic, (y * thumb_size, x * thumb_size))
except UnidentifiedImageError:
print(
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
)
print(f"\n{ERROR} Couldn't read {entry.path / entry.filename}")
except KeyboardInterrupt:
# self.quit(save=False, backup=True)
run = False
@@ -1178,7 +1168,7 @@ class CliDriver:
print(f"{INFO} Collage operation cancelled.")
clear_scr = False
except:
print(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
print(f"{ERROR} {entry.path / entry.filename}")
traceback.print_exc()
print("Continuing...")
i = i + 1
@@ -1455,7 +1445,7 @@ class CliDriver:
f"{WHITE_FG}Enter the filename for your DupeGuru results file:\n> {RESET}",
end="",
)
dg_results_file = os.path.normpath(input())
dg_results_file = Path(input())
print(
f"{INFO} Checking for duplicate files in Library '{self.lib.library_dir}'..."
)
@@ -1477,7 +1467,7 @@ class CliDriver:
) > 1:
if com[1].lower() == "entries":
for i, e in enumerate(self.lib.entries, start=0):
title = f"[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path}{os.path.sep}{self.lib.entries[i].filename}"
title = f"[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path / os.path.sep / self.lib.entries[i].filename}"
print(
self.format_subtitle(
title,
@@ -1526,12 +1516,11 @@ class CliDriver:
for dupe in self.lib.dupe_entries:
print(
self.lib.entries[dupe[0]].path
+ os.path.sep
+ self.lib.entries[dupe[0]].filename
/ self.lib.entries[dupe[0]].filename
)
for d in dupe[1]:
print(
f"\t-> {(self.lib.entries[d].path + os.path.sep + self.lib.entries[d].filename)}"
f"\t-> {(self.lib.entries[d].path / self.lib.entries[d].filename)}"
)
time.sleep(0.1)
print("Press Enter to Continue...")
@@ -1871,20 +1860,18 @@ class CliDriver:
# entry = self.lib.get_entry_from_index(
# self.filtered_entries[index])
entry = self.lib.get_entry(self.filtered_entries[index][1])
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
filename = self.lib.library_dir / entry.path / entry.filename
# if self.lib.is_legacy_library:
# title += ' (Legacy Format)'
h1 = f"[{index + 1}/{len(self.filtered_entries)}] {filename}"
# print(self.format_subtitle(subtitle))
print(
self.format_h1(
h1, self.get_file_color(os.path.splitext(filename)[1])
)
self.format_h1(h1, self.get_file_color(filename.suffix.lower()))
)
print("")
if not os.path.isfile(filename):
if not filename.is_file():
print(
f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}"
)
@@ -2527,7 +2514,7 @@ class CliDriver:
while True:
entry = self.lib.get_entry_from_index(index)
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
filename = self.lib.library_dir / entry.path / entry.filename
if refresh:
if clear_scr:
@@ -2542,7 +2529,7 @@ class CliDriver:
for i, match in enumerate(self.lib.missing_matches[filename]):
print(self.format_h1(f"[{i+1}] {match}"), end="\n\n")
fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry.filename)}'
fn = self.lib.library_dir / match / entry.filename
self.print_thumbnail(
index=-1,
filepath=fn,
@@ -2585,9 +2572,7 @@ class CliDriver:
# Open =============================================================
elif com[0].lower() == "open" or com[0].lower() == "o":
for match in self.lib.missing_matches[filename]:
fn = os.path.normpath(
self.lib.library_dir + "/" + match + "/" + entry.filename
)
fn = self.lib.library_dir / match / entry.filename
open_file(fn)
refresh = False
# clear()
@@ -2630,9 +2615,7 @@ class CliDriver:
while True:
dupe = self.lib.dupe_files[index]
if os.path.exists(os.path.normpath(f"{dupe[0]}")) and os.path.exists(
os.path.normpath(f"{dupe[1]}")
):
if dupe[0].exists() and dupe[1].exists():
# entry = self.lib.get_entry_from_index(index_1)
entry_1_index = self.lib.get_entry_id_from_filepath(dupe[0])
entry_2_index = self.lib.get_entry_id_from_filepath(dupe[1])
@@ -2779,7 +2762,7 @@ class CliDriver:
title = f"{self.base_title} - Library '{self.lib.library_dir}'"
entry = self.lib.entries[entry_index]
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
filename = self.lib.library_dir / entry.path / entry.filename
field_name = self.lib.get_field_attr(entry.fields[field_index], "name")
subtitle = f'Editing "{field_name}" Field'
h1 = f"{filename}"
@@ -2796,7 +2779,7 @@ class CliDriver:
)
print("")
if not os.path.isfile(filename):
if not filename.is_file():
print(
f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}"
)
@@ -3048,7 +3031,7 @@ class CliDriver:
title = f"{self.base_title} - Library '{self.lib.library_dir}'"
entry = self.lib.entries[entry_index]
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
filename = self.lib.library_dir / entry.path / entry.filename
field_name = self.lib.get_field_attr(entry.fields[field_index], "name")
subtitle = f'Editing "{field_name}" Field'
h1 = f"{filename}"
@@ -3061,7 +3044,7 @@ class CliDriver:
print(self.format_h1(h1, self.get_file_color(os.path.splitext(filename)[1])))
print("")
if not os.path.isfile(filename):
if not filename.is_file():
print(
f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}"
)

View File

@@ -1,5 +1,5 @@
VERSION: str = "9.2.1" # Major.Minor.Patch
VERSION_BRANCH: str = "Pre-Release" # 'Alpha', 'Beta', or '' for Full Release
VERSION: str = "9.3.2" # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
@@ -9,76 +9,104 @@ LIBRARY_FILENAME: str = "ts_library.json"
# TODO: Turn this whitelist into a user-configurable blacklist.
IMAGE_TYPES: list[str] = [
"png",
"jpg",
"jpeg",
"jpg_large",
"jpeg_large",
"jfif",
"gif",
"tif",
"tiff",
"heic",
"heif",
"webp",
"bmp",
"svg",
"avif",
"apng",
"jp2",
"j2k",
"jpg2",
".png",
".jpg",
".jpeg",
".jpg_large",
".jpeg_large",
".jfif",
".gif",
".tif",
".tiff",
".heic",
".heif",
".webp",
".bmp",
".svg",
".avif",
".apng",
".jp2",
".j2k",
".jpg2",
]
RAW_IMAGE_TYPES: list[str] = [
".raw",
".dng",
".rw2",
".nef",
".arw",
".crw",
".cr2",
".cr3",
]
RAW_IMAGE_TYPES: list[str] = ["raw", "dng", "rw2", "nef", "arw", "crw", "cr3"]
VIDEO_TYPES: list[str] = [
"mp4",
"webm",
"mov",
"hevc",
"mkv",
"avi",
"wmv",
"flv",
"gifv",
"m4p",
"m4v",
"3gp",
".mp4",
".webm",
".mov",
".hevc",
".mkv",
".avi",
".wmv",
".flv",
".gifv",
".m4p",
".m4v",
".3gp",
]
AUDIO_TYPES: list[str] = [
"mp3",
"mp4",
"mpeg4",
"m4a",
"aac",
"wav",
"flac",
"alac",
"wma",
"ogg",
"aiff",
".mp3",
".mp4",
".mpeg4",
".m4a",
".aac",
".wav",
".flac",
".alac",
".wma",
".ogg",
".aiff",
]
DOC_TYPES: list[str] = [
".txt",
".rtf",
".md",
".doc",
".docx",
".pdf",
".tex",
".odt",
".pages",
]
DOC_TYPES: list[str] = ["txt", "rtf", "md", "doc", "docx", "pdf", "tex", "odt", "pages"]
PLAINTEXT_TYPES: list[str] = [
"txt",
"md",
"css",
"html",
"xml",
"json",
"js",
"ts",
"ini",
"htm",
"csv",
"php",
"sh",
"bat",
".txt",
".md",
".css",
".html",
".xml",
".json",
".js",
".ts",
".ini",
".htm",
".csv",
".php",
".sh",
".bat",
]
SPREADSHEET_TYPES: list[str] = ["csv", "xls", "xlsx", "numbers", "ods"]
PRESENTATION_TYPES: list[str] = ["ppt", "pptx", "key", "odp"]
ARCHIVE_TYPES: list[str] = ["zip", "rar", "tar", "tar.gz", "tgz", "7z"]
PROGRAM_TYPES: list[str] = ["exe", "app"]
SHORTCUT_TYPES: list[str] = ["lnk", "desktop", "url"]
SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"]
PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"]
ARCHIVE_TYPES: list[str] = [
".zip",
".rar",
".tar",
".tar",
".gz",
".tgz",
".7z",
".s7z",
]
PROGRAM_TYPES: list[str] = [".exe", ".app"]
SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"]
ALL_FILE_TYPES: list[str] = (
IMAGE_TYPES
@@ -135,3 +163,6 @@ TAG_COLORS = [
"cool gray",
"olive",
]
TAG_FAVORITE = 1
TAG_ARCHIVED = 0

View File

@@ -8,9 +8,32 @@ class SettingItems(str, enum.Enum):
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
WINDOW_SHOW_LIBS = "window_show_libs"
AUTOPLAY = "autoplay_videos"
class Theme(str, enum.Enum):
COLOR_BG = "#65000000"
COLOR_HOVER = "#65AAAAAA"
COLOR_PRESSED = "#65EEEEEE"
COLOR_DISABLED = "#65F39CAA"
COLOR_DISABLED_BG = "#65440D12"
class SearchMode(int, enum.Enum):
"""Operational modes for item searching."""
AND = 0
OR = 1
class FieldID(int, enum.Enum):
TITLE = 0
AUTHOR = 1
ARTIST = 2
DESCRIPTION = 4
NOTES = 5
TAGS = 6
CONTENT_TAGS = 7
META_TAGS = 8
DATE_PUBLISHED = 14
SOURCE = 21

View File

@@ -1,4 +1,5 @@
from typing import TypedDict
from typing_extensions import NotRequired
class JsonLibary(TypedDict("", {"ts-version": str})):
@@ -8,7 +9,9 @@ class JsonLibary(TypedDict("", {"ts-version": str})):
fields: list # TODO
macros: "list[JsonMacro]"
entries: "list[JsonEntry]"
ignored_extensions: list[str]
ext_list: list[str]
is_exclude_list: bool
ignored_extensions: NotRequired[list[str]] # deprecated
class JsonBase(TypedDict):

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@
import json
import os
from pathlib import Path
from enum import Enum
from src.core.library import Entry, Library
from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS
@@ -20,7 +22,7 @@ class TagStudioCore:
def __init__(self):
self.lib: Library = Library()
def get_gdl_sidecar(self, filepath: str, source: str = "") -> dict:
def get_gdl_sidecar(self, filepath: str | Path, source: str = "") -> dict:
"""
Attempts to open and dump a Gallery-DL Sidecar sidecar file for
the filepath.\n Returns a formatted object with notable values or an
@@ -28,16 +30,19 @@ class TagStudioCore:
"""
json_dump = {}
info = {}
_filepath: Path = Path(filepath)
_filepath = _filepath.parent / (_filepath.stem + ".json")
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
# This may only occur with sidecar files that are downloaded separate from posts.
if source == "instagram":
if not os.path.isfile(os.path.normpath(filepath + ".json")):
filepath = filepath[:-16] + "1" + filepath[-15:]
if not _filepath.is_file():
newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:]
_filepath = _filepath.parent / (newstem + ".json")
try:
with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f:
with open(_filepath, "r", encoding="utf8") as f:
json_dump = json.load(f)
if json_dump:
@@ -101,19 +106,17 @@ class TagStudioCore:
def match_conditions(self, entry_id: int) -> None:
"""Matches defined conditions against a file to add Entry data."""
cond_file = os.path.normpath(
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/conditions.json"
)
cond_file = self.lib.library_dir / TS_FOLDER_NAME / "conditions.json"
# TODO: Make this stored somewhere better instead of temporarily in this JSON file.
entry: Entry = self.lib.get_entry(entry_id)
try:
if os.path.isfile(cond_file):
if cond_file.is_file():
with open(cond_file, "r", encoding="utf8") as f:
json_dump = json.load(f)
for c in json_dump["conditions"]:
match: bool = False
for path_c in c["path_conditions"]:
if os.path.normpath(path_c) in entry.path:
if str(Path(path_c).resolve()) in str(entry.path):
match = True
break
if match:
@@ -180,7 +183,7 @@ class TagStudioCore:
"""
try:
entry = self.lib.get_entry(entry_id)
stubs = entry.filename.rsplit("_", 3)
stubs = str(entry.filename).rsplit("_", 3)
# print(stubs)
# source, author = os.path.split(entry.path)
url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}"
@@ -195,7 +198,7 @@ class TagStudioCore:
"""
try:
entry = self.lib.get_entry(entry_id)
stubs = entry.filename.rsplit("_", 2)
stubs = str(entry.filename).rsplit("_", 2)
# stubs[0] = stubs[0].replace(f"{author}_", '', 1)
# print(stubs)
# NOTE: Both Instagram usernames AND their ID can have underscores in them,

View File

@@ -0,0 +1,27 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from chardet.universaldetector import UniversalDetector
from pathlib import Path
def detect_char_encoding(filepath: Path) -> str | None:
"""
Attempts to detect the character encoding of a text file.
Args:
filepath (Path): The path of the text file to analyze.
Returns:
str | None: The detected character encoding, if any.
"""
detector = UniversalDetector()
with open(filepath, "rb") as text_file:
for line in text_file.readlines():
detector.feed(line)
if detector.done:
break
detector.close()
return detector.result["encoding"]

View File

@@ -0,0 +1,59 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from src.qt.helpers.gradient import linear_gradient
# TODO: Consolidate the built-in QT theme values with the values
# here, in enums.py, and in palette.py.
_THEME_DARK_FG: str = "#FFFFFF55"
_THEME_LIGHT_FG: str = "#000000DD"
def theme_fg_overlay(image: Image.Image) -> Image.Image:
"""
Overlay the foreground theme color onto an image.
Args:
image (Image): The PIL Image object to apply an overlay to.
"""
overlay_color = (
_THEME_DARK_FG
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
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.
Args:
image (Image): The PIL Image object to apply an overlay to.
gradient (list[str): A list of string hex color codes for use as
the colors of the gradient.
"""
im: Image.Image = _apply_overlay(image, linear_gradient(image.size, gradient))
return im
def _apply_overlay(image: Image.Image, overlay: Image.Image) -> Image.Image:
"""
Internal method to apply an overlay on top of an image, using
the image's alpha channel as a mask.
Args:
image (Image): The PIL Image object to apply an overlay to.
overlay (Image): The PIL Image object to act as the overlay contents.
"""
im: Image.Image = Image.new(mode="RGBA", size=image.size, color="#00000000")
im.paste(overlay, (0, 0), mask=image)
return im

View File

@@ -8,6 +8,7 @@ import subprocess
import shutil
import sys
import traceback
from pathlib import Path
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import Qt
@@ -19,7 +20,7 @@ INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
def open_file(path: str, file_manager: bool = False):
def open_file(path: str | Path, file_manager: bool = False):
"""Open a file in the default application or file explorer.
Args:
@@ -27,13 +28,14 @@ def open_file(path: str, file_manager: bool = False):
file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS).
Defaults to False.
"""
logging.info(f"Opening file: {path}")
if not os.path.exists(path):
logging.error(f"File not found: {path}")
_path = str(path)
logging.info(f"Opening file: {_path}")
if not os.path.exists(_path):
logging.error(f"File not found: {_path}")
return
try:
if sys.platform == "win32":
normpath = os.path.normpath(path)
normpath = os.path.normpath(_path)
if file_manager:
command_name = "explorer"
command_args = '/select,"' + normpath + '"'
@@ -59,7 +61,7 @@ def open_file(path: str, file_manager: bool = False):
else:
if sys.platform == "darwin":
command_name = "open"
command_args = [path]
command_args = [_path]
if file_manager:
# will reveal in Finder
command_args.append("-R")
@@ -73,12 +75,12 @@ def open_file(path: str, file_manager: bool = False):
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:file://{path}",
f"array:string:file://{_path}",
"string:",
]
else:
command_name = "xdg-open"
command_args = [path]
command_args = [_path]
command = shutil.which(command_name)
if command is not None:
subprocess.Popen([command] + command_args, close_fds=True)
@@ -89,21 +91,21 @@ def open_file(path: str, file_manager: bool = False):
class FileOpenerHelper:
def __init__(self, filepath: str):
def __init__(self, filepath: str | Path):
"""Initialize the FileOpenerHelper.
Args:
filepath (str): The path to the file to open.
"""
self.filepath = filepath
self.filepath = str(filepath)
def set_filepath(self, filepath: str):
def set_filepath(self, filepath: str | Path):
"""Set the filepath to open.
Args:
filepath (str): The path to the file to open.
"""
self.filepath = filepath
self.filepath = str(filepath)
def open_file(self):
"""Open the file in the default application."""

View File

@@ -5,7 +5,9 @@
from PIL import Image, ImageEnhance, ImageChops
def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl):
def four_corner_gradient_background(
image: Image.Image, adj_size, mask, hl
) -> Image.Image:
if image.size != (adj_size, adj_size):
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
@@ -48,3 +50,16 @@ def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl):
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
return final
def linear_gradient(
size=tuple[int, int],
colors=list[str],
interpolation: Image.Resampling = Image.Resampling.BICUBIC,
) -> Image.Image:
seed: Image.Image = Image.new(mode="RGBA", size=(len(colors), 1), color="#000000")
for i, color in enumerate(colors):
c_im: Image.Image = Image.new(mode="RGBA", size=(1, 1), color=color)
seed.paste(c_im, (i, 0))
gradient: Image.Image = seed.resize(size, resample=interpolation)
return gradient

View File

@@ -0,0 +1,16 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtWidgets import QPushButton
class QPushButtonWrapper(QPushButton):
"""
This is a customized implementation of the PySide6 QPushButton that allows to suppress the warning that is triggered
by disconnecting a signal that is not currently connected.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_connected = False

View File

@@ -12,215 +12,227 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,
QSize, Qt)
from PySide6.QtGui import (QFont, QAction)
import logging
import typing
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt)
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget, QSplitter)
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget, QSplitter, QCheckBox,
QSpacerItem)
from src.qt.pagination import Pagination
from src.qt.widgets.landing import LandingWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logging.basicConfig(format="%(message)s", level=logging.INFO)
class Ui_MainWindow(QMainWindow):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setupUi(self)
def __init__(self, driver: "QtDriver", parent=None) -> None:
super().__init__(parent)
self.driver: "QtDriver" = driver
self.setupUi(self)
# self.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
# self.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False)
# # self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# NOTE: These are old attempts to allow for a translucent/acrylic
# window effect. This may be attempted again in the future.
# self.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
# self.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False)
# # self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# self.windowFX = WindowEffect()
# self.windowFX.setAcrylicEffect(self.winId(), isEnableShadow=False)
# self.windowFX = WindowEffect()
# self.windowFX.setAcrylicEffect(self.winId(), isEnableShadow=False)
# # self.setStyleSheet(
# # 'background:#EE000000;'
# # )
# # self.setStyleSheet(
# # 'background:#EE000000;'
# # )
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(1300, 720)
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(1300, 720)
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
# ComboBox goup for search type and thumbnail size
self.horizontalLayout_3 = QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
# left side spacer
spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout_3.addItem(spacerItem)
# Search type selector
self.comboBox_2 = QComboBox(self.centralwidget)
self.comboBox_2.setMinimumSize(QSize(165, 0))
self.comboBox_2.setObjectName("comboBox_2")
self.comboBox_2.addItem("")
self.comboBox_2.addItem("")
self.horizontalLayout_3.addWidget(self.comboBox_2)
# Thumbnail Size placeholder
self.comboBox = QComboBox(self.centralwidget)
self.comboBox.setObjectName(u"comboBox")
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.comboBox.sizePolicy().hasHeightForWidth())
self.comboBox.setSizePolicy(sizePolicy)
self.comboBox.setMinimumWidth(128)
self.comboBox.setMaximumWidth(128)
self.horizontalLayout_3.addWidget(self.comboBox)
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
self.splitter = QSplitter()
self.splitter.setObjectName(u"splitter")
self.splitter.setHandleWidth(12)
self.splitter = QSplitter()
self.splitter.setObjectName(u"splitter")
self.splitter.setHandleWidth(12)
self.frame_container = QWidget()
self.frame_layout = QVBoxLayout(self.frame_container)
self.frame_layout.setSpacing(0)
self.frame_container = QWidget()
self.frame_layout = QVBoxLayout(self.frame_container)
self.frame_layout.setSpacing(0)
self.scrollArea = QScrollArea()
self.scrollArea.setObjectName(u"scrollArea")
self.scrollArea.setFocusPolicy(Qt.WheelFocus)
self.scrollArea.setFrameShape(QFrame.NoFrame)
self.scrollArea.setFrameShadow(QFrame.Plain)
self.scrollArea.setWidgetResizable(True)
self.scrollAreaWidgetContents = QWidget()
self.scrollAreaWidgetContents.setObjectName(
u"scrollAreaWidgetContents")
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 590))
self.gridLayout_2 = QGridLayout(self.scrollAreaWidgetContents)
self.gridLayout_2.setSpacing(8)
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.gridLayout_2.setContentsMargins(0, 0, 0, 8)
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
self.frame_layout.addWidget(self.scrollArea)
self.scrollArea = QScrollArea()
self.scrollArea.setObjectName(u"scrollArea")
self.scrollArea.setFocusPolicy(Qt.WheelFocus)
self.scrollArea.setFrameShape(QFrame.NoFrame)
self.scrollArea.setFrameShadow(QFrame.Plain)
self.scrollArea.setWidgetResizable(True)
self.scrollAreaWidgetContents = QWidget()
self.scrollAreaWidgetContents.setObjectName(
u"scrollAreaWidgetContents")
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 590))
self.gridLayout_2 = QGridLayout(self.scrollAreaWidgetContents)
self.gridLayout_2.setSpacing(8)
self.gridLayout_2.setObjectName(u"gridLayout_2")
self.gridLayout_2.setContentsMargins(0, 0, 0, 8)
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
self.frame_layout.addWidget(self.scrollArea)
self.landing_widget: LandingWidget = LandingWidget(self.driver, self.devicePixelRatio())
self.frame_layout.addWidget(self.landing_widget)
# self.page_bar_controls = QWidget()
# self.page_bar_controls.setStyleSheet('background:blue;')
# self.page_bar_controls.setMinimumHeight(32)
self.pagination = Pagination()
self.frame_layout.addWidget(self.pagination)
self.pagination = Pagination()
self.frame_layout.addWidget(self.pagination)
self.horizontalLayout.addWidget(self.splitter)
self.splitter.addWidget(self.frame_container)
self.splitter.setStretchFactor(0, 1)
# self.frame_layout.addWidget(self.page_bar_controls)
# self.frame_layout.addWidget(self.page_bar_controls)
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 1)
self.horizontalLayout.addWidget(self.splitter)
self.splitter.addWidget(self.frame_container)
self.splitter.setStretchFactor(0, 1)
self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
self.horizontalLayout_2.setSizeConstraint(QLayout.SetMinimumSize)
self.backButton = QPushButton(self.centralwidget)
self.backButton.setObjectName(u"backButton")
self.backButton.setMinimumSize(QSize(0, 32))
self.backButton.setMaximumSize(QSize(32, 16777215))
font = QFont()
font.setPointSize(14)
font.setBold(True)
self.backButton.setFont(font)
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 1)
self.horizontalLayout_2.addWidget(self.backButton)
self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
self.horizontalLayout_2.setSizeConstraint(QLayout.SetMinimumSize)
self.backButton = QPushButton(self.centralwidget)
self.backButton.setObjectName(u"backButton")
self.backButton.setMinimumSize(QSize(0, 32))
self.backButton.setMaximumSize(QSize(32, 16777215))
font = QFont()
font.setPointSize(14)
font.setBold(True)
self.backButton.setFont(font)
self.forwardButton = QPushButton(self.centralwidget)
self.forwardButton.setObjectName(u"forwardButton")
self.forwardButton.setMinimumSize(QSize(0, 32))
self.forwardButton.setMaximumSize(QSize(32, 16777215))
font1 = QFont()
font1.setPointSize(14)
font1.setBold(True)
font1.setKerning(True)
self.forwardButton.setFont(font1)
self.horizontalLayout_2.addWidget(self.backButton)
self.horizontalLayout_2.addWidget(self.forwardButton)
self.forwardButton = QPushButton(self.centralwidget)
self.forwardButton.setObjectName(u"forwardButton")
self.forwardButton.setMinimumSize(QSize(0, 32))
self.forwardButton.setMaximumSize(QSize(32, 16777215))
font1 = QFont()
font1.setPointSize(14)
font1.setBold(True)
font1.setKerning(True)
self.forwardButton.setFont(font1)
self.searchField = QLineEdit(self.centralwidget)
self.searchField.setObjectName(u"searchField")
self.searchField.setMinimumSize(QSize(0, 32))
font2 = QFont()
font2.setPointSize(11)
font2.setBold(False)
self.searchField.setFont(font2)
self.horizontalLayout_2.addWidget(self.forwardButton)
self.horizontalLayout_2.addWidget(self.searchField)
self.searchField = QLineEdit(self.centralwidget)
self.searchField.setObjectName(u"searchField")
self.searchField.setMinimumSize(QSize(0, 32))
font2 = QFont()
font2.setPointSize(11)
font2.setBold(False)
self.searchField.setFont(font2)
self.searchButton = QPushButton(self.centralwidget)
self.searchButton.setObjectName(u"searchButton")
self.searchButton.setMinimumSize(QSize(0, 32))
self.searchButton.setFont(font2)
self.horizontalLayout_2.addWidget(self.searchField)
self.horizontalLayout_2.addWidget(self.searchButton)
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
self.gridLayout_2.setContentsMargins(6, 6, 6, 6)
self.searchButton = QPushButton(self.centralwidget)
self.searchButton.setObjectName(u"searchButton")
self.searchButton.setMinimumSize(QSize(0, 32))
self.searchButton.setFont(font2)
MainWindow.setCentralWidget(self.centralwidget)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName(u"statusbar")
sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(
self.statusbar.sizePolicy().hasHeightForWidth())
self.statusbar.setSizePolicy(sizePolicy1)
MainWindow.setStatusBar(self.statusbar)
self.horizontalLayout_2.addWidget(self.searchButton)
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
self.retranslateUi(MainWindow)
self.comboBox = QComboBox(self.centralwidget)
self.comboBox.setObjectName(u"comboBox")
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(
self.comboBox.sizePolicy().hasHeightForWidth())
self.comboBox.setSizePolicy(sizePolicy)
self.comboBox.setMinimumWidth(128)
self.comboBox.setMaximumWidth(128)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
self.gridLayout.addWidget(self.comboBox, 4, 0, 1, 1, Qt.AlignRight)
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate(
"MainWindow", u"MainWindow", None))
# Navigation buttons
self.backButton.setText(
QCoreApplication.translate("MainWindow", u"<", None))
self.forwardButton.setText(
QCoreApplication.translate("MainWindow", u">", None))
# Search field
self.searchField.setPlaceholderText(
QCoreApplication.translate("MainWindow", u"Search Entries", None))
self.searchButton.setText(
QCoreApplication.translate("MainWindow", u"Search", None))
# Search type selector
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)"))
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)"))
self.comboBox.setCurrentText("")
# Thumbnail size selector
self.comboBox.setPlaceholderText(
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
# retranslateUi
self.gridLayout_2.setContentsMargins(6, 6, 6, 6)
def moveEvent(self, event) -> None:
# time.sleep(0.02) # sleep for 20ms
pass
MainWindow.setCentralWidget(self.centralwidget)
# self.menubar = QMenuBar(MainWindow)
# self.menubar.setObjectName(u"menubar")
# self.menubar.setGeometry(QRect(0, 0, 1280, 22))
# MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName(u"statusbar")
sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(
self.statusbar.sizePolicy().hasHeightForWidth())
self.statusbar.setSizePolicy(sizePolicy1)
MainWindow.setStatusBar(self.statusbar)
def resizeEvent(self, event) -> None:
# time.sleep(0.02) # sleep for 20ms
pass
# menu_bar = self.menuBar()
# self.setMenuBar(menu_bar)
# self.gridLayout.addWidget(menu_bar, 4, 0, 1, 1, Qt.AlignRight)
# self.frame_layout.addWidget(menu_bar)
self.retranslateUi(MainWindow)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate(
"MainWindow", u"MainWindow", None))
self.backButton.setText(
QCoreApplication.translate("MainWindow", u"<", None))
self.forwardButton.setText(
QCoreApplication.translate("MainWindow", u">", None))
self.searchField.setPlaceholderText(
QCoreApplication.translate("MainWindow", u"Search Entries", None))
self.searchButton.setText(
QCoreApplication.translate("MainWindow", u"Search", None))
self.comboBox.setCurrentText("")
self.comboBox.setPlaceholderText(
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
# retranslateUi
def moveEvent(self, event) -> None:
# time.sleep(0.02) # sleep for 20ms
pass
def resizeEvent(self, event) -> None:
# time.sleep(0.02) # sleep for 20ms
pass
# def _createMenuBar(self, main_window):
# menu_bar = QMenuBar(main_window)
# file_menu = QMenu('&File', main_window)
# edit_menu = QMenu('&Edit', main_window)
# tools_menu = QMenu('&Tools', main_window)
# macros_menu = QMenu('&Macros', main_window)
# help_menu = QMenu('&Help', main_window)
# file_menu.addAction(QAction('&New Library', main_window))
# file_menu.addAction(QAction('&Open Library', main_window))
# file_menu.addAction(QAction('&Save Library', main_window))
# file_menu.addAction(QAction('&Close Library', main_window))
# file_menu.addAction(QAction('&Refresh Directories', main_window))
# file_menu.addAction(QAction('&Add New Files to Library', main_window))
# menu_bar.addMenu(file_menu)
# menu_bar.addMenu(edit_menu)
# menu_bar.addMenu(tools_menu)
# menu_bar.addMenu(macros_menu)
# menu_bar.addMenu(help_menu)
# main_window.setMenuBar(menu_bar)
def toggle_landing_page(self, enabled: bool):
if enabled:
self.scrollArea.setHidden(True)
self.landing_widget.setHidden(False)
self.landing_widget.animate_logo_in()
else:
self.landing_widget.setHidden(True)
self.landing_widget.set_status_label("")
self.scrollArea.setHidden(False)

View File

@@ -24,6 +24,7 @@ class AddFieldModal(QWidget):
# - OR -
# [Cancel] [Save]
super().__init__()
self.is_connected = False
self.lib = library
self.setWindowTitle(f"Add Field")
self.setWindowModality(Qt.WindowModality.ApplicationModal)

View File

@@ -96,7 +96,7 @@ class BuildTagPanel(PanelWidget):
self.subtags_layout.setSpacing(0)
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.subtags_title = QLabel()
self.subtags_title.setText("Subtags")
self.subtags_title.setText("Parent Tags")
self.subtags_layout.addWidget(self.subtags_title)
self.scroll_contents = QWidget()
@@ -118,7 +118,7 @@ class BuildTagPanel(PanelWidget):
self.subtags_add_button.setText("+")
tsp = TagSearchPanel(self.lib)
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
self.add_tag_modal = PanelModal(tsp, "Add Subtags", "Add Subtags")
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.subtags_layout.addWidget(self.subtags_add_button)

View File

@@ -32,7 +32,7 @@ class DeleteUnlinkedEntriesModal(QWidget):
super().__init__()
self.lib = library
self.driver = driver
self.setWindowTitle(f"Delete Unlinked Entries")
self.setWindowTitle("Delete Unlinked Entries")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
@@ -78,23 +78,9 @@ class DeleteUnlinkedEntriesModal(QWidget):
self.model.clear()
for i in self.lib.missing_files:
self.model.appendRow(QStandardItem(i))
self.model.appendRow(QStandardItem(str(i)))
def delete_entries(self):
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
# # pb.setMaximum(len(self.lib.missing_files))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Deleting Entries')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.lib.ref(pb))
# r.done.connect(lambda: self.done.emit())
# # r.done.connect(lambda: self.model.clear())
# QThreadPool.globalInstance().start(r)
# # r.run()
iterator = FunctionIterator(self.lib.remove_missing_files)
pw = ProgressWidget(
@@ -119,23 +105,3 @@ class DeleteUnlinkedEntriesModal(QWidget):
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
# def delete_entries_runnable(self):
# deleted = []
# for i, missing in enumerate(self.lib.missing_files):
# # pb.setValue(i)
# # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries')
# try:
# id = self.lib.get_entry_id_from_filepath(missing)
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
# self.lib.remove_entry(id)
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
# deleted.append(missing)
# except KeyError:
# logging.info(
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
# yield i
# for d in deleted:
# self.lib.missing_files.remove(d)
# # self.driver.filter_items('')
# # self.done.emit()

View File

@@ -4,51 +4,110 @@
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem
from PySide6.QtWidgets import (
QVBoxLayout,
QHBoxLayout,
QWidget,
QPushButton,
QTableWidget,
QTableWidgetItem,
QStyledItemDelegate,
QLineEdit,
QComboBox,
QLabel,
)
from src.core.library import Library
from src.qt.widgets.panel import PanelWidget
class FileExtensionItemDelegate(QStyledItemDelegate):
def setModelData(self, editor, model, index):
if isinstance(editor, QLineEdit):
if editor.text() and not editor.text().startswith("."):
editor.setText(f".{editor.text()}")
super().setModelData(editor, model, index)
class FileExtensionModal(PanelWidget):
done = Signal()
def __init__(self, library: "Library"):
super().__init__()
# Initialize Modal =====================================================
self.lib = library
self.setWindowTitle(f"File Extensions")
self.setWindowTitle("File Extensions")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(200, 400)
self.setMinimumSize(240, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.table = QTableWidget(len(self.lib.ignored_extensions), 1)
# Create Table Widget --------------------------------------------------
self.table = QTableWidget(len(self.lib.ext_list), 1)
self.table.horizontalHeader().setVisible(False)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.setItemDelegate(FileExtensionItemDelegate())
# Create "Add Button" Widget -------------------------------------------
self.add_button = QPushButton()
self.add_button.setText("&Add Extension")
self.add_button.clicked.connect(self.add_item)
self.add_button.setDefault(True)
self.add_button.setMinimumWidth(100)
# Create Mode Widgets --------------------------------------------------
self.mode_widget = QWidget()
self.mode_layout = QHBoxLayout(self.mode_widget)
self.mode_layout.setContentsMargins(0, 0, 0, 0)
self.mode_layout.setSpacing(12)
self.mode_label = QLabel()
self.mode_label.setText("List Mode:")
self.mode_combobox = QComboBox()
self.mode_combobox.setEditable(False)
self.mode_combobox.addItem("Exclude")
self.mode_combobox.addItem("Include")
self.mode_combobox.setCurrentIndex(0 if self.lib.is_exclude_list else 1)
self.mode_combobox.currentIndexChanged.connect(
lambda i: self.update_list_mode(i)
)
self.mode_layout.addWidget(self.mode_label)
self.mode_layout.addWidget(self.mode_combobox)
self.mode_layout.setStretch(1, 1)
# Add Widgets To Layout ------------------------------------------------
self.root_layout.addWidget(self.mode_widget)
self.root_layout.addWidget(self.table)
self.root_layout.addWidget(
self.add_button, alignment=Qt.AlignmentFlag.AlignCenter
)
# Finalize Modal -------------------------------------------------------
self.refresh_list()
def update_list_mode(self, mode: int):
"""
Update the mode of the extension list: "Exclude" or "Include".
Args:
mode (int): The list mode, given by the index of the mode inside
the mode combobox. 0 for "Exclude", 1 for "Include".
"""
if mode == 0:
self.lib.is_exclude_list = True
elif mode == 1:
self.lib.is_exclude_list = False
def refresh_list(self):
for i, ext in enumerate(self.lib.ignored_extensions):
for i, ext in enumerate(self.lib.ext_list):
self.table.setItem(i, 0, QTableWidgetItem(ext))
def add_item(self):
self.table.insertRow(self.table.rowCount())
def save(self):
self.lib.ignored_extensions.clear()
self.lib.ext_list.clear()
for i in range(self.table.rowCount()):
ext = self.table.item(i, 0)
if ext and ext.text():
self.lib.ignored_extensions.append(ext.text())
self.lib.ext_list.append(ext.text().lower())

View File

@@ -137,9 +137,7 @@ class FixDupeFilesModal(QWidget):
self.set_dupe_count(self.count)
def select_file(self):
qfd = QFileDialog(
self, "Open DupeGuru Results File", os.path.normpath(self.lib.library_dir)
)
qfd = QFileDialog(self, "Open DupeGuru Results File", str(self.lib.library_dir))
qfd.setFileMode(QFileDialog.FileMode.ExistingFile)
qfd.setNameFilter("DupeGuru Files (*.dupeguru)")
if qfd.exec_():

View File

@@ -6,7 +6,7 @@
import logging
import typing
from PySide6.QtCore import QThread, Qt, QThreadPool
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from src.core.library import Library
@@ -14,6 +14,7 @@ from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -21,65 +22,79 @@ if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class FixUnlinkedEntriesModal(QWidget):
# done = Signal(int)
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.count = -1
self.setWindowTitle(f"Fix Unlinked Entries")
self.missing_count = -1
self.dupe_count = -1
self.setWindowTitle("Fix Unlinked Entries")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.desc_widget = QLabel()
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
self.desc_widget.setStyleSheet(
# 'background:blue;'
"text-align:left;"
# 'font-weight:bold;'
# 'font-size:14px;'
# 'padding-top: 6px'
""
self.unlinked_desc_widget = QLabel()
self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel")
self.unlinked_desc_widget.setWordWrap(True)
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
self.unlinked_desc_widget.setText(
"""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired."""
)
self.desc_widget.setText("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""")
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.missing_count = QLabel()
self.missing_count.setObjectName("missingCountLabel")
self.missing_count.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
"font-weight:bold;"
"font-size:14px;"
# 'padding-top: 6px'
""
self.dupe_desc_widget = QLabel()
self.dupe_desc_widget.setObjectName("dupeDescriptionLabel")
self.dupe_desc_widget.setWordWrap(True)
self.dupe_desc_widget.setStyleSheet("text-align:left;")
self.dupe_desc_widget.setText(
"""Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with "duplicate files", which are duplicates of your files themselves outside of TagStudio."""
)
self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
# self.missing_count.setText('Missing Files: N/A')
self.refresh_button = QPushButton()
self.refresh_button.setText("&Refresh")
self.refresh_button.clicked.connect(lambda: self.refresh_missing_files())
self.missing_count_label = QLabel()
self.missing_count_label.setObjectName("missingCountLabel")
self.missing_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
self.missing_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.dupe_count_label = QLabel()
self.dupe_count_label.setObjectName("dupeCountLabel")
self.dupe_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.refresh_unlinked_button = QPushButton()
self.refresh_unlinked_button.setText("&Refresh All")
self.refresh_unlinked_button.clicked.connect(
lambda: self.refresh_missing_files()
)
self.merge_class = MergeDuplicateEntries(self.lib, self.driver)
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
self.search_button = QPushButton()
self.search_button.setText("&Search && Relink")
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
self.relink_class.done.connect(lambda: self.refresh_missing_files())
self.relink_class.done.connect(lambda: self.driver.update_thumbs())
self.relink_class.done.connect(
lambda: self.refresh_and_repair_dupe_entries(self.merge_class)
)
self.search_button.clicked.connect(lambda: self.relink_class.repair_entries())
self.refresh_dupe_button = QPushButton()
self.refresh_dupe_button.setText("Refresh Duplicate Entries")
self.refresh_dupe_button.clicked.connect(lambda: self.refresh_dupe_entries())
self.merge_dupe_button = QPushButton()
self.merge_dupe_button.setText("&Merge Duplicate Entries")
self.merge_class.done.connect(lambda: self.set_dupe_count(-1))
self.merge_class.done.connect(lambda: self.set_missing_count(-1))
self.merge_class.done.connect(lambda: self.driver.filter_items())
self.merge_dupe_button.clicked.connect(lambda: self.merge_class.merge_entries())
self.manual_button = QPushButton()
self.manual_button.setText("&Manual Relink")
@@ -92,14 +107,6 @@ class FixUnlinkedEntriesModal(QWidget):
self.delete_button.setText("De&lete Unlinked Entries")
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
# self.combo_box = QComboBox()
# self.combo_box.setEditable(False)
# # self.combo_box.setMaxVisibleItems(5)
# self.combo_box.setStyleSheet('combobox-popup:0;')
# self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
# for df in self.lib.default_fields:
# self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
@@ -107,50 +114,39 @@ class FixUnlinkedEntriesModal(QWidget):
self.done_button = QPushButton()
self.done_button.setText("&Done")
# self.save_button.setAutoDefault(True)
self.done_button.setDefault(True)
self.done_button.clicked.connect(self.hide)
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
self.button_layout.addWidget(self.done_button)
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.done.connect(lambda x: callback(x))
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.missing_count)
self.root_layout.addWidget(self.refresh_button)
self.root_layout.addWidget(self.missing_count_label)
self.root_layout.addWidget(self.unlinked_desc_widget)
self.root_layout.addWidget(self.refresh_unlinked_button)
self.root_layout.addWidget(self.search_button)
self.manual_button.setHidden(True)
self.root_layout.addWidget(self.manual_button)
self.root_layout.addWidget(self.delete_button)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.dupe_count_label)
self.root_layout.addWidget(self.dupe_desc_widget)
self.root_layout.addWidget(self.refresh_dupe_button)
self.root_layout.addWidget(self.merge_dupe_button)
self.root_layout.addStretch(2)
self.root_layout.addWidget(self.button_container)
self.set_missing_count(self.count)
self.set_missing_count(self.missing_count)
self.set_dupe_count(self.dupe_count)
def refresh_missing_files(self):
logging.info(f"Start RMF: {QThread.currentThread()}")
# pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Scanning Library')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
iterator = FunctionIterator(self.lib.refresh_missing_files)
pw = ProgressWidget(
window_title="Scanning Library",
label_text=f"Scanning Library for Unlinked Entries...",
label_text="Scanning Library for Unlinked Entries...",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.entries),
)
pw.show()
iterator.value.connect(lambda v: pw.update_progress(v + 1))
# rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(
@@ -159,30 +155,76 @@ class FixUnlinkedEntriesModal(QWidget):
pw.deleteLater(),
self.set_missing_count(len(self.lib.missing_files)),
self.delete_modal.refresh_list(),
self.refresh_dupe_entries(),
)
)
# r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
# QThreadPool.globalInstance().start(r)
# # r.run()
# pass
def refresh_dupe_entries(self):
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
pw = ProgressWidget(
window_title="Scanning Library",
label_text="Scanning Library for Duplicate Entries...",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.entries),
)
pw.show()
iterator.value.connect(lambda v: pw.update_progress(v + 1))
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.set_dupe_count(len(self.lib.dupe_entries)),
)
)
# def update_scan_value(self, pb:QProgressDialog, value=int):
# # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
# pb.setValue(value)
def refresh_and_repair_dupe_entries(self, merge_class: MergeDuplicateEntries):
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
pw = ProgressWidget(
window_title="Scanning Library",
label_text="Scanning Library for Duplicate Entries...",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.entries),
)
pw.show()
iterator.value.connect(lambda v: pw.update_progress(v + 1))
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(), # type: ignore
pw.deleteLater(), # type: ignore
self.set_dupe_count(len(self.lib.dupe_entries)),
merge_class.merge_entries(),
)
)
def set_missing_count(self, count: int):
self.count = count
if self.count < 0:
self.missing_count = count
if self.missing_count < 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
self.missing_count.setText(f"Unlinked Entries: N/A")
elif self.count == 0:
self.missing_count_label.setText("Unlinked Entries: N/A")
elif self.missing_count == 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
self.missing_count.setText(f"Unlinked Entries: {count}")
self.missing_count_label.setText(f"Unlinked Entries: {count}")
else:
self.search_button.setDisabled(False)
self.delete_button.setDisabled(False)
self.missing_count.setText(f"Unlinked Entries: {count}")
self.missing_count_label.setText(f"Unlinked Entries: {count}")
def set_dupe_count(self, count: int):
self.dupe_count = count
if self.dupe_count < 0:
self.dupe_count_label.setText("Duplicate Entries: N/A")
self.merge_dupe_button.setDisabled(True)
elif self.dupe_count == 0:
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
self.merge_dupe_button.setDisabled(True)
else:
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
self.merge_dupe_button.setDisabled(False)

View File

@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
QFrame,
)
from src.core.enums import FieldID
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
@@ -67,19 +68,19 @@ def folders_to_tags(library: Library):
add_tag_to_tree(reversed_tag)
for entry in library.entries:
folders = entry.path.split("\\")
folders = list(entry.path.parts)
if len(folders) == 1 and folders[0] == "":
continue
tag = add_folders_to_tree(folders)
if tag:
if not entry.has_tag(library, tag.id):
entry.add_tag(library, tag.id, 6)
entry.add_tag(library, tag.id, FieldID.TAGS)
logging.info("Done")
def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]:
if list != None:
if list is not None:
list.append(tag)
else:
list = [tag]
@@ -120,7 +121,7 @@ def generate_preview_data(library: Library):
add_tag_to_tree(reversed_tag)
for entry in library.entries:
folders = entry.path.split("\\")
folders = list(entry.path.parts)
if len(folders) == 1 and folders[0] == "":
continue
branch = add_folders_to_tree(folders)
@@ -144,7 +145,7 @@ def generate_preview_data(library: Library):
if cut:
branch["dirs"].pop(folder)
if not "tag" in branch:
if "tag" not in branch:
return
if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first
return False
@@ -289,7 +290,7 @@ class TreeItem(QWidget):
self.children_layout.addWidget(item)
for file in data["files"]:
label = QLabel()
label.setText(" -> " + file)
label.setText(" -> " + str(file))
self.children_layout.addWidget(label)
if len(data["files"]) == 0 and len(data["dirs"].values()) == 0:
@@ -321,7 +322,7 @@ class ModifiedTagWidget(
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
if parentTag != None:
if parentTag is not None:
text = f"{tag.name} ({parentTag.name})".replace("&", "&&")
else:
text = tag.name.replace("&", "&&")

View File

@@ -0,0 +1,46 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import typing
from PySide6.QtCore import QObject, Signal, QThreadPool
from src.core.library import Library
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.widgets.progress import ProgressWidget
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class MergeDuplicateEntries(QObject):
done = Signal()
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
def merge_entries(self):
iterator = FunctionIterator(self.lib.merge_dupe_entries)
pw = ProgressWidget(
window_title="Merging Duplicate Entries",
label_text="",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.dupe_entries),
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x))
iterator.value.connect(
lambda: (pw.update_label("Merging Duplicate Entries..."))
)
r = CustomRunnable(lambda: iterator.run())
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
QThreadPool.globalInstance().start(r)

View File

@@ -26,20 +26,6 @@ class RelinkUnlinkedEntries(QObject):
self.fixed = 0
def repair_entries(self):
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
# # pb.setMaximum(len(self.lib.missing_files))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Relinking Entries')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.repair_entries_runnable(pb))
# r.done.connect(lambda: self.done.emit())
# # r.done.connect(lambda: self.model.clear())
# QThreadPool.globalInstance().start(r)
# # r.run()
iterator = FunctionIterator(self.lib.fix_missing_files)
pw = ProgressWidget(
@@ -49,6 +35,7 @@ class RelinkUnlinkedEntries(QObject):
minimum=0,
maximum=len(self.lib.missing_files),
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
@@ -60,7 +47,6 @@ class RelinkUnlinkedEntries(QObject):
),
)
)
# iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]))
r = CustomRunnable(lambda: iterator.run())
r.done.connect(
@@ -73,27 +59,3 @@ class RelinkUnlinkedEntries(QObject):
def reset_fixed(self):
self.fixed = 0
# def repair_entries_runnable(self, pb: QProgressDialog):
# fixed = 0
# for i in self.lib.fix_missing_files():
# if i[1]:
# fixed += 1
# pb.setValue(i[0])
# pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked')
# for i, missing in enumerate(self.lib.missing_files):
# pb.setValue(i)
# pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries')
# self.lib.fix_missing_files()
# try:
# id = self.lib.get_entry_id_from_filepath(missing)
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
# self.lib.remove_entry(id)
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
# deleted.append(missing)
# except KeyError:
# logging.info(
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
# for d in deleted:
# self.lib.missing_files.remove(d)

View File

@@ -38,7 +38,7 @@ class TagSearchPanel(PanelWidget):
self.lib: Library = library
# self.callback = callback
self.first_tag_id = None
self.tag_limit = 30
self.tag_limit = 100
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
@@ -107,9 +107,7 @@ class TagSearchPanel(PanelWidget):
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
self.scroll_layout.takeAt(0).widget().deleteLater()
found_tags = self.lib.search_tags(query, include_cluster=True)[
: self.tag_limit - 1
]
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
self.first_tag_id = found_tags[0] if found_tags else None
for tag_id in found_tags:

View File

@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
QLineEdit,
QSizePolicy,
)
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
# class NumberEdit(QLineEdit):
# def __init__(self, parent=None) -> None:
@@ -50,13 +50,13 @@ class Pagination(QWidget, QObject):
# self.setMinimumHeight(32)
# [<] ----------------------------------
self.prev_button = QPushButton()
self.prev_button = QPushButtonWrapper()
self.prev_button.setText("<")
self.prev_button.setMinimumSize(self.button_size)
self.prev_button.setMaximumSize(self.button_size)
# --- [1] ------------------------------
self.start_button = QPushButton()
self.start_button = QPushButtonWrapper()
self.start_button.setMinimumSize(self.button_size)
self.start_button.setMaximumSize(self.button_size)
# self.start_button.setStyleSheet('background:cyan;')
@@ -104,14 +104,14 @@ class Pagination(QWidget, QObject):
self.end_ellipses.setText(". . .")
# ----------------------------- [42] ---
self.end_button = QPushButton()
self.end_button = QPushButtonWrapper()
self.end_button.setMinimumSize(self.button_size)
self.end_button.setMaximumSize(self.button_size)
# self.end_button.setMaximumHeight(self.button_size.height())
# self.end_button.setStyleSheet('background:red;')
# ---------------------------------- [>]
self.next_button = QPushButton()
self.next_button = QPushButtonWrapper()
self.next_button.setText(">")
self.next_button.setMinimumSize(self.button_size)
self.next_button.setMaximumSize(self.button_size)
@@ -145,7 +145,6 @@ class Pagination(QWidget, QObject):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
end_page = page_count - 1
if page_count <= 1:
# Hide everything if there are only one or less pages.
# [-------------- HIDDEN --------------]
@@ -178,6 +177,7 @@ class Pagination(QWidget, QObject):
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
self._assign_click(self.prev_button, index - 1)
self.prev_button.setDisabled(False)
if index == end_page:
self.next_button.setDisabled(True)
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
@@ -428,16 +428,15 @@ class Pagination(QWidget, QObject):
# print(f'GOTO PAGE: {index}')
self.update_buttons(self.page_count, index)
def _assign_click(self, button: QPushButton, index):
try:
def _assign_click(self, button: QPushButtonWrapper, index):
if button.is_connected:
button.clicked.disconnect()
except RuntimeError:
pass
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
button.is_connected = True
def _populate_buffer_buttons(self):
for i in range(max(self.buffer_page_count * 2, 5)):
button = QPushButton()
button = QPushButtonWrapper()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
@@ -445,7 +444,7 @@ class Pagination(QWidget, QObject):
self.start_buffer_layout.addWidget(button)
for i in range(max(self.buffer_page_count * 2, 5)):
button = QPushButton()
button = QPushButtonWrapper()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)

View File

@@ -0,0 +1,67 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from pathlib import Path
from typing import Any
import ujson
logging.basicConfig(format="%(message)s", level=logging.INFO)
class ResourceManager:
"""A resource manager for retrieving resources."""
_map: dict = {}
_cache: dict[str, Any] = {}
_initialized: bool = False
def __init__(self) -> None:
# Load JSON resource map
if not ResourceManager._initialized:
with open(
Path(__file__).parent / "resources.json", mode="r", encoding="utf-8"
) as f:
ResourceManager._map = ujson.load(f)
logging.info(
f"[ResourceManager] {len(ResourceManager._map.items())} resources registered"
)
ResourceManager._initialized = True
def get(self, id: str) -> Any:
"""Get a resource from the ResourceManager.
This can include resources inside and outside of QResources, and will return
theme-respecting variations of resources if available.
Args:
id (str): The name of the resource.
Returns:
Any: The resource if found, else None.
"""
cached_res = ResourceManager._cache.get(id)
if cached_res:
return cached_res
else:
res: dict = ResourceManager._map.get(id)
if res.get("mode") in ["r", "rb"]:
with open(
(Path(__file__).parents[2] / "resources" / res.get("path")),
res.get("mode"),
) as f:
data = f.read()
if res.get("mode") == "rb":
data = bytes(data)
ResourceManager._cache[id] = data
return data
elif res.get("mode") in ["qt"]:
# TODO: Qt resource loading logic
pass
def __getattr__(self, __name: str) -> Any:
attr = self.get(__name)
if attr:
return attr
raise AttributeError(f"Attribute {id} not found")

View File

@@ -0,0 +1,18 @@
{
"play_icon": {
"path": "qt/images/play.svg",
"mode": "rb"
},
"pause_icon": {
"path": "qt/images/pause.svg",
"mode": "rb"
},
"volume_icon": {
"path": "qt/images/volume.svg",
"mode": "rb"
},
"volume_mute_icon": {
"path": "qt/images/volume_mute.svg",
"mode": "rb"
}
}

View File

@@ -19,10 +19,17 @@ from datetime import datetime as dt
from pathlib import Path
from queue import Queue
from typing import Optional
from PIL import Image
from PySide6 import QtCore
from PySide6.QtCore import QObject, QThread, Signal, Qt, QThreadPool, QTimer, QSettings
from PySide6.QtCore import (
QObject,
QThread,
Signal,
Qt,
QThreadPool,
QTimer,
QSettings,
)
from PySide6.QtGui import (
QGuiApplication,
QPixmap,
@@ -44,40 +51,28 @@ from PySide6.QtWidgets import (
QSplashScreen,
QMenu,
QMenuBar,
QComboBox,
)
from humanfriendly import format_timespan
from src.core.enums import SettingItems
from src.core.enums import SettingItems, SearchMode
from src.core.library import ItemType
from src.core.ts_core import TagStudioCore
from src.core.constants import (
PLAINTEXT_TYPES,
TAG_COLORS,
DATE_FIELDS,
TEXT_FIELDS,
BOX_FIELDS,
ALL_FILE_TYPES,
SHORTCUT_TYPES,
PROGRAM_TYPES,
ARCHIVE_TYPES,
PRESENTATION_TYPES,
SPREADSHEET_TYPES,
DOC_TYPES,
AUDIO_TYPES,
VIDEO_TYPES,
IMAGE_TYPES,
LIBRARY_FILENAME,
COLLAGE_FOLDER_NAME,
BACKUP_FOLDER_NAME,
TS_FOLDER_NAME,
VERSION_BRANCH,
VERSION,
TAG_FAVORITE,
TAG_ARCHIVED,
)
from src.core.utils.web import strip_web_protocol
from src.qt.flowlayout import FlowLayout
from src.qt.main_window import Ui_MainWindow
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.resource_manager import ResourceManager
from src.qt.widgets.collage_icon import CollageIconRenderer
from src.qt.widgets.panel import PanelModal
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -172,11 +167,14 @@ class QtDriver(QObject):
super().__init__()
self.core: TagStudioCore = core
self.lib = self.core.lib
self.rm: ResourceManager = ResourceManager()
self.args = args
self.frame_dict: dict = {}
self.nav_frames: list[NavigationState] = []
self.cur_frame_idx: int = -1
self.search_mode = SearchMode.AND
# self.main_window = None
# self.main_window = Ui_MainWindow()
@@ -228,7 +226,7 @@ class QtDriver(QObject):
None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly
)
if dir not in (None, ""):
self.open_library(dir)
self.open_library(Path(dir))
def signal_handler(self, sig, frame):
if sig in (SIGINT, SIGTERM, SIGQUIT):
@@ -253,8 +251,8 @@ class QtDriver(QObject):
# pal.setColor(QPalette.ColorGroup.Normal,
# QPalette.ColorRole.Window, QColor('#110F1B'))
# app.setPalette(pal)
home_path = os.path.normpath(f"{Path(__file__).parent}/ui/home.ui")
icon_path = os.path.normpath(f"{Path(__file__).parents[2]}/resources/icon.png")
home_path = Path(__file__).parent / "ui/home.ui"
icon_path = Path(__file__).parents[2] / "resources/icon.png"
# Handle OS signals
self.setup_signals()
@@ -264,7 +262,7 @@ class QtDriver(QObject):
timer.timeout.connect(lambda: None)
# self.main_window = loader.load(home_path)
self.main_window = Ui_MainWindow()
self.main_window = Ui_MainWindow(self)
self.main_window.setWindowTitle(self.base_title)
self.main_window.mousePressEvent = self.mouse_navigation # type: ignore
# self.main_window.setStyleSheet(
@@ -292,7 +290,7 @@ class QtDriver(QObject):
if sys.platform != "darwin":
icon = QIcon()
icon.addFile(icon_path)
icon.addFile(str(icon_path))
app.setWindowIcon(icon)
menu_bar = QMenuBar(self.main_window)
@@ -388,7 +386,26 @@ class QtDriver(QObject):
edit_menu.addSeparator()
manage_file_extensions_action = QAction("Ignored File Extensions", menu_bar)
select_all_action = QAction("Select All", menu_bar)
select_all_action.triggered.connect(self.select_all_action_callback)
select_all_action.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
QtCore.Qt.Key.Key_A,
)
)
select_all_action.setToolTip("Ctrl+A")
edit_menu.addAction(select_all_action)
clear_select_action = QAction("Clear Selection", menu_bar)
clear_select_action.triggered.connect(self.clear_select_action_callback)
clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
clear_select_action.setToolTip("Esc")
edit_menu.addAction(clear_select_action)
edit_menu.addSeparator()
manage_file_extensions_action = QAction("Manage File Extensions", menu_bar)
manage_file_extensions_action.triggered.connect(
lambda: self.show_file_extension_modal()
)
@@ -480,7 +497,6 @@ class QtDriver(QObject):
lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio")
)
help_menu.addAction(self.repo_action)
self.set_macro_menu_viability()
menu_bar.addMenu(file_menu)
@@ -495,9 +511,7 @@ class QtDriver(QObject):
l.addWidget(self.preview_panel)
QFontDatabase.addApplicationFont(
os.path.normpath(
f"{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf"
)
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
)
self.thumb_size = 128
@@ -514,13 +528,21 @@ class QtDriver(QObject):
elif self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool):
lib = self.settings.value(SettingItems.LAST_LIBRARY)
# TODO: Remove this check if the library is no longer saved with files
if lib and not (Path(lib) / TS_FOLDER_NAME).exists():
logging.error(
f"[QT DRIVER] {TS_FOLDER_NAME} folder in {lib} does not exist."
)
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
lib = None
if lib:
self.splash.showMessage(
f'Opening Library "{lib}"...',
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
QColor("#9782ff"),
)
self.open_library(lib)
self.open_library(Path(lib))
if self.args.ci:
# gracefully terminate the app in CI environment
@@ -531,6 +553,7 @@ class QtDriver(QObject):
self.shutdown()
def init_library_window(self):
# self._init_landing_page() # Taken care of inside the widget now
self._init_thumb_grid()
# TODO: Put this into its own method that copies the font file(s) into memory
@@ -547,12 +570,25 @@ class QtDriver(QObject):
search_field.returnPressed.connect(
lambda: self.filter_items(self.main_window.searchField.text())
)
search_type_selector: QComboBox = self.main_window.comboBox_2
search_type_selector.currentIndexChanged.connect(
lambda: self.set_search_type(
SearchMode(search_type_selector.currentIndex())
)
)
back_button: QPushButton = self.main_window.backButton
back_button.clicked.connect(self.nav_back)
forward_button: QPushButton = self.main_window.forwardButton
forward_button.clicked.connect(self.nav_forward)
# NOTE: Putting this early will result in a white non-responsive
# window until everything is loaded. Consider adding a splash screen
# or implementing some clever loading tricks.
self.main_window.show()
self.main_window.activateWindow()
self.main_window.toggle_landing_page(True)
self.frame_dict = {}
self.main_window.pagination.index.connect(
lambda i: (
@@ -574,11 +610,6 @@ class QtDriver(QObject):
# self.render_times: list = []
# self.main_window.setWindowFlag(Qt.FramelessWindowHint)
# NOTE: Putting this early will result in a white non-responsive
# window until everything is loaded. Consider adding a splash screen
# or implementing some clever loading tricks.
self.main_window.show()
self.main_window.activateWindow()
# self.main_window.raise_()
self.splash.finish(self.main_window)
self.preview_panel.update_widgets()
@@ -664,6 +695,7 @@ class QtDriver(QObject):
self.selected.clear()
self.preview_panel.update_widgets()
self.filter_items()
self.main_window.toggle_landing_page(True)
end_time = time.time()
self.main_window.statusbar.showMessage(
@@ -677,7 +709,7 @@ class QtDriver(QObject):
fn = self.lib.save_library_backup_to_disk()
end_time = time.time()
self.main_window.statusbar.showMessage(
f'Library Backup Saved at: "{os.path.normpath(os.path.normpath(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}/{fn}"))}" ({format_timespan(end_time - start_time)})'
f'Library Backup Saved at: "{ self.lib.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / fn}" ({format_timespan(end_time - start_time)})'
)
def add_tag_action_callback(self):
@@ -692,6 +724,23 @@ class QtDriver(QObject):
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
self.modal.show()
def select_all_action_callback(self):
for item in self.item_thumbs:
if item.mode and (item.mode, item.item_id) not in self.selected:
self.selected.append((item.mode, item.item_id))
item.thumb_button.set_selected(True)
self.set_macro_menu_viability()
self.preview_panel.update_widgets()
def clear_select_action_callback(self):
self.selected.clear()
for item in self.item_thumbs:
item.thumb_button.set_selected(False)
self.set_macro_menu_viability()
self.preview_panel.update_widgets()
def show_tag_database(self):
self.modal = PanelModal(
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
@@ -699,10 +748,12 @@ class QtDriver(QObject):
self.modal.show()
def show_file_extension_modal(self):
# self.modal = FileExtensionModal(self.lib)
panel = FileExtensionModal(self.lib)
self.modal = PanelModal(
panel, "Ignored File Extensions", "Ignored File Extensions", has_save=True
panel,
"File Extensions",
"File Extensions",
has_save=True,
)
self.modal.saved.connect(lambda: (panel.save(), self.filter_items("")))
self.modal.show()
@@ -847,8 +898,8 @@ class QtDriver(QObject):
def run_macro(self, name: str, entry_id: int):
"""Runs a specific Macro on an Entry given a Macro name."""
entry = self.lib.get_entry(entry_id)
path = os.path.normpath(f"{self.lib.library_dir}/{entry.path}/{entry.filename}")
source = path.split(os.sep)[1].lower()
path = self.lib.library_dir / entry.path / entry.filename
source = entry.path.parts[0]
if name == "sidecar":
self.lib.add_generic_data_to_entry(
self.core.get_gdl_sidecar(path, source), entry_id
@@ -1200,13 +1251,11 @@ class QtDriver(QObject):
entry = self.lib.get_entry(
self.nav_frames[self.cur_frame_idx].contents[i][1]
)
filepath = os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
filepath = self.lib.library_dir / entry.path / entry.filename
item_thumb.set_item_id(entry.id)
item_thumb.assign_archived(entry.has_tag(self.lib, 0))
item_thumb.assign_favorite(entry.has_tag(self.lib, 1))
item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED))
item_thumb.assign_favorite(entry.has_tag(self.lib, TAG_FAVORITE))
# ctrl_down = True if QGuiApplication.keyboardModifiers() else False
# TODO: Change how this works. The click function
# for collations a few lines down should NOT be allowed during modifier keys.
@@ -1247,9 +1296,7 @@ class QtDriver(QObject):
else collation.e_ids_and_pages[0][0]
)
cover_e = self.lib.get_entry(cover_id)
filepath = os.path.normpath(
f"{self.lib.library_dir}/{cover_e.path}/{cover_e.filename}"
)
filepath = self.lib.library_dir / cover_e.path / cover_e.filename
item_thumb.set_count(str(len(collation.e_ids_and_pages)))
item_thumb.update_clickable(
clickable=(
@@ -1312,7 +1359,7 @@ class QtDriver(QObject):
# self.filtered_items = self.lib.search_library(query)
# 73601 Entries at 500 size should be 246
all_items = self.lib.search_library(query)
all_items = self.lib.search_library(query, search_mode=self.search_mode)
frames: list[list[tuple[ItemType, int]]] = []
frame_count = math.ceil(len(all_items) / self.max_results)
for i in range(0, frame_count):
@@ -1353,6 +1400,10 @@ class QtDriver(QObject):
# self.update_thumbs()
def set_search_type(self, mode=SearchMode.AND):
self.search_mode = mode
self.filter_items(self.main_window.searchField.text())
def remove_recent_library(self, item_key: str):
self.settings.beginGroup(SettingItems.LIBS_LIST)
self.settings.remove(item_key)
@@ -1386,25 +1437,20 @@ class QtDriver(QObject):
self.settings.endGroup()
self.settings.sync()
def open_library(self, path):
def open_library(self, path: Path):
"""Opens a TagStudio library."""
open_message: str = f'Opening Library "{str(path)}"...'
self.main_window.landing_widget.set_status_label(open_message)
self.main_window.statusbar.showMessage(open_message, 3)
self.main_window.repaint()
if self.lib.library_dir:
self.save_library()
self.lib.clear_internal_vars()
self.main_window.statusbar.showMessage(f"Opening Library {path}", 3)
return_code = self.lib.open_library(path)
if return_code == 1:
# if self.args.external_preview:
# self.init_external_preview()
# if len(self.lib.entries) <= 1000:
# print(f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...')
# self.lib.refresh_missing_files()
# title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\''
# self.main_window.setWindowTitle(title_text)
pass
else:
logging.info(
f"{ERROR} No existing TagStudio library found at '{path}'. Creating one."
@@ -1422,6 +1468,7 @@ class QtDriver(QObject):
self.selected.clear()
self.preview_panel.update_widgets()
self.filter_items()
self.main_window.toggle_landing_page(False)
def create_collage(self) -> None:
"""Generates and saves an image collage based on Library Entries."""
@@ -1548,8 +1595,11 @@ class QtDriver(QObject):
self.completed += 1
# logging.info(f'threshold:{len(self.lib.entries}, completed:{self.completed}')
if self.completed == len(self.lib.entries):
filename = os.path.normpath(
f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{dt.utcnow().strftime("%F_%T").replace(":", "")}.png'
filename = (
self.lib.library_dir
/ TS_FOLDER_NAME
/ COLLAGE_FOLDER_NAME
/ f'collage_{dt.utcnow().strftime("%F_%T").replace(":", "")}.png'
)
self.collage.save(filename)
self.collage = None

View File

@@ -16,6 +16,71 @@
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="5" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QComboBox" name="comboBox_2">
<property name="minimumSize">
<size>
<width>165</width>
<height>0</height>
</size>
</property>
<item>
<property name="text">
<string>And (includes all tags)</string>
</property>
</item>
<item>
<property name="text">
<string>Or (includes any tag)</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>128</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>256</width>
<height>32</height>
</size>
</property>
<property name="currentText">
<string/>
</property>
<property name="placeholderText">
<string>Thumbnail Size</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="9" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QScrollArea" name="scrollArea">
@@ -37,7 +102,7 @@
<x>0</x>
<y>0</y>
<width>1260</width>
<height>590</height>
<height>585</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout_2">
@@ -158,34 +223,6 @@
</item>
</layout>
</item>
<item row="4" column="0" alignment="Qt::AlignRight">
<widget class="QComboBox" name="comboBox">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>128</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>256</width>
<height>32</height>
</size>
</property>
<property name="currentText">
<string/>
</property>
<property name="placeholderText">
<string>Thumbnail Size</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
@@ -194,7 +231,7 @@
<x>0</x>
<y>0</y>
<width>1280</width>
<height>22</height>
<height>21</height>
</rect>
</property>
</widget>

View File

@@ -3,7 +3,7 @@
################################################################################
## Form generated from reading UI file 'home.ui'
##
## Created by: Qt User Interface Compiler version 6.5.1
## Created by: Qt User Interface Compiler version 6.6.3
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
@@ -18,7 +18,7 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
from PySide6.QtWidgets import (QApplication, QComboBox, QFrame, QGridLayout,
QHBoxLayout, QLayout, QLineEdit, QMainWindow,
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget)
QSpacerItem, QStatusBar, QWidget)
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
@@ -29,6 +29,35 @@ class Ui_MainWindow(object):
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
self.horizontalLayout_3 = QHBoxLayout()
self.horizontalLayout_3.setObjectName(u"horizontalLayout_3")
self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.horizontalLayout_3.addItem(self.horizontalSpacer)
self.comboBox_2 = QComboBox(self.centralwidget)
self.comboBox_2.addItem("")
self.comboBox_2.addItem("")
self.comboBox_2.setObjectName(u"comboBox_2")
self.comboBox_2.setMinimumSize(QSize(165, 0))
self.horizontalLayout_3.addWidget(self.comboBox_2)
self.comboBox = QComboBox(self.centralwidget)
self.comboBox.setObjectName(u"comboBox")
sizePolicy = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.comboBox.sizePolicy().hasHeightForWidth())
self.comboBox.setSizePolicy(sizePolicy)
self.comboBox.setMinimumSize(QSize(128, 0))
self.comboBox.setMaximumSize(QSize(256, 32))
self.horizontalLayout_3.addWidget(self.comboBox)
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.scrollArea = QScrollArea(self.centralwidget)
@@ -39,7 +68,7 @@ class Ui_MainWindow(object):
self.scrollArea.setWidgetResizable(True)
self.scrollAreaWidgetContents = QWidget()
self.scrollAreaWidgetContents.setObjectName(u"scrollAreaWidgetContents")
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 590))
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 585))
self.gridLayout_2 = QGridLayout(self.scrollAreaWidgetContents)
self.gridLayout_2.setSpacing(8)
self.gridLayout_2.setObjectName(u"gridLayout_2")
@@ -49,7 +78,7 @@ class Ui_MainWindow(object):
self.horizontalLayout.addWidget(self.scrollArea)
self.gridLayout.addLayout(self.horizontalLayout, 5, 0, 1, 1)
self.gridLayout.addLayout(self.horizontalLayout, 9, 0, 1, 1)
self.horizontalLayout_2 = QHBoxLayout()
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
@@ -97,26 +126,14 @@ class Ui_MainWindow(object):
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
self.comboBox = QComboBox(self.centralwidget)
self.comboBox.setObjectName(u"comboBox")
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.comboBox.sizePolicy().hasHeightForWidth())
self.comboBox.setSizePolicy(sizePolicy)
self.comboBox.setMinimumSize(QSize(128, 0))
self.comboBox.setMaximumSize(QSize(256, 32))
self.gridLayout.addWidget(self.comboBox, 4, 0, 1, 1, Qt.AlignRight)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QMenuBar(MainWindow)
self.menubar.setObjectName(u"menubar")
self.menubar.setGeometry(QRect(0, 0, 1280, 22))
self.menubar.setGeometry(QRect(0, 0, 1280, 21))
MainWindow.setMenuBar(self.menubar)
self.statusbar = QStatusBar(MainWindow)
self.statusbar.setObjectName(u"statusbar")
sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
sizePolicy1.setHeightForWidth(self.statusbar.sizePolicy().hasHeightForWidth())
@@ -130,11 +147,14 @@ class Ui_MainWindow(object):
def retranslateUi(self, MainWindow):
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None))
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", u"And (includes all tags)", None))
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", u"Or (includes any tag)", None))
self.comboBox.setCurrentText("")
self.comboBox.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
self.backButton.setText(QCoreApplication.translate("MainWindow", u"<", None))
self.forwardButton.setText(QCoreApplication.translate("MainWindow", u">", None))
self.searchField.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Search Entries", None))
self.searchButton.setText(QCoreApplication.translate("MainWindow", u"Search", None))
self.comboBox.setCurrentText("")
self.comboBox.setPlaceholderText(QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
# retranslateUi

View File

@@ -0,0 +1,18 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QLabel
class ClickableLabel(QLabel):
"""A clickable Label widget."""
clicked = Signal()
def __init__(self):
super().__init__()
def mousePressEvent(self, event):
self.clicked.emit()

View File

@@ -52,9 +52,7 @@ class CollageIconRenderer(QObject):
keep_aspect,
):
entry = self.lib.get_entry(entry_id)
filepath = os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
filepath = self.lib.library_dir / entry.path / entry.filename
file_type = os.path.splitext(filepath)[1].lower()[1:]
color: str = ""
@@ -91,16 +89,14 @@ class CollageIconRenderer(QObject):
self.rendered.emit(pic)
if not data_only_mode:
logging.info(
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}\033[0m"
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
)
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
# sys.stdout.flush()
if file_type in IMAGE_TYPES:
if filepath.suffix.lower() in IMAGE_TYPES:
try:
with Image.open(
os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
str(self.lib.library_dir / entry.path / entry.filename)
) as pic:
if keep_aspect:
pic.thumbnail(size)
@@ -115,8 +111,8 @@ class CollageIconRenderer(QObject):
self.rendered.emit(pic)
except DecompressionBombError as e:
logging.info(f"[ERROR] One of the images was too big ({e})")
elif file_type in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
elif filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(str(filepath))
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
@@ -145,8 +141,9 @@ class CollageIconRenderer(QObject):
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
)
with Image.open(
os.path.normpath(
f"{Path(__file__).parents[2]}/resources/qt/images/thumb_broken_512.png"
str(
Path(__file__).parents[2]
/ "resources/qt/images/thumb_broken_512.png"
)
) as pic:
pic.thumbnail(size)

View File

@@ -13,28 +13,23 @@ from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
class FieldContainer(QWidget):
# TODO: reference a resources folder rather than path.parents[3]?
clipboard_icon_128: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/clipboard_icon_128.png"
)
str(Path(__file__).parents[3] / "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(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/edit_icon_128.png"
)
str(Path(__file__).parents[3] / "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(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/trash_icon_128.png"
)
str(Path(__file__).parents[3] / "resources/qt/images/trash_icon_128.png")
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
trash_icon_128.load()
@@ -87,7 +82,7 @@ class FieldContainer(QWidget):
self.title_layout.addStretch(2)
self.copy_button = QPushButton()
self.copy_button = QPushButtonWrapper()
self.copy_button.setMinimumSize(button_size, button_size)
self.copy_button.setMaximumSize(button_size, button_size)
self.copy_button.setFlat(True)
@@ -98,7 +93,7 @@ class FieldContainer(QWidget):
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
self.edit_button = QPushButton()
self.edit_button = QPushButtonWrapper()
self.edit_button.setMinimumSize(button_size, button_size)
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
@@ -107,7 +102,7 @@ class FieldContainer(QWidget):
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
self.remove_button = QPushButton()
self.remove_button = QPushButtonWrapper()
self.remove_button.setMinimumSize(button_size, button_size)
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
@@ -130,31 +125,30 @@ class FieldContainer(QWidget):
# self.set_inner_widget(mode)
def set_copy_callback(self, callback: Optional[MethodType]):
try:
if self.copy_button.is_connected:
self.copy_button.clicked.disconnect()
except RuntimeError:
pass
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
if callback is not None:
self.copy_button.is_connected = True
def set_edit_callback(self, callback: Optional[MethodType]):
try:
if self.edit_button.is_connected:
self.edit_button.clicked.disconnect()
except RuntimeError:
pass
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
if callback is not None:
self.edit_button.is_connected = True
def set_remove_callback(self, callback: Optional[Callable]):
try:
if self.remove_button.is_connected:
self.remove_button.clicked.disconnect()
except RuntimeError:
pass
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
self.remove_button.is_connected = True
def set_inner_widget(self, widget: "FieldWidget"):
# widget.setStyleSheet('background-color:green;')

View File

@@ -1,13 +1,11 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import contextlib
import logging
import os
import time
import typing
from types import FunctionType
from pathlib import Path
from typing import Optional
@@ -23,9 +21,15 @@ from PySide6.QtWidgets import (
QCheckBox,
)
from src.core.enums import FieldID
from src.core.library import ItemType, Library, Entry
from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.core.constants import (
AUDIO_TYPES,
VIDEO_TYPES,
IMAGE_TYPES,
TAG_FAVORITE,
TAG_ARCHIVED,
)
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -38,9 +42,6 @@ ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
DEFAULT_META_TAG_FIELD = 8
TAG_FAVORITE = 1
TAG_ARCHIVED = 0
logging.basicConfig(format="%(message)s", level=logging.INFO)
@@ -53,16 +54,12 @@ class ItemThumb(FlowWidget):
update_cutoff: float = time.time()
collation_icon_128: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/collation_icon_128.png"
)
str(Path(__file__).parents[3] / "resources/qt/images/collation_icon_128.png")
)
collation_icon_128.load()
tag_group_icon_128: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/tag_group_icon_128.png"
)
str(Path(__file__).parents[3] / "resources/qt/images/tag_group_icon_128.png")
)
tag_group_icon_128.load()
@@ -315,6 +312,7 @@ class ItemThumb(FlowWidget):
def set_mode(self, mode: Optional[ItemType]) -> None:
if mode is None:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.unsetCursor()
self.thumb_button.setHidden(True)
# self.check_badges.setHidden(True)
@@ -322,6 +320,7 @@ class ItemThumb(FlowWidget):
# self.item_type_badge.setHidden(True)
pass
elif mode == ItemType.ENTRY and self.mode != ItemType.ENTRY:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.cb_container.setHidden(False)
@@ -331,6 +330,7 @@ class ItemThumb(FlowWidget):
self.count_badge.setHidden(True)
self.ext_badge.setHidden(True)
elif mode == ItemType.COLLATION and self.mode != ItemType.COLLATION:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
self.cb_container.setHidden(True)
@@ -339,6 +339,7 @@ class ItemThumb(FlowWidget):
self.count_badge.setHidden(False)
self.item_type_badge.setHidden(False)
elif mode == ItemType.TAG_GROUP and self.mode != ItemType.TAG_GROUP:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.thumb_button.setHidden(False)
# self.cb_container.setHidden(True)
@@ -354,9 +355,11 @@ class ItemThumb(FlowWidget):
# pass
def set_extension(self, ext: str) -> None:
if ext and ext not in IMAGE_TYPES or ext in ["gif", "apng"]:
if ext and ext.startswith(".") is False:
ext = "." + ext
if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]:
self.ext_badge.setHidden(False)
self.ext_badge.setText(ext.upper())
self.ext_badge.setText(ext.upper()[1:])
if ext in VIDEO_TYPES + AUDIO_TYPES:
self.count_badge.setHidden(False)
else:
@@ -392,19 +395,22 @@ class ItemThumb(FlowWidget):
def update_clickable(self, clickable: typing.Callable):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}')
try:
if self.thumb_button.is_connected:
self.thumb_button.clicked.disconnect()
except RuntimeError:
pass
if clickable:
self.thumb_button.clicked.connect(clickable)
self.thumb_button.is_connected = True
def update_badges(self):
if self.mode == ItemType.ENTRY:
# logging.info(f'[UPDATE BADGES] ENTRY: {self.lib.get_entry(self.item_id)}')
# logging.info(f'[UPDATE BADGES] ARCH: {self.lib.get_entry(self.item_id).has_tag(self.lib, 0)}, FAV: {self.lib.get_entry(self.item_id).has_tag(self.lib, 1)}')
self.assign_archived(self.lib.get_entry(self.item_id).has_tag(self.lib, 0))
self.assign_favorite(self.lib.get_entry(self.item_id).has_tag(self.lib, 1))
self.assign_archived(
self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_ARCHIVED)
)
self.assign_favorite(
self.lib.get_entry(self.item_id).has_tag(self.lib, TAG_FAVORITE)
)
def set_item_id(self, id: int):
"""
@@ -414,9 +420,7 @@ class ItemThumb(FlowWidget):
if id == -1:
return
entry = self.lib.get_entry(self.item_id)
filepath = os.path.normpath(
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
)
filepath = self.lib.library_dir / entry.path / entry.filename
self.opener.set_filepath(filepath)
def assign_favorite(self, value: bool):
@@ -475,7 +479,7 @@ class ItemThumb(FlowWidget):
entry.add_tag(
self.panel.driver.lib,
tag_id,
field_id=DEFAULT_META_TAG_FIELD,
field_id=FieldID.META_TAGS,
field_index=-1,
)
else:

View File

@@ -0,0 +1,181 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import sys
import typing
from pathlib import Path
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QPropertyAnimation, QPoint, QEasingCurve
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QPushButton
from src.qt.widgets.clickable_label import ClickableLabel
from src.qt.helpers.color_overlay import gradient_overlay, theme_fg_overlay
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
logging.basicConfig(format="%(message)s", level=logging.INFO)
class LandingWidget(QWidget):
def __init__(self, driver: "QtDriver", pixel_ratio: float):
super().__init__()
self.driver: "QtDriver" = driver
self.logo_label: ClickableLabel = ClickableLabel()
self._pixel_ratio: float = pixel_ratio
self._logo_width: int = int(480 * pixel_ratio)
self._special_click_count: int = 0
# Create layout --------------------------------------------------------
self.landing_layout = QVBoxLayout()
self.landing_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.landing_layout.setSpacing(12)
self.setLayout(self.landing_layout)
# Create landing logo --------------------------------------------------
# self.landing_logo_pixmap = QPixmap(":/images/tagstudio_logo_text_mono.png")
self.logo_raw: Image.Image = Image.open(
Path(__file__).parents[3]
/ "resources/qt/images/tagstudio_logo_text_mono.png"
)
self.landing_pixmap: QPixmap = QPixmap()
self.update_logo_color()
self.logo_label.clicked.connect(self._update_special_click)
# Initialize landing logo animation ------------------------------------
self.logo_pos_anim = QPropertyAnimation(self.logo_label, b"pos")
self.logo_pos_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self.logo_pos_anim.setDuration(1000)
self.logo_special_anim = QPropertyAnimation(self.logo_label, b"pos")
self.logo_special_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self.logo_special_anim.setDuration(500)
# Create "Open/Create Library" button ----------------------------------
open_shortcut_text: str = ""
if sys.platform == "darwin":
open_shortcut_text = "(⌘+O)"
else:
open_shortcut_text = "(Ctrl+O)"
self.open_button: QPushButton = QPushButton()
self.open_button.setMinimumWidth(200)
self.open_button.setText(f"Open/Create Library {open_shortcut_text}")
self.open_button.clicked.connect(self.driver.open_library_from_dialog)
# Create status label --------------------------------------------------
self.status_label = QLabel()
self.status_label.setMinimumWidth(200)
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.status_label.setText("")
# Initialize landing logo animation ------------------------------------
self.status_pos_anim = QPropertyAnimation(self.status_label, b"pos")
self.status_pos_anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self.status_pos_anim.setDuration(500)
# Add widgets to layout ------------------------------------------------
self.landing_layout.addWidget(self.logo_label)
self.landing_layout.addWidget(
self.open_button, alignment=Qt.AlignmentFlag.AlignCenter
)
self.landing_layout.addWidget(
self.status_label, alignment=Qt.AlignmentFlag.AlignCenter
)
def update_logo_color(self, style: str = "mono"):
"""
Update the color of the TagStudio logo.
Args:
style (str): = The style of the logo. Either "mono" or "gradient".
"""
logo_im: Image.Image = None
if style == "mono":
logo_im = theme_fg_overlay(self.logo_raw)
elif style == "gradient":
gradient_colors: list[str] = ["#d27bf4", "#7992f5", "#63c6e3", "#63f5cf"]
logo_im = gradient_overlay(self.logo_raw, gradient_colors)
logo_final: Image.Image = Image.new(
mode="RGBA", size=self.logo_raw.size, color="#00000000"
)
logo_final.paste(logo_im, (0, 0), mask=self.logo_raw)
self.landing_pixmap = QPixmap.fromImage(ImageQt.ImageQt(logo_im))
self.landing_pixmap.setDevicePixelRatio(self._pixel_ratio)
self.landing_pixmap = self.landing_pixmap.scaledToWidth(
self._logo_width, Qt.TransformationMode.SmoothTransformation
)
self.logo_label.setMaximumHeight(
int(self.logo_raw.size[1] * (self.logo_raw.size[0] / self._logo_width))
)
self.logo_label.setMaximumWidth(self._logo_width)
self.logo_label.setPixmap(self.landing_pixmap)
def _update_special_click(self):
"""
Increment the click count for the logo easter egg if it has not
been triggered. If it reaches the click threshold, this triggers it
and prevents it from triggering again.
"""
if self._special_click_count >= 0:
self._special_click_count += 1
if self._special_click_count >= 10:
self.update_logo_color("gradient")
self.animate_logo_pop()
self._special_click_count = -1
def animate_logo_in(self):
"""Animate in the TagStudio logo."""
# NOTE: Sometimes, mostly on startup without a library open, the
# y position of logo_label is something like 10. I'm not sure what
# the cause of this is, so I've just done this workaround to disable
# the animation if the y position is too incorrect.
if self.logo_label.y() > 50:
self.logo_pos_anim.setStartValue(
QPoint(self.logo_label.x(), self.logo_label.y() - 100)
)
self.logo_pos_anim.setEndValue(self.logo_label.pos())
self.logo_pos_anim.start()
def animate_logo_pop(self):
"""Special pop animation for the TagStudio logo."""
self.logo_special_anim.setStartValue(self.logo_label.pos())
self.logo_special_anim.setKeyValueAt(
0.25, QPoint(self.logo_label.x() - 5, self.logo_label.y())
)
self.logo_special_anim.setKeyValueAt(
0.5, QPoint(self.logo_label.x() + 5, self.logo_label.y() - 10)
)
self.logo_special_anim.setKeyValueAt(
0.75, QPoint(self.logo_label.x() - 5, self.logo_label.y())
)
self.logo_special_anim.setEndValue(self.logo_label.pos())
self.logo_special_anim.start()
# def animate_status(self):
# # if self.status_label.y() > 50:
# logging.info(f"{self.status_label.pos()}")
# self.status_pos_anim.setStartValue(
# QPoint(self.status_label.x(), self.status_label.y() + 50)
# )
# self.status_pos_anim.setEndValue(self.status_label.pos())
# self.status_pos_anim.start()
def set_status_label(self, text=str):
"""
Set the text of the status label.
Args:
text (str): Text of the status to set.
"""
# if text:
# self.animate_status()
self.status_label.setText(text)

View File

@@ -3,7 +3,7 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
from pathlib import Path
import time
import typing
from datetime import datetime as dt
@@ -30,7 +30,7 @@ from humanfriendly import format_size
from src.core.enums import SettingItems, Theme
from src.core.library import Entry, ItemType, Library
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
from src.qt.modals.add_field import AddFieldModal
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -40,16 +40,17 @@ from src.qt.widgets.text import TextWidget
from src.qt.widgets.panel import PanelModal
from src.qt.widgets.text_box_edit import EditTextBox
from src.qt.widgets.text_line_edit import EditTextLine
from src.qt.widgets.item_thumb import ItemThumb
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.widgets.video_player import VideoPlayer
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
@@ -61,6 +62,7 @@ class PreviewPanel(QWidget):
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__()
self.is_connected = False
self.lib = library
self.driver: QtDriver = driver
self.initialized = False
@@ -83,14 +85,15 @@ class PreviewPanel(QWidget):
self.open_file_action = QAction("Open file", self)
self.open_explorer_action = QAction("Open file in explorer", self)
self.preview_img = QPushButton()
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_vid = VideoPlayer(driver)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer()
self.thumb_renderer.updated.connect(
lambda ts, i, s: (self.preview_img.setIcon(i))
@@ -110,7 +113,9 @@ class PreviewPanel(QWidget):
image_layout.addWidget(self.preview_img)
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
image_layout.addWidget(self.preview_vid)
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
self.image_container.setMinimumSize(*self.img_button_size)
self.file_label = FileOpenerLabel("Filename")
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(
@@ -215,7 +220,7 @@ class PreviewPanel(QWidget):
self.afb_layout = QVBoxLayout(self.afb_container)
self.afb_layout.setContentsMargins(0, 12, 0, 0)
self.add_field_button = QPushButton()
self.add_field_button = QPushButtonWrapper()
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumSize(96, 28)
self.add_field_button.setMaximumSize(96, 28)
@@ -276,7 +281,9 @@ class PreviewPanel(QWidget):
row_layout.addWidget(label)
layout.addLayout(row_layout)
def set_button_style(btn: QPushButton, extras: list[str] | None = None):
def set_button_style(
btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
):
base_style = [
f"background-color:{Theme.COLOR_BG.value};",
"border-radius:6px;",
@@ -295,6 +302,7 @@ class PreviewPanel(QWidget):
"}"
f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}"
f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}"
f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}"
)
)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -303,12 +311,16 @@ class PreviewPanel(QWidget):
button = QPushButton(text=cut_val)
button.setObjectName(f"path{item_key}")
lib = Path(full_val)
if not lib.exists() or not (lib / TS_FOLDER_NAME).exists():
button.setDisabled(True)
button.setToolTip("Location is missing")
def open_library_button_clicked(path):
return lambda: self.driver.open_library(path)
return lambda: self.driver.open_library(Path(path))
button.clicked.connect(open_library_button_clicked(full_val))
set_button_style(button)
button_remove = QPushButton("")
button_remove.setCursor(Qt.CursorShape.PointingHandCursor)
button_remove.setFixedWidth(30)
@@ -378,6 +390,9 @@ class PreviewPanel(QWidget):
self.img_button_size = (int(adj_width), int(adj_height))
self.preview_img.setMaximumSize(adj_size)
self.preview_img.setIconSize(adj_size)
self.preview_vid.resizeVideo(adj_size)
self.preview_vid.setMaximumSize(adj_size)
self.preview_vid.setMinimumSize(adj_size)
# self.preview_img.setMinimumSize(adj_size)
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
@@ -399,16 +414,16 @@ class PreviewPanel(QWidget):
self.afb_container, Qt.AlignmentFlag.AlignHCenter
)
try:
if self.afm.is_connected:
self.afm.done.disconnect()
if self.add_field_button.is_connected:
self.add_field_button.clicked.disconnect()
except RuntimeError:
pass
# self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets()))
self.afm.done.connect(
lambda f: (self.add_field_to_selected(f), self.update_widgets())
)
self.afm.is_connected = True
self.add_field_button.clicked.connect(self.afm.show)
def add_field_to_selected(self, field_id: int):
@@ -435,7 +450,7 @@ class PreviewPanel(QWidget):
# 0 Selected Items
if not self.driver.selected:
if self.selected or not self.initialized:
self.file_label.setText(f"No Items Selected")
self.file_label.setText("No Items Selected")
self.file_label.setFilePath("")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
@@ -454,13 +469,13 @@ class PreviewPanel(QWidget):
True,
update_on_ratio_change=True,
)
try:
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
for i, c in enumerate(self.containers):
c.setHidden(True)
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
self.selected = list(self.driver.selected)
self.add_field_button.setHidden(True)
@@ -468,14 +483,15 @@ class PreviewPanel(QWidget):
elif len(self.driver.selected) == 1:
# 1 Selected Entry
if self.driver.selected[0][0] == ItemType.ENTRY:
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
# If a new selection is made, update the thumbnail and filepath.
if not self.selected or self.selected != self.driver.selected:
filepath = os.path.normpath(
f"{self.lib.library_dir}/{item.path}/{item.filename}"
)
filepath = self.lib.library_dir / item.path / item.filename
self.file_label.setFilePath(filepath)
window_title = filepath
window_title = str(filepath)
ratio: float = self.devicePixelRatio()
self.thumb_renderer.render(
time.time(),
@@ -484,7 +500,7 @@ class PreviewPanel(QWidget):
ratio,
update_on_ratio_change=True,
)
self.file_label.setText("\u200b".join(filepath))
self.file_label.setText("\u200b".join(str(filepath)))
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
self.preview_img.setContextMenuPolicy(
@@ -499,56 +515,86 @@ class PreviewPanel(QWidget):
)
# TODO: Do this somewhere else, this is just here temporarily.
extension = os.path.splitext(filepath)[1][1:].lower()
try:
image = None
if extension in IMAGE_TYPES:
image = Image.open(filepath)
elif extension in RAW_IMAGE_TYPES:
with rawpy.imread(filepath) as raw:
rgb = raw.postprocess()
image = Image.new(
"L", (rgb.shape[1], rgb.shape[0]), color="black"
)
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
if filepath.suffix.lower() in IMAGE_TYPES:
image = Image.open(str(filepath))
elif filepath.suffix.lower() in RAW_IMAGE_TYPES:
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess()
image = Image.new(
"L", (rgb.shape[1], rgb.shape[0]), color="black"
)
except (
rawpy._rawpy.LibRawIOError,
rawpy._rawpy.LibRawFileUnsupportedError,
):
pass
elif filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(str(filepath))
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
raise cv2.error("File is invalid or has 0 frames")
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
if success:
self.preview_img.hide()
self.preview_vid.play(
filepath, QSize(image.width, image.height)
)
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
self.preview_vid.show()
# Stats for specific file types are displayed here.
if extension in (IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES):
if image and filepath.suffix.lower() in (
IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES
):
self.dimensions_label.setText(
f"{extension.upper()}{format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px"
f"{filepath.suffix.upper()[1:]}{format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
)
else:
self.dimensions_label.setText(f"{extension.upper()}")
self.dimensions_label.setText(
f"{filepath.suffix.upper()[1:]}{format_size(filepath.stat().st_size)}"
)
if not image:
raise UnidentifiedImageError
if not filepath.is_file():
raise FileNotFoundError
except FileNotFoundError as e:
self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}")
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
except (FileNotFoundError, cv2.error) as e:
self.dimensions_label.setText(f"{filepath.suffix.upper()}")
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
except (
UnidentifiedImageError,
FileNotFoundError,
cv2.error,
DecompressionBombError,
) as e:
self.dimensions_label.setText(
f"{extension.upper()}{format_size(os.stat(filepath).st_size)}"
f"{filepath.suffix.upper()[1:]}{format_size(filepath.stat().st_size)}"
)
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
try:
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
self.preview_img.clicked.connect(
lambda checked=False, filepath=filepath: open_file(filepath)
)
self.preview_img.is_connected = True
self.selected = list(self.driver.selected)
for i, f in enumerate(item.fields):
self.write_container(i, f)
@@ -571,6 +617,9 @@ class PreviewPanel(QWidget):
# Multiple Selected Items
elif len(self.driver.selected) > 1:
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
if self.selected != self.driver.selected:
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
@@ -591,10 +640,9 @@ class PreviewPanel(QWidget):
True,
update_on_ratio_change=True,
)
try:
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
self.preview_img.is_connected = False
self.common_fields = []
self.mixed_fields = []
@@ -723,12 +771,12 @@ class PreviewPanel(QWidget):
"""
Replacement for tag_callback.
"""
try:
if self.is_connected:
self.tags_updated.disconnect()
except RuntimeError:
pass
logging.info(f"[UPDATE CONTAINER] Setting tags updated slot")
logging.info("[UPDATE CONTAINER] Setting tags updated slot")
self.tags_updated.connect(slot)
self.is_connected = True
# def write_container(self, item:Union[Entry, Collation, Tag], index, field):
def write_container(self, index, field, mixed=False):
@@ -1017,7 +1065,8 @@ class PreviewPanel(QWidget):
)
# remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel)
remove_mb.setDefaultButton(cancel_button)
remove_mb.setEscapeButton(cancel_button)
result = remove_mb.exec_()
# logging.info(result)
if result == 1:
if result == 3:
callback()

View File

@@ -24,9 +24,7 @@ INFO = f"[INFO]"
class TagWidget(QWidget):
edit_icon_128: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/edit_icon_128.png"
)
str(Path(__file__).parents[3] / "resources/qt/images/edit_icon_128.png")
).resize((math.floor(14 * 1.25), math.floor(14 * 1.25)))
edit_icon_128.load()
on_remove = Signal()

View File

@@ -10,6 +10,7 @@ import typing
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QPushButton
from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED
from src.core.library import Library, Tag
from src.qt.flowlayout import FlowLayout
from src.qt.widgets.fields import FieldWidget
@@ -141,7 +142,7 @@ class TagBoxWidget(FieldWidget):
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
self.edit_modal.show()
def add_tag_callback(self, tag_id):
def add_tag_callback(self, tag_id: int):
# self.base_layout.addWidget(TagWidget(self.lib, self.lib.get_tag(tag), True))
# self.tags.append(tag)
logging.info(
@@ -154,7 +155,7 @@ class TagBoxWidget(FieldWidget):
self.driver.lib, tag_id, field_id=id, field_index=-1
)
self.updated.emit()
if tag_id == 0 or tag_id == 1:
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
self.driver.update_badges()
# if type((x[0]) == ThumbButton):
@@ -180,7 +181,7 @@ class TagBoxWidget(FieldWidget):
self.driver.lib, tag_id, field_index=index[0]
)
self.updated.emit()
if tag_id == 0 or tag_id == 1:
if tag_id in (TAG_FAVORITE, TAG_ARCHIVED):
self.driver.update_badges()
# def show_add_button(self, value:bool):

View File

@@ -6,10 +6,11 @@
from PySide6 import QtCore
from PySide6.QtCore import QEvent
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
from PySide6.QtWidgets import QWidget, QPushButton
from PySide6.QtWidgets import QWidget
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
class ThumbButton(QPushButton):
class ThumbButton(QPushButtonWrapper):
def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
super().__init__(parent)
self.thumb_size: tuple[int, int] = thumb_size

View File

@@ -5,7 +5,6 @@
import logging
import math
import os
from pathlib import Path
import cv2
@@ -30,6 +29,7 @@ from src.core.constants import (
IMAGE_TYPES,
RAW_IMAGE_TYPES,
)
from src.core.utils.encoding import detect_char_encoding
ImageFile.LOAD_TRUNCATED_IMAGES = True
@@ -88,7 +88,7 @@ class ThumbRenderer(QObject):
def render(
self,
timestamp: float,
filepath,
filepath: str | Path,
base_size: tuple[int, int],
pixel_ratio: float,
is_loading=False,
@@ -99,7 +99,7 @@ class ThumbRenderer(QObject):
image: Image.Image = None
pixmap: QPixmap = None
final: Image.Image = None
extension: str = None
_filepath: Path = Path(filepath)
resampling_method = Image.Resampling.BILINEAR
if ThumbRenderer.font_pixel_ratio != pixel_ratio:
ThumbRenderer.font_pixel_ratio = pixel_ratio
@@ -118,14 +118,12 @@ class ThumbRenderer(QObject):
pixmap.setDevicePixelRatio(pixel_ratio)
if update_on_ratio_change:
self.updated_ratio.emit(1)
elif filepath:
extension = os.path.splitext(filepath)[1][1:].lower()
elif _filepath:
try:
# Images =======================================================
if extension in IMAGE_TYPES:
if _filepath.suffix.lower() in IMAGE_TYPES:
try:
image = Image.open(filepath)
image = Image.open(_filepath)
if image.mode != "RGB" and image.mode != "RGBA":
image = image.convert(mode="RGBA")
if image.mode == "RGBA":
@@ -136,12 +134,12 @@ class ThumbRenderer(QObject):
image = ImageOps.exif_transpose(image)
except DecompressionBombError as e:
logging.info(
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {filepath} (because of {e})"
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})"
)
elif extension in RAW_IMAGE_TYPES:
elif _filepath.suffix.lower() in RAW_IMAGE_TYPES:
try:
with rawpy.imread(filepath) as raw:
with rawpy.imread(str(_filepath)) as raw:
rgb = raw.postprocess()
image = Image.frombytes(
"RGB",
@@ -151,20 +149,23 @@ class ThumbRenderer(QObject):
)
except DecompressionBombError as e:
logging.info(
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {filepath} (because of {e})"
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})"
)
except rawpy._rawpy.LibRawIOError:
except (
rawpy._rawpy.LibRawIOError,
rawpy._rawpy.LibRawFileUnsupportedError,
) as e:
logging.info(
f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {filepath}"
f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {_filepath.name} ({type(e).__name__})"
)
# Videos =======================================================
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
elif _filepath.suffix.lower() in VIDEO_TYPES:
video = cv2.VideoCapture(str(_filepath))
frame_count = video.get(cv2.CAP_PROP_FRAME_COUNT)
if frame_count <= 0:
raise cv2.error("File is invalid or has 0 frames")
video.set(cv2.CAP_PROP_POS_FRAMES, frame_count // 2)
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
@@ -176,8 +177,9 @@ class ThumbRenderer(QObject):
image = Image.fromarray(frame)
# Plain Text ===================================================
elif extension in PLAINTEXT_TYPES:
with open(filepath, "r", encoding="utf-8") as text_file:
elif _filepath.suffix.lower() in PLAINTEXT_TYPES:
encoding = detect_char_encoding(_filepath)
with open(_filepath, "r", encoding=encoding) as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
draw = ImageDraw.Draw(bg)
@@ -191,7 +193,7 @@ class ThumbRenderer(QObject):
# axes = figure.add_subplot(projection='3d')
# # Load the STL files and add the vectors to the plot
# your_mesh = mesh.Mesh.from_file(filepath)
# your_mesh = mesh.Mesh.from_file(_filepath)
# poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors)
# poly_collection.set_color((0,0,1)) # play with color
@@ -230,7 +232,6 @@ class ThumbRenderer(QObject):
< max(base_size[0], base_size[1])
else Image.Resampling.BILINEAR
)
image = image.resize((new_x, new_y), resample=resampling_method)
if gradient:
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
@@ -268,19 +269,19 @@ class ThumbRenderer(QObject):
) as e:
if e is not UnicodeDecodeError:
logging.info(
f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {filepath} ({e})"
f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})"
)
if update_on_ratio_change:
self.updated_ratio.emit(1)
final = ThumbRenderer.thumb_broken_512.resize(
(adj_size, adj_size), resample=resampling_method
)
qim = ImageQt.ImageQt(final)
if image:
image.close()
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixel_ratio)
if pixmap:
self.updated.emit(
timestamp,
@@ -289,8 +290,10 @@ class ThumbRenderer(QObject):
math.ceil(adj_size / pixel_ratio),
math.ceil(final.size[1] / pixel_ratio),
),
extension,
_filepath.suffix.lower(),
)
else:
self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension)
self.updated.emit(
timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower()
)

View File

@@ -0,0 +1,350 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from pathlib import Path
import typing
from PySide6.QtCore import (
Qt,
QSize,
QTimer,
QVariantAnimation,
QUrl,
QObject,
QEvent,
QRectF,
)
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene
from PySide6.QtGui import (
QPen,
QColor,
QBrush,
QResizeEvent,
QWheelEvent,
QAction,
QRegion,
QBitmap,
)
from PySide6.QtSvgWidgets import QSvgWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from PIL import Image, ImageDraw
from src.core.enums import SettingItems
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class VideoPlayer(QGraphicsView):
"""A basic video player."""
video_preview = None
play_pause = None
mute_button = None
def __init__(self, driver: "QtDriver") -> None:
super().__init__()
self.driver = driver
self.resolution = QSize(1280, 720)
self.animation = QVariantAnimation(self)
self.animation.valueChanged.connect(
lambda value: self.setTintTransparency(value)
)
self.hover_fix_timer = QTimer()
self.hover_fix_timer.timeout.connect(lambda: self.checkIfStillHovered())
self.hover_fix_timer.setSingleShot(True)
self.content_visible = False
self.filepath = None
# Set up the video player.
self.installEventFilter(self)
self.setScene(QGraphicsScene(self))
self.player = QMediaPlayer(self)
self.player.mediaStatusChanged.connect(
lambda: self.checkMediaStatus(self.player.mediaStatus())
)
self.video_preview = VideoPreview()
self.player.setVideoOutput(self.video_preview)
self.video_preview.setAcceptHoverEvents(True)
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.RightButton)
self.video_preview.installEventFilter(self)
self.player.setAudioOutput(
QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)
)
self.player.audioOutput().setMuted(True)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scene().addItem(self.video_preview)
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
# Set up the video tint.
self.video_tint = self.scene().addRect(
0,
0,
self.video_preview.size().width(),
self.video_preview.size().height(),
QPen(QColor(0, 0, 0, 0)),
QBrush(QColor(0, 0, 0, 0)),
)
# Set up the buttons.
self.play_pause = QSvgWidget()
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.play_pause.setMouseTracking(True)
self.play_pause.installEventFilter(self)
self.scene().addWidget(self.play_pause)
self.play_pause.resize(72, 72)
self.play_pause.move(
int(self.width() / 2 - self.play_pause.size().width() / 2),
int(self.height() / 2 - self.play_pause.size().height() / 2),
)
self.play_pause.hide()
self.mute_button = QSvgWidget()
self.mute_button.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.mute_button.setMouseTracking(True)
self.mute_button.installEventFilter(self)
self.scene().addWidget(self.mute_button)
self.mute_button.resize(32, 32)
self.mute_button.move(
int(self.width() - self.mute_button.size().width() / 2),
int(self.height() - self.mute_button.size().height() / 2),
)
self.mute_button.hide()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper(filepath=self.filepath)
autoplay_action = QAction("Autoplay", self)
autoplay_action.setCheckable(True)
self.addAction(autoplay_action)
autoplay_action.setChecked(
self.driver.settings.value(SettingItems.AUTOPLAY, True, bool) # type: ignore
)
autoplay_action.triggered.connect(lambda: self.toggleAutoplay())
self.autoplay = autoplay_action
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction("Open file in explorer", self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)
self.addAction(open_explorer_action)
def close(self, *args, **kwargs) -> None:
self.player.stop()
super().close(*args, **kwargs)
def toggleAutoplay(self) -> None:
self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked())
self.driver.settings.sync()
def checkMediaStatus(self, media_status: QMediaPlayer.MediaStatus) -> None:
if media_status == QMediaPlayer.MediaStatus.EndOfMedia:
# Switches current video to with video at filepath.
# Reason for this is because Pyside6 can't handle setting a new source and freezes.
# Even if I stop the player before switching, it breaks.
# On the plus side, this adds infinite looping for the video preview.
self.player.stop()
self.player.setSource(QUrl().fromLocalFile(self.filepath))
self.player.setPosition(0)
if self.autoplay.isChecked():
self.player.play()
else:
self.player.pause()
self.opener.set_filepath(self.filepath)
self.keepControlsInPlace()
self.updateControls()
def updateControls(self) -> None:
if self.player.audioOutput().isMuted():
self.mute_button.load(self.driver.rm.volume_mute_icon)
else:
self.mute_button.load(self.driver.rm.volume_icon)
if self.player.isPlaying():
self.play_pause.load(self.driver.rm.pause_icon)
else:
self.play_pause.load(self.driver.rm.play_icon)
def wheelEvent(self, event: QWheelEvent) -> None:
return
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
# This chunk of code is for the video controls.
if (
obj == self.play_pause
and event.type() == QEvent.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton # type: ignore
):
if self.player.hasVideo():
self.pauseToggle()
if (
obj == self.mute_button
and event.type() == QEvent.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton # type: ignore
):
if self.player.hasAudio():
self.muteToggle()
if (
obj == self.video_preview
and event.type() == QEvent.Type.GraphicsSceneHoverEnter
or event.type() == QEvent.Type.HoverEnter
):
if self.video_preview.isUnderMouse():
self.underMouse()
self.hover_fix_timer.start(10)
elif (
obj == self.video_preview
and event.type() == QEvent.Type.GraphicsSceneHoverLeave
or event.type() == QEvent.Type.HoverLeave
):
if not self.video_preview.isUnderMouse():
self.hover_fix_timer.stop()
self.releaseMouse()
return super().eventFilter(obj, event)
def checkIfStillHovered(self) -> None:
# I don't know why, but the HoverLeave event is not triggered sometimes
# and does not hide the controls.
# So, this is a workaround. This is called by a QTimer every 10ms to check if the mouse
# is still in the video preview.
if not self.video_preview.isUnderMouse():
self.releaseMouse()
else:
self.hover_fix_timer.start(10)
def setTintTransparency(self, value) -> None:
self.video_tint.setBrush(QBrush(QColor(0, 0, 0, value)))
def underMouse(self) -> bool:
self.animation.setStartValue(self.video_tint.brush().color().alpha())
self.animation.setEndValue(100)
self.animation.setDuration(250)
self.animation.start()
self.play_pause.show()
self.mute_button.show()
self.keepControlsInPlace()
self.updateControls()
return super().underMouse()
def releaseMouse(self) -> None:
self.animation.setStartValue(self.video_tint.brush().color().alpha())
self.animation.setEndValue(0)
self.animation.setDuration(500)
self.animation.start()
self.play_pause.hide()
self.mute_button.hide()
return super().releaseMouse()
def resetControlsToDefault(self) -> None:
# Resets the video controls to their default state.
self.play_pause.load(self.driver.rm.pause_icon)
self.mute_button.load(self.driver.rm.volume_mute_icon)
def pauseToggle(self) -> None:
if self.player.isPlaying():
self.player.pause()
self.play_pause.load(self.driver.rm.play_icon)
else:
self.player.play()
self.play_pause.load(self.driver.rm.pause_icon)
def muteToggle(self) -> None:
if self.player.audioOutput().isMuted():
self.player.audioOutput().setMuted(False)
self.mute_button.load(self.driver.rm.volume_icon)
else:
self.player.audioOutput().setMuted(True)
self.mute_button.load(self.driver.rm.volume_mute_icon)
def play(self, filepath: str, resolution: QSize) -> None:
# Sets the filepath and sends the current player position to the very end,
# so that the new video can be played.
logging.info(f"Playing {filepath}")
self.resolution = resolution
self.filepath = filepath
if self.player.isPlaying():
self.player.setPosition(self.player.duration())
self.player.play()
else:
self.checkMediaStatus(QMediaPlayer.MediaStatus.EndOfMedia)
def stop(self) -> None:
self.filepath = None
self.player.stop()
def resizeVideo(self, new_size: QSize) -> None:
# Resizes the video preview to the new size.
self.video_preview.setSize(new_size)
self.video_tint.setRect(
0, 0, self.video_preview.size().width(), self.video_preview.size().height()
)
contents = self.contentsRect()
self.centerOn(self.video_preview)
self.roundCorners()
self.setSceneRect(0, 0, contents.width(), contents.height())
self.keepControlsInPlace()
def roundCorners(self) -> None:
width: int = int(max(self.contentsRect().size().width(), 0))
height: int = int(max(self.contentsRect().size().height(), 0))
mask = Image.new(
"RGBA",
(
width,
height,
),
(0, 0, 0, 255),
)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle(
(0, 0) + (width, height),
radius=12,
fill=(0, 0, 0, 0),
)
final_mask = mask.getchannel("A").toqpixmap()
self.setMask(QRegion(QBitmap(final_mask)))
def keepControlsInPlace(self) -> None:
# Keeps the video controls in the places they should be.
self.play_pause.move(
int(self.width() / 2 - self.play_pause.size().width() / 2),
int(self.height() / 2 - self.play_pause.size().height() / 2),
)
self.mute_button.move(
int(self.width() - self.mute_button.size().width() - 10),
int(self.height() - self.mute_button.size().height() - 10),
)
def resizeEvent(self, event: QResizeEvent) -> None:
# Keeps the video preview in the center of the screen.
self.centerOn(self.video_preview)
self.resizeVideo(
QSize(
int(self.video_preview.size().width()),
int(self.video_preview.size().height()),
)
)
return
class VideoPreview(QGraphicsVideoItem):
def boundingRect(self):
return QRectF(0, 0, self.size().width(), self.size().height())
def paint(self, painter, option, widget):
# painter.brush().setColor(QColor(0, 0, 0, 255))
# You can set any shape you want here.
# RoundedRect is the standard rectangle with rounded corners.
# With 2nd and 3rd parameter you can tweak the curve until you get what you expect
super().paint(painter, option, widget)

View File

@@ -0,0 +1,42 @@
import sys
import pathlib
import pytest
from syrupy.extensions.json import JSONSnapshotExtension
CWD = pathlib.Path(__file__).parent
sys.path.insert(0, str(CWD.parent))
from src.core.library import Tag, Library
@pytest.fixture
def test_tag():
yield Tag(
id=1,
name="Tag Name",
shorthand="TN",
aliases=["First A", "Second A"],
subtags_ids=[2, 3, 4],
color="",
)
@pytest.fixture
def test_library():
lib_dir = CWD / "fixtures" / "library"
lib = Library()
ret_code = lib.open_library(lib_dir)
assert ret_code == 1
# create files for the entries
for entry in lib.entries:
(lib_dir / entry.filename).touch()
yield lib
@pytest.fixture
def snapshot_json(snapshot):
return snapshot.with_defaults(extension_class=JSONSnapshotExtension)

View File

@@ -0,0 +1,6 @@
[
[
"<ItemType.ENTRY: 0>",
2
]
]

View File

@@ -0,0 +1,6 @@
[
[
"<ItemType.ENTRY: 0>",
1
]
]

View File

@@ -0,0 +1,4 @@
[
"{'id': 1, 'filename': 'foo.txt', 'path': '.', 'fields': [{6: [1001]}]}",
"{'id': 2, 'filename': 'bar.txt', 'path': '.', 'fields': [{6: [1000]}]}"
]

View File

@@ -0,0 +1,18 @@
import pytest
def test_open_library(test_library, snapshot_json):
assert test_library.entries == snapshot_json
@pytest.mark.parametrize(
["query"],
[
("First",),
("Second",),
("--nomatch--",),
],
)
def test_library_search(test_library, query, snapshot_json):
res = test_library.search_library(query)
assert res == snapshot_json

View File

@@ -1,18 +1,8 @@
from src.core.library import Tag
def test_subtag(test_tag):
test_tag.remove_subtag(2)
test_tag.remove_subtag(2)
def test_construction():
tag = Tag(
id=1,
name="Tag Name",
shorthand="TN",
aliases=["First A", "Second A"],
subtags_ids=[2, 3, 4],
color="",
)
assert tag
def test_empty_construction():
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
assert tag
test_tag.add_subtag(5)
# repeated add should not add the subtag
test_tag.add_subtag(5)
assert test_tag.subtag_ids == [3, 4, 5]

View File

@@ -0,0 +1,69 @@
{
"ts-version": "9.3.1",
"ext_list": [
".json",
".xmp",
".aae"
],
"is_exclude_list": true,
"tags": [
{
"id": 0,
"name": "Archived",
"aliases": [
"Archive"
],
"color": "Red"
},
{
"id": 1,
"name": "Favorite",
"aliases": [
"Favorited",
"Favorites"
],
"color": "Yellow"
},
{
"id": 1000,
"name": "first",
"shorthand": "first",
"color": "magenta"
},
{
"id": 1001,
"name": "second",
"shorthand": "second",
"color": "blue"
}
],
"collations": [],
"fields": [],
"macros": [],
"entries": [
{
"id": 1,
"filename": "foo.txt",
"path": ".",
"fields": [
{
"6": [
1001
]
}
]
},
{
"id": 2,
"filename": "bar.txt",
"path": ".",
"fields": [
{
"6": [
1000
]
}
]
}
]
}