Compare commits

...

371 Commits

Author SHA1 Message Date
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
Travis Abendshien
57e27bb51f Revert "Fix create library + type checks"
This reverts commit 6357fea8db.
2024-05-20 17:44:52 -07:00
Travis Abendshien
66ec0913b6 Revert "Add duplicate entry handling (Fix #179)"
This reverts commit 491ebb6714.
2024-05-20 17:43:25 -07:00
Travis Abendshien
6357fea8db Fix create library + type checks 2024-05-20 17:36:22 -07:00
Travis Abendshien
491ebb6714 Add duplicate entry handling (Fix #179)
- Running "Fix Unlinked Entries" will no longer result in duplicate entries if the directory was refreshed after the original entries became unlinked.
- A "Duplicate Entries" section is added to the "Fix Unlinked Entries" modal to help repair existing affected libraries.
2024-05-20 17:14:30 -07:00
Travis Abendshien
385b4117db Fix incorrect pillow-heif import 2024-05-18 19:03:57 -07:00
Travis Abendshien
be3992f655 Add HEIC/HEIF image support
- Add support for HEIC/HEIF image thumbnails and previews
- Replace dependency "pillow_avif_plugin" with "pi-heif"
- Remove unused dependencies in ts_cli.py
2024-05-18 18:58:01 -07:00
Travis Abendshien
18becd62a3 Add RAW image support (Resolve #193)
- Add thumbnail and preview support for RAW images ["raw", "dng", "rw2", "nef", "arw", "crw", "cr3"]
- Optimize the preview panel's dimension calculations (still need to move this elsewhere)
- Refactored use of "Path" in thumb_renderer.py
2024-05-18 18:49:35 -07:00
Travis Abendshien
699ecd367c Adaptive resampling method for images (Fix #174)
When loading an image for thumbnails and previews, the resampling method is now determined by the size of the original image. Now low resolution images use "nearest neighbor" sampling while higher resolution images continue to use "bilinear" sampling.
2024-05-18 17:57:28 -07:00
Travis Abendshien
9d7609a8e5 Load palletized images as RGBA (Fix #175) 2024-05-18 17:32:54 -07:00
Theasacraft
e94c4871d7 Refactor Thumbrenderer (#168)
* Merge Render methods

* Cleanup comments

* Removed old render methods and replaced with new one

* Fix Formatting

- Change all instances of "os.path.normpath" to pathlib's "Path"
- Remove unused import
- Modify log formatting
- Change "self.tr" to "self.thumb_renderer" to avoid masking internal method
- Restore DecompressionBombError handling from main
- Misc. formatting

* Fix MyPy no-redef

---------

Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
2024-05-18 16:56:45 -07:00
Travis Abendshien
02bf15e080 Merge pull request #142 from Hidorikun/test-support-2
Add pytest support
2024-05-17 21:13:41 -07:00
Travis Abendshien
5f60ec1702 Add missing imports to ts_core.py 2024-05-17 21:12:02 -07:00
Travis Abendshien
cdf2581f84 Merge pull request #192 from yedpodtrzitko/yed/better-mypy-pr
use reviewdog for mypy job
2024-05-17 21:02:02 -07:00
yedpodtrzitko
af8b4e3872 use reviewdog for mypy job 2024-05-18 11:21:40 +08:00
Travis Abendshien
ac9dd5879e Merge pull request #189 from michaelmegrath/main
fix: Clear Edit Button on container update (#115)
2024-05-17 14:14:59 -07:00
Michael Megrath
badcd72bea fix: Clear Edit Button on container update (#115) 2024-05-16 22:09:41 -07:00
Vele George
8733c8d301 Update constants.py 2024-05-16 10:37:34 +03:00
Vele George
4726f1fc63 Merge branch 'main' into test-support-2 2024-05-16 10:37:27 +03:00
Travis Abendshien
1461f2ee70 Merge pull request #186 from yedpodtrzitko/main
fix: update recent libs when creating new one
2024-05-15 22:39:42 -07:00
yedpodtrzitko
1bfc24b70f fix: update recent libs when creating new one 2024-05-16 13:28:29 +08:00
Jiri
c09f50c568 ci: add mypy check (#161)
* ci: add mypy check

* fix remaining mypy issues

* ignore whole methods
2024-05-15 22:25:53 -07:00
Travis Abendshien
66aecf2030 Merge pull request #180 from yedpodtrzitko/yed/fix-sidebar-size
fix sidebar expanding
2024-05-15 16:38:57 -07:00
yedpodtrzitko
dc188264f9 fix sidebar expanding 2024-05-16 07:25:21 +08:00
Travis Abendshien
6e56f13eda Bump version to v9.2.1 2024-05-15 15:30:33 -07:00
Vele George
c9ea25b940 Merge branch 'main' into test-support-2 2024-05-15 10:23:36 +03:00
Travis Abendshien
e814d09c60 Add macOS Gatekeeper note to README 2024-05-15 00:15:44 -07:00
Travis Abendshien
69115ed9bb Merge pull request #173 from xarvex/release-binary-2
Correct upload binaries used in release workflow
2024-05-14 22:46:35 -07:00
Xarvex
296aed6575 Correct upload binaries used in release workflow 2024-05-15 00:45:19 -05:00
Travis Abendshien
e655fd091d Update CHANGELOG.md 2024-05-14 22:27:15 -07:00
Travis Abendshien
8780063e22 Merge branch 'main' of https://github.com/TagStudioDev/TagStudio 2024-05-14 22:11:51 -07:00
Travis Abendshien
c6d2a89263 Update documentation
- Update README
- Update CHNAGELOG
- Update `--config-file` argument help message
2024-05-14 22:11:40 -07:00
Xarvex
ecea6effa4 Release workflow with binary executables (#172)
* Refactor: remove __init__ meant for Python versions before 3.3
This does mess with a large amount of imports, as the system was being
misused to re-export submodules. This change is necessary if PyInstaller
is to work at all.

* Add MacOS icon

* Create PyInstaller spec file

* Create Release workflow
Creates executable with PyInstaller, leveraging tag_studio.spec

* Support both nonportable and portable in tag_studio.spec

* Rename spec-file to create consistently-named directories

* Only ignore other spec files

* Swap exclusion option

* Use windowed application

* Ensure environment variables are strings

* Cleanup visual order on GitHub interface

* Use app for MacOS

* Only cycle through MacOS version

* All executables generated for MacOS are portable

* Use up-to-date packages

Should resolve caching issues

* Correct architecture naming for MacOS
2024-05-14 22:06:39 -07:00
Travis Abendshien
8e11e28561 Merge pull request #159 from Loran425/main
Change QSettings behavior to work with executables
2024-05-14 14:36:56 -07:00
Travis Abendshien
5d85417ce4 Merge pull request #151 from yedpodtrzitko/yed/libs-sidebar
add list of libraries into sidebar
2024-05-14 12:06:42 -07:00
Vele George
4b1119ecba Merge branch 'main' into test-support-2 2024-05-14 11:38:24 +03:00
yedpodtrzitko
5d21375e65 dont expand recent libs 2024-05-14 15:51:41 +08:00
yedpodtrzitko
f60a93f35b keep remove button small 2024-05-14 14:58:42 +08:00
yedpodtrzitko
a71ed7c426 add button for removing recent lib 2024-05-14 12:52:25 +08:00
yedpodtrzitko
06f528f8b5 CR feedback 2024-05-14 12:37:09 +08:00
yedpodtrzitko
94a0b00007 add list of libraries into sidebar 2024-05-14 12:37:08 +08:00
Andrew Arneson
eba7c3e178 Merge remote-tracking branch 'upstream/main' 2024-05-13 20:45:47 -06:00
Andrew Arneson
89b1921e56 Eliminate guess work on config file
Removes support for directory as a `--config-file` argument
2024-05-13 20:45:37 -06:00
Travis Abendshien
f35d9c1313 Merge pull request #165 from yedpodtrzitko/yed/ci-run
ci: try to run the app
2024-05-13 14:31:46 -07:00
Theasacraft
6a2199dd2e Fix pillow decompression bomb error mentioned in #164 (#166)
* Fixes DecompressionBombError

* Fixes DecompressionBombError in PreviewPanel

* Ruff reformat

* Handle all DecompressionBombErrors

* Handle all DecompressionBombErrors

* RUFF

* fix typo

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

* fix typo

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

* Ruff reformat

---------

Co-authored-by: Thesacraft <admin@samuelbellmann.de>
2024-05-13 14:18:07 -07:00
Xarvex
0416fde7f5 Refactor: remove __init__.py files meant for Python versions before 3.3 (#160)
* Refactor: remove __init__ meant for Python versions before 3.3
This does mess with a large amount of imports, as the system was being
misused to re-export submodules. This change is necessary if PyInstaller
is to work at all.

* Thanks Ruff

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

---------

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
2024-05-13 12:50:04 -07:00
Travis Abendshien
02d6b22b25 Splash screen now stays on top of other windows 2024-05-13 12:13:06 -07:00
Travis Abendshien
851d1fb3b2 Changes to allow for native menu bars 2024-05-13 01:10:40 -07:00
Travis Abendshien
4616da4e5f Added the splash screen to the QResources system 2024-05-12 23:52:44 -07:00
Travis Abendshien
d43b00bd00 Updated macOS Icon
- Also cleaned up surrounding commented-out code
- This should hopefully fix the dock icon trying to swap during runtime
2024-05-12 23:44:41 -07:00
Andrew Arneson
e7318c7473 Ruff Formatting 2024-05-12 23:09:28 -06:00
Andrew Arneson
74e90df680 Revert QSettings location to IniFormat UserScope defaults
Adds `-c/--config-file <filename/dir>` argument to tag_studio.py
2024-05-12 23:07:01 -06:00
Andrew Arneson
6566682504 Merge remote-tracking branch 'refs/remotes/upstream/main' 2024-05-12 22:44:50 -06:00
Travis Abendshien
b00dbf9548 Squashed commit of the following:
commit 094b6d50d975ec8feaa24af6a8a7b121fe87bce3
Author: Travis Abendshien <lvnvtravis@gmail.com>
Date:   Sun May 12 19:52:25 2024 -0700

    Formatted using Ruff

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

commit 088ef5263e38c948dda9a875fcc56af97762a73e
Author: Travis Abendshien <lvnvtravis@gmail.com>
Date:   Sun May 12 19:51:03 2024 -0700

    Reduced QResource Usage + Path Refactor

    - Removed unused or redundant QResources
    - Removed unreliable uses of the Qt resource system in favor of direct paths
    - Refactored paths with "parent.parent.parent" to use ".parents[index]"

    Co-Authored-By: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>
2024-05-12 21:15:46 -07:00
Travis Abendshien
f8d44c5fae Updated Application Icons
- Updated application icons
- Added .icns file for macOS
- (Potentially) stopped macOS dock icon from being updated with a different icon during runtime
2024-05-12 21:05:29 -07:00
Andrew Arneson
88b0f70271 Merge remote-tracking branch 'refs/remotes/upstream/main' 2024-05-12 21:29:13 -06:00
yedpodtrzitko
93526fa73f run tagstudio in CI 2024-05-13 08:46:55 +08:00
Travis Abendshien
f69f173368 Disable Native Menubars 2024-05-12 15:23:03 -07:00
Travis Abendshien
92752aef4a Reverted macOS menubar change
Reverted exception to allow the native macOS menubar in 94f929d122 because it doesn't work anyway.
2024-05-12 14:51:49 -07:00
Vele George
ad850cba94 Merge branch 'main' into test-support-2 2024-05-11 21:30:15 +03:00
Andrew Arneson
191d8f995f Merge remote-tracking branch 'refs/remotes/upstream/main' 2024-05-10 18:00:43 -06:00
Andrew Arneson
eede5b3600 Move the tagstudio.ini file to alongside the executable 2024-05-10 17:58:56 -06:00
Travis Abendshien
3aa71d6f8a Formatted with Ruff 2024-05-10 15:45:19 -07:00
Travis Abendshien
94f929d122 Allow the use of the native macOS menu bar 2024-05-10 15:43:26 -07:00
Travis Abendshien
03a46ae57b Style Changes
- Removed legacy style experiments, including strange translucent widgets and indigo default tags
- Renamed "Folders To Tags" to "Create Tags From Folders"
- Edited formatting of "Create Tags From Folders" modal
- Renamed "Tag Database" to "Manage Tags"/"Library Tags"
- Tweaked names of other menubar actions
- Removed some commented-out code
2024-05-10 15:29:28 -07:00
yed podtrzitko
b8d72a65c8 misc: remove duplicate code for tags updating (#135) 2024-05-09 17:39:46 -07:00
William de Castro
8321f43d6e Add Build Script for mac os systems (#76)
* chore: add TagStudio.spec to gitignore

Prevent TagStudio.spec to be added to repo in the future

* chore: add Build Script for macos

Create script using pyinstaller to generate a macos app for tagstudio

* chore: revert duplicated files

* chore: rename build file naming
2024-05-09 01:22:51 -07:00
Sylvia K
f9ea20e29c Refactor: Deduplication in pagination.py's "Set Elipses Sizes" section (#141) 2024-05-08 12:07:58 -07:00
Travis Abendshien
b6ccb88a95 Fixed Some Images Breaking Thumbnailer
Fixed images that were considered to be "truncated" from breaking the thumbnail renderer when trying to transpose via an EXIF flag. This was happening with certain panorama photos.
2024-05-08 11:39:03 -07:00
Travis Abendshien
de434872d6 Merge pull request #145 from yedpodtrzitko/yed/hold-the-jobs
dont run job threads needlessly
2024-05-08 10:36:58 -07:00
Travis Abendshien
7acfecf7b9 Merge pull request #147 from arthniwa/add-help-menu-action
Add action to help menu
2024-05-08 10:15:46 -07:00
arthniwa
7f776f4c86 Fix format required by Ruff 2024-05-08 10:57:48 -05:00
yedpodtrzitko
e803f6adcb wait for threads to finish 2024-05-08 22:49:05 +08:00
Hidorikun
b6848bb81f Ruff format 2024-05-08 17:32:15 +03:00
Travis Abendshien
7ef2aa6a80 Merge pull request #78 from Thesacraft/main
Build Script for windows
2024-05-07 23:15:32 -07:00
arthniwa
48ad4aaad2 Add action to help menu
Add "Go to GitHub Repository" to the help menu.
2024-05-08 00:42:16 -05:00
yedpodtrzitko
0e621011fc dont run job threads needlessly 2024-05-08 11:18:01 +08:00
Hidorikun
fb7c73d96b Add pytest support 2024-05-06 12:06:30 +03:00
Travis Abendshien
57a15f651e Merge pull request #140 from yedpodtrzitko/yed/fix-edit-enter
fix input enterPressed event
2024-05-04 23:36:40 -07:00
yedpodtrzitko
3aa4fa214f fix input enterPressed event 2024-05-05 14:22:37 +08:00
Travis Abendshien
ec55e92599 Merge pull request #136 from SylviaSK/main
Fix for #119, Prompt for new location user if library cannot be saved
2024-05-04 18:28:23 -07:00
Travis Abendshien
c27f99e3a4 Merge pull request #117 from abby-freakazoid/improve-startup-script 2024-05-04 15:53:36 -07:00
Sylvia Krech
78ea0b3641 close_library() fix 2024-05-04 16:45:38 -05:00
Sylvia Krech
523f233f4a Ruff formatting 2024-05-04 15:16:31 -05:00
Sylvia Krech
ea05907227 Fix for #119, Prompt for new location user if library cannot be saved 2024-05-04 12:20:43 -05:00
Travis Abendshien
089c8dd50c Reformatted using Ruff 2024-05-03 19:40:36 -07:00
Travis Abendshien
77d7559014 Merge pull request #111 from yedpodtrzitko/yed/sidebar-active-cursor
feat: add hand cursor for active sidebar elements
2024-05-03 19:15:37 -07:00
Travis Abendshien
164aba1bb0 Merge pull request #113 from TechCrafterGaming/PR-003
minor bug fixes to Tag Search Box
2024-05-03 19:08:34 -07:00
Travis Abendshien
fd622ea378 Merge pull request #114 from Loran425/refactor/tag_database
Remove functionally duplicated code in update_tags
2024-05-03 18:22:42 -07:00
Travis Abendshien
b2d87b05d8 Merge pull request #123 from SylviaSK/main
Fix opening to a file with spaces in path on windows / #120
2024-05-03 18:13:07 -07:00
Travis Abendshien
ac3d7c95cb Merge pull request #126 from JinguBangWest/main
Refactor strip_web_protocol()
2024-05-03 17:59:32 -07:00
Travis Abendshien
64514db457 Merge pull request #107 from yedpodtrzitko/yed/ruff-formatting
add code formatting & Github Action via ruff
2024-05-03 17:52:10 -07:00
JinguBangWest
58c773a0de change to string.removeprefix(prefix) 2024-05-03 14:37:32 -04:00
JinguBangWest
bb1161baa9 Refactor strip_web_protocol
This allows for more prefixes to be added in the future if needed without repeating code multiple times.
2024-05-02 22:05:57 -04:00
Sylvia Krech
cd719c6d01 minor comment fix 2024-05-02 11:54:10 -05:00
Sylvia Krech
9a405dd336 Fix for #120, Windows filepaths with spaces 2024-05-02 11:47:32 -05:00
yedpodtrzitko
afd6e113d2 add ruff pre-commit hook and README info 2024-05-02 14:19:58 +08:00
Travis Abendshien
ea8d954548 Merge pull request #122 from abby-freakazoid/bugfix/validate-library-path-before-open/#118
Fix library reopen doesn't check if path is valid #118
2024-05-01 23:00:06 -07:00
Travis Abendshien
a1fcd23d13 Merge pull request #121 from gabrieljreed/bugfix/right-click-file-path/#116
Bugfix/right click file path/#116
2024-05-01 22:34:47 -07:00
Abby
99a0bfe919 Fix library reopen doesn't check if path is valid #118 2024-05-02 00:25:21 -05:00
gabrieljreed
eb5124bd0a Update docstrings 2024-05-01 22:01:08 -07:00
gabrieljreed
0e1e9e924b Merge remote-tracking branch 'upstream/main' into bugfix/right-click-file-path/#116 2024-05-01 21:56:29 -07:00
gabrieljreed
85b4be8525 Add docstrings 2024-05-01 21:45:43 -07:00
gabrieljreed
08c0704926 Fix open_file for platforms other than Windows 2024-05-01 21:42:58 -07:00
gabrieljreed
81f550a543 Fix open_explorer on Mac, fix right click on FileOpenerLabel 2024-05-01 21:41:11 -07:00
Abby
aedc5afefa Improved TagStudio.sh
"set -e" to abort on error
"cd …" to prevent creating .venv if script is launched outside git dir
"[ … ]" to skip creating venv when it already exists
2024-05-01 22:37:50 -05:00
Andrew.Arneson
9951c00a45 Remove functionally duplicated code in update_tags 2024-05-01 13:04:17 -06:00
TechCrafterGaming
b834166d61 minor bug fixes to Tag Search Box 2024-05-01 15:27:12 +02:00
yedpodtrzitko
61f9a49782 feat: add hand cursor for active sidebar elements 2024-05-01 18:46:55 +08:00
Travis Abendshien
f71b947673 Merge pull request #110 from yedpodtrzitko/yed/fix-thumbs-orientation
fix image thumbnails rotation
2024-05-01 03:00:46 -07:00
Travis Abendshien
3d5ed3a948 Added EXIF transpose to small thumbnails 2024-05-01 02:58:43 -07:00
yedpodtrzitko
8604a7f08f fix image thumbnails rotation 2024-05-01 17:15:33 +08:00
Xarvex
67c18e9a25 Merge pull request #109 from yedpodtrzitko/yed/macos-file-open
fix file opening on posix
2024-05-01 03:51:23 -05:00
Xarvex
81e0e7b072 Accidental double sys import during merge conflict 2024-05-01 03:50:27 -05:00
Xarvex
ba5a995b5d Merge branch 'yed/macos-file-open' of github.com:yedpodtrzitko/TagStudio into yed/macos-file-open 2024-05-01 03:43:53 -05:00
Xarvex
c58590a221 Make use of open_file from #84
Refactor done in #80 essentially reverted changes
Re-fixes #81, also re-fixes windows opening behind TagStudio
2024-05-01 03:30:35 -05:00
yedpodtrzitko
f40be005f8 fix file opening on posix 2024-05-01 16:21:40 +08:00
yedpodtrzitko
f9fc28d5ec fix file opening on posix 2024-05-01 15:46:08 +08:00
yedpodtrzitko
1030328420 add ruff formatting info and CI pipeline 2024-05-01 15:19:20 +08:00
Travis Abendshien
1a7316d54c Merge pull request #103 from yedpodtrzitko/yed/show-tags-implicitly
show all tags in Add Tags panel by default
2024-04-30 21:55:46 -07:00
Travis Abendshien
92e7e05540 Merge pull request #99 from TechCrafterGaming/FIX-001
added action to close a library
2024-04-30 21:07:06 -07:00
yedpodtrzitko
d478116094 show all tags in tag panel by default 2024-05-01 00:04:37 +08:00
Travis Abendshien
a10478bc8b Misc Bug Fixes
- Catches additional exception in the sorting process of new files added to the library
- Skips this sorting process if there are more than 100,000 files (this is a temporary sorting measure, anyway)
- Possibly improved performance during the functionless (and cursed) "Applying Configured Macros" step of loading new files
2024-04-30 02:41:07 -07:00
TechCrafterGaming
a669fd67c3 small adjustments and comments 2024-04-30 08:07:10 +02:00
TechCrafterGaming
7cdb26e822 added if statement 2024-04-30 08:04:13 +02:00
TechCrafterGaming
16a9d32ef3 added last_library value to be set properly 2024-04-30 08:02:46 +02:00
Travis Abendshien
9b90bfad84 Merge pull request #98 from Creepler13/Folders-to-tags-cleanup
Folders to tags refractor + Bug fix + Enhancement
2024-04-29 22:43:15 -07:00
Travis Abendshien
6f900ff371 Merge pull request #101 from TechCrafterGaming/FIX-002 2024-04-29 16:54:07 -07:00
TechCrafterGaming
d02f068991 small changes to README 2024-04-30 00:13:24 +02:00
TechCrafterGaming
878a5dfc8a small changes to ts_qt.py (doc strings) 2024-04-30 00:12:39 +02:00
TechCrafterGaming
030c817323 added action to close a library 2024-04-29 23:57:35 +02:00
Travis Abendshien
9ad81c4582 Merge pull request #94 from Thesacraft/Shutdown-threads-safely
Shuts the threads down before quitting the application Fixes #86
2024-04-29 14:42:22 -07:00
Creepler13
b1c0ec5817 Open/Close all buttons 2024-04-29 23:01:15 +02:00
Creepler13
ea904da58b Folders to tags Cleanup + bugfix 2024-04-29 21:29:43 +02:00
Travis Abendshien
0c9dd7f43b Merge pull request #95 from Loran425/patch/library-loading
Patch Bug that prevents loading ts_library.json fields ids as integers
2024-04-29 12:01:04 -07:00
Andrew.Arneson
885b2b0358 Patch Bug that prevents loading ts_library.json fields ids as integers 2024-04-29 12:55:30 -06:00
Theasacraft
7139eea4c4 Ends threads before quitting application
This fixes this warning:
QThread: Destroyed while thread is still running
2024-04-29 19:37:36 +02:00
Theasacraft
41e419c1e7 Ends threads before quitting application
This fixes this warning:
QThread: Destroyed while thread is still running
2024-04-29 18:46:35 +02:00
Travis Abendshien
93177dd696 Merge pull request #89 from yedpodtrzitko/main
fix: strip leading slash in library items
2024-04-29 01:05:36 -07:00
yedpodtrzitko
d8a11a796d fix: strip leading slash in library items 2024-04-29 12:53:41 +08:00
Travis Abendshien
c9c399faa9 Merge pull request #80 from Loran425/qt-refactor
Qt refactor
2024-04-28 21:22:20 -07:00
Theasacraft
44106e2c59 Updates Build_win.bat to not delete source and be able to build portable version 2024-04-29 00:13:48 +02:00
Theasacraft
260583cfeb Update Build_win.bat for faster start of executable
I changed it to output an executable and a _internals dir because this makes it, atleast from my experience, much faster (12s -> 1-2s). This is probably because the decompression that has to happen before startup if its in one file
2024-04-28 21:52:16 +02:00
Andrew Arneson
eb2a175b75 Changes from #84 split into src.qt.helpers.open_file from ts_qt.py 2024-04-28 13:37:28 -06:00
Andrew Arneson
89b159cfa1 Merge remote-tracking branch 'upstream/main' into qt-refactor
# Conflicts:
#	tagstudio/src/qt/ts_qt.py
2024-04-28 13:36:25 -06:00
Travis Abendshien
fb300d0d07 Merge pull request #84 from xarvex/main
Allow opening and selecting files in file managers for all platforms
2024-04-28 12:26:33 -07:00
Xarvex
3446e0601a Incorrect usage of list append
Co-authored-by: William de Castro <williamtdcastro@gmail.com>
2024-04-28 14:07:23 -05:00
Andrew Arneson
22e0ef8f4b Cleanup Imports 2024-04-28 12:57:21 -06:00
Andrew Arneson
1351002378 Breakout ItemThumb and PreviewPanel from ts_qt.py 2024-04-28 12:55:37 -06:00
Andrew Arneson
6e8baced7d Revert "Cannot split out ItemThumb or PreviewPanel due to close ties with driver"
This reverts commit 6b9b813e26.
2024-04-28 12:18:31 -06:00
Andrew Arneson
310fc0d958 Re-enable typechecking and autocompletion for modals that rely on QtDriver in ts_qt.py 2024-04-28 12:17:16 -06:00
Xarvex
737be6199f DETACHED_PROCESS is no longer necessary
Causes program window to open below TagStudio
2024-04-28 12:34:25 -05:00
Andrew Arneson
ca9735ca86 Split out remaining modals from ts_qt.py 2024-04-28 11:09:06 -06:00
Xarvex
4a9c4de56a Further isolate program window from TagStudio 2024-04-28 11:31:18 -05:00
Andrew Arneson
f7c4e1ccc0 Revert "Cannot split out unlinked file functions due to close ties with driver"
This reverts commit c848c57cb4.
2024-04-28 10:28:05 -06:00
Andrew Arneson
f6b131c412 Revert "Cannot split out duped file functions due to close ties with driver"
This reverts commit abd36e55b3.
2024-04-28 10:28:05 -06:00
Andrew Arneson
a695e222f4 Revert "Cannot split out folder_to_tag file functions due to close ties with driver"
This reverts commit 237bcd90b0.
2024-04-28 10:28:04 -06:00
Andrew Arneson
0d6746abe0 Revert "Cannot split out mirror entities file functions due to close ties with driver"
This reverts commit a62dbc3f2d.
2024-04-28 10:28:04 -06:00
Xarvex
2d558efde7 Allow opening and selecting files in file managers for all platforms
Fixes: #81
Refactored file opener helper to use already present open_file
Refactored open_file to allow option for file manager
2024-04-28 04:14:07 -05:00
Andrew Arneson
82a53481f7 Missing Newlines 2024-04-27 21:40:53 -06:00
Andrew Arneson
d26b308ae2 split TextWidget from ts_qt.py 2024-04-27 21:25:01 -06:00
Andrew Arneson
5431c1996a Copied (with git history):
ts_qt.py

 — into: —

./widgets/text_edit.py
2024-04-27 21:21:02 -06:00
Andrew Arneson
731b6d1568 * (merge)
Copied (with git history):

ts_qt.py

 — into: —

./widgets/text_edit.py
2024-04-27 21:21:02 -06:00
Andrew Arneson
68b075a6c8 * (keep ts_qt.py)
Copied (with git history):

ts_qt.py

 — into: —

./widgets/text_edit.py
2024-04-27 21:21:02 -06:00
Andrew Arneson
99cc8dc991 * (create ./widgets/text_edit.py)
Copied (with git history):

ts_qt.py

 — into: —

./widgets/text_edit.py
2024-04-27 21:21:02 -06:00
Andrew Arneson
cdba60c7d3 Cleanup Imports 2024-04-27 21:00:38 -06:00
Theasacraft
0495824878 Update requirements.txt to use last tested pyinstaller version
Thanks to @williamtcastro for pointing it out
2024-04-28 04:54:02 +02:00
Andrew Arneson
6b9b813e26 Cannot split out ItemThumb or PreviewPanel due to close ties with driver
Might be able to resolve with more time and a separate PR
2024-04-27 20:52:51 -06:00
Andrew Arneson
ad85266f98 Error File Creation 2024-04-27 20:46:16 -06:00
Andrew Arneson
a62dbc3f2d Cannot split out mirror entities file functions due to close ties with driver
Might be able to resolve with more time and a separate PR
2024-04-27 20:45:04 -06:00
Andrew Arneson
237bcd90b0 Cannot split out folder_to_tag file functions due to close ties with driver
Might be able to resolve with more time and a separate PR
2024-04-27 20:44:46 -06:00
Andrew Arneson
abd36e55b3 Cannot split out duped file functions due to close ties with driver
Might be able to resolve with more time and a separate PR
2024-04-27 20:43:35 -06:00
Andrew Arneson
7b0064d14b split FileExtensionModal from ts_qt.py 2024-04-27 20:42:24 -06:00
Andrew Arneson
c848c57cb4 Cannot split out unlinked file functions due to close ties with driver
Might be able to resolve with more time and a separate PR
2024-04-27 20:36:21 -06:00
Andrew Arneson
ebf2c0bb99 split AddFieldModal from ts_qt.py 2024-04-27 20:26:26 -06:00
Andrew Arneson
53fefb7497 split TagBoxWidget from ts_qt.py 2024-04-27 20:22:02 -06:00
Andrew Arneson
a9afb8e2cf split TagBoxWidget from ts_qt.py 2024-04-27 20:15:14 -06:00
Andrew Arneson
f0019c7086 split BuildTagPanel from ts_qt.py 2024-04-27 20:12:40 -06:00
Andrew Arneson
c8439c976f split TagSearchPanel from ts_qt.py 2024-04-27 20:10:01 -06:00
Andrew Arneson
4a3b0c103f split TagWidget from ts_qt.py 2024-04-27 19:58:35 -06:00
Andrew Arneson
6168434f3d split ProgressWidget from ts_qt.py 2024-04-27 19:52:02 -06:00
Andrew Arneson
9b7b384183 split EditTextLine from ts_qt.py 2024-04-27 19:43:53 -06:00
Andrew Arneson
5f6752e067 split EditTextBox from ts_qt.py 2024-04-27 19:40:55 -06:00
Andrew Arneson
e71ef7f671 split PanelWidget and PanelModal from ts_qt.py 2024-04-27 19:35:52 -06:00
Andrew Arneson
a1fe57a352 split ThumbRenderer from ts_qt.py 2024-04-27 19:22:42 -06:00
Andrew Arneson
0364d3a95c split ThumbButton from ts_qt.py 2024-04-27 19:15:44 -06:00
Andrew Arneson
63f8268fd4 split collage renderer from ts_qt.py 2024-04-27 19:05:47 -06:00
Andrew Arneson
e29b2838c6 split FieldContainer and FieldWidget from ts_qt.py 2024-04-27 19:05:46 -06:00
Andrew Arneson
7caba3b825 add missing empty line at end of file 2024-04-27 18:45:59 -06:00
Andrew Arneson
74c4c6c85d split FileOpenerHelper/Label from ts_qt.py 2024-04-27 18:40:07 -06:00
Andrew Arneson
abc9f3f4e4 split open_file func from ts_qt.py 2024-04-27 18:33:29 -06:00
Andrew Arneson
f26fd5a63c Copied (with git history):
./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:37 -06:00
Andrew Arneson
bb6a73e410 * (merge)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:36 -06:00
Andrew Arneson
54b70676f3 * (keep ./ts_qt.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:36 -06:00
Andrew Arneson
91c918796d * (create ./modals/folders_to_tags.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
fb05d386c1 * (create ./widgets/thumb_renderer.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
4665fb22ff * (create ./widgets/collage_icon.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
b25c1efc9d * (create ./widgets/thumb_button.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
f160071b27 * (create ./widgets/item_thumb.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
73ba643132 * (create ./widgets/preview_panel.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
effdd28b80 * (create ./helpers/file_opener.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
875bc34519 * (create ./modals/file_extension.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
77bbbe95b3 * (create ./modals/add_field.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
5f31fe4623 * (create ./modals/relink_unlinked.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
2f61ac706f * (create ./modals/delete_unlinked.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
822aa4df39 * (create ./modals/fix_unlinked.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
d67f8b6f0e * (create ./modals/mirror_entities.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
60d1d290a2 * (create ./modals/fix_dupes.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:35 -06:00
Andrew Arneson
65a2fe676d * (create ./widgets/progress.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
30998a144a * (create ./modals/tag_database.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
9c44d74cb6 * (create ./modals/build_tag.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
4de7865aba * (create ./modals/tag_search.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
8fed3350f4 * (create ./modals/edit_text.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
02542bda7e * (create ./modals/__init__.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
36841da965 * (create ./widgets/panel.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
58b0e94d65 * (create ./widgets/tag.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
4b7b915c68 * (create ./widgets/text_box_edit.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
6091916b86 * (create ./widgets/text_line_edit.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
273843f9c1 * (create ./widgets/tag_box.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
da1c3f8fa8 * (create ./widgets/fields.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Andrew Arneson
aec0786e61 * (create ./helpers/open_file.py)
Copied (with git history):

./ts_qt.py

 — into: —

./helpers/open_file.py ./widgets/fields.py ./widgets/tag_box.py ./widgets/text_line_edit.py ./widgets/text_box_edit.py ./widgets/tag.py ./widgets/panel.py ./modals/__init__.py ./modals/edit_text.py ./modals/tag_search.py ./modals/build_tag.py ./modals/tag_database.py ./widgets/progress.py ./modals/fix_dupes.py ./modals/mirror_entities.py ./modals/fix_unlinked.py ./modals/delete_unlinked.py ./modals/relink_unlinked.py ./modals/add_field.py ./modals/file_extension.py ./helpers/file_opener.py ./widgets/preview_panel.py ./widgets/item_thumb.py ./widgets/thumb_button.py ./widgets/collage_icon.py ./widgets/thumb_renderer.py ./modals/folders_to_tags.py
2024-04-27 18:20:34 -06:00
Theasacraft
9959eb1049 Update .gitignore to revert doubled entrys 2024-04-28 02:00:17 +02:00
Theasacraft
4f00a8ac88 Update .gitignore to ignore build specific files and folders 2024-04-28 01:56:13 +02:00
Theasacraft
329c23e23c Update requirements.txt to include pyinstaller
Pyinstaller is used to build the windows executable
2024-04-28 01:54:17 +02:00
Theasacraft
f53a9249f0 Build script for windows
Adds a build script for windows
2024-04-28 01:51:09 +02:00
Travis Abendshien
6097570591 Merge branch 'text-thumbnails' 2024-04-27 16:26:11 -07:00
Travis Abendshien
8541fc59d1 Increased plaintext types; Exception handling 2024-04-27 16:25:49 -07:00
Travis Abendshien
c955fb1589 Code Style Changes 2024-04-27 15:59:56 -07:00
Travis Abendshien
276cfaf635 Merge pull request #40 from chao-master/feature/typed-dict-and-typing
🏷️ Add strict typing dicts for ts_library.json
2024-04-27 15:53:58 -07:00
Travis Abendshien
6bba7dafbc Merge branch 'main' into feature/typed-dict-and-typing 2024-04-27 15:34:08 -07:00
Travis Abendshien
2110592971 Merge pull request #72 from yedpodtrzitko/yed/readability
improve code readability
2024-04-27 15:27:58 -07:00
Travis Abendshien
15de97cfe7 Merge pull request #58 from Creepler13/Folders-to-Tags
Add folders to tags tool
2024-04-27 14:58:17 -07:00
Travis Abendshien
114c1a4481 Merge pull request #69 from cirillom/image-preview
Ability to open file or file location using context menu
2024-04-27 13:33:59 -07:00
Creepler13
1f9a13fc4d Removed unused imports 2024-04-27 21:14:37 +02:00
Creepler13
e2aba7ddf7 Added Proper Visualls to show what would be added 2024-04-27 21:13:57 +02:00
yedpodtrzitko
f0f8e0ea7e simplify bool conditions 2024-04-28 01:47:16 +08:00
yedpodtrzitko
d5d9cd3e7a improve code readability 2024-04-28 01:17:41 +08:00
Matheus Cirillo
ade1fb1c11 Merge remote-tracking branch 'tags/main' into image-preview 2024-04-27 09:54:16 -03:00
Matheus Cirillo
955d4a0c9f fixed bug where it wouldn't open outside debug mode 2024-04-27 09:44:04 -03:00
Travis Abendshien
31f4022895 Added File Extension Blacklist
- All file types (minus JSON, XMP, and AAE) are now shown by default in the library (existing libraries will need to refresh)
- Added the Edit -> "Ignore File Extensions" option, providing the user with a way to blacklist certain file extensions from their library
- The targeted version number has been updated to 9.2.0 (this is not the final 9.2.0 release, commits will still be added before that release is packaged up)
2024-04-27 02:00:18 -07:00
Travis Abendshien
5b4d35b5c0 Added default thumbnail for files 2024-04-26 22:13:10 -07:00
Matheus Cirillo
6831a89392 file opening preview panel context menu 2024-04-26 23:19:24 -03:00
Matheus Cirillo
5bd2aaaf9e context menu for thumbs 2024-04-26 22:04:40 -03:00
Travis Abendshien
c789d09b07 Plaintext Thumbs (Proof of Concept)
**BASIC** support for rendering thumbnails for certain plaintext types (txt, md, html, etc.) using PIL.
Notable issues include:
- Long draw times (entire files are read)
- No text wrapping
- Hardcoded style
- Blurry text in preview pane images
- No cached thumbnails (I call dibs on the thumbnail caching system)
2024-04-26 17:33:02 -07:00
Matheus Cirillo
a9cbab40ab works as expected (open file in explorer) in windows 2024-04-26 20:40:43 -03:00
Matheus Cirillo
bcf4453c8d clickable label to the right place 2024-04-26 20:17:54 -03:00
Matheus Cirillo
18dcedd6a0 code cleanup 2024-04-26 20:13:58 -03:00
Matheus Cirillo
79e0263e97 file_label opens file path 2024-04-26 20:09:43 -03:00
Creepler13
960b2038ef Merge branch 'Folders-to-Tags' of https://github.com/Creepler13/TagStudio into Folders-to-Tags 2024-04-26 23:52:20 +02:00
Creepler13
88292f42af Merge branch 'Folders-to-Tags' of https://github.com/Creepler13/TagStudio into Folders-to-Tags 2024-04-26 23:37:47 +02:00
Creepler13
3cd6fa136f Finished folders to Tags tool 2024-04-26 23:36:50 +02:00
Creepler13
0541c9fb01 first prototype 2024-04-26 23:36:50 +02:00
Travis Abendshien
039c574cf4 Merge pull request #60 from Thesacraft/main
Fixes issue with badges not getting updated if meta tags gets removed
2024-04-26 13:36:57 -07:00
Theasacraft
042ed9209f Merge branch 'CyanVoxel:main' into main 2024-04-26 22:33:14 +02:00
Travis Abendshien
992a840dfe Merge pull request #63 from DrRetro2033/Shortcuts
Keyboard Shortcuts
2024-04-26 13:32:55 -07:00
Theasacraft
1e17b6c467 Merge branch 'CyanVoxel:main' into main 2024-04-26 22:29:04 +02:00
Travis Abendshien
a0b017815e Merge pull request #61 from DrRetro2033/main
A simple Tag Database.
2024-04-26 13:23:32 -07:00
Travis Abendshien
80d6e09834 Merge pull request #64 from xarvex/file-open-2
Windows: fix files w/ spaces opening cmd rather than associated program
2024-04-26 13:10:39 -07:00
Xarvex
66fec73136 Correct usage of start command 2024-04-26 15:05:07 -05:00
Xarvex
1f7a5d3cbb Windows: fix files w/ spaces opening cmd rather than associated program 2024-04-26 14:46:13 -05:00
DrRetro
dde7eec946 Keyboard Shortcuts Added to basic Functions 2024-04-26 12:01:02 -04:00
DrRetro
85ae167817 Fixed Issue with Tags in Database not refreshing after Tag was Edited. 2024-04-26 11:01:09 -04:00
DrRetro
5feb3a6d20 Fixed Issue with Previous Commit 2024-04-26 10:51:49 -04:00
DrRetro
f23ff1669e Fixed Issue when Searching Tags, Editing Option was not Connected to Searched TagWidets 2024-04-26 10:50:19 -04:00
DrRetro
6b1035b0f6 A simple TagDatabasePanel has been created based off TagSearchPanel.
To access, go to Edit > Tag Database
2024-04-26 10:07:59 -04:00
Theasacraft
243d786298 Update ts_qt.py to update badges if meta tag field is removed 2024-04-26 15:41:37 +02:00
Theasacraft
898ce5fdc7 Update ts_qt.py to update badges if meta tag field is removed 2024-04-26 15:34:06 +02:00
Creepler13
749d7c8fc0 Finished folders to Tags tool 2024-04-26 14:59:41 +02:00
Creepler13
1774a00d34 first prototype 2024-04-26 14:59:40 +02:00
Creepler13
9020aaddf4 Finished folders to Tags tool 2024-04-26 14:58:21 +02:00
Travis Abendshien
00651e6242 Fixed Incorrect Fields Being Updated in Multi-Selection
Fixes #55

Co-Authored-By: Andrew Arneson <Loran425@gmail.com>
Co-Authored-By: Xarvex <60973030+xarvex@users.noreply.github.com>
2024-04-26 01:45:03 -07:00
Travis Abendshien
b7638046a3 Addded TagStudio.ini to gitignore; Updated Qt resource comment 2024-04-25 22:22:28 -07:00
Travis Abendshien
c446911b59 Merge pull request #34 from DrRetro2033/main
Mutli-Select Tagging
2024-04-25 22:18:00 -07:00
DrRetro
201a63e273 Refresh_badges added to QtDriver, and favorite and archived badges checks selection. 2024-04-25 20:51:12 -04:00
Creepler13
d8faa27dbf first prototype 2024-04-26 00:11:58 +02:00
Travis Abendshien
ff488da6c8 Merge pull request #51 from Loran425/main
QOL Open last opened library
2024-04-25 14:22:12 -07:00
Andrew Arneson
2514809e17 remove doubled up line 2024-04-25 14:20:46 -06:00
Andrew Arneson
82bb63191a Tweak setting location to ensure compatibility between IDEs
Explicitly call sync to save `.ini` file
2024-04-25 14:12:40 -06:00
Andrew Arneson
9a076a775f Merge remote-tracking branch 'upstream/main' 2024-04-25 12:23:55 -06:00
Andrew Arneson
5c746a9950 add Qsettings and restore last library on open
Settings are currently stored as an INI file within the project directory rather than messing with registry or system configurations.
2024-04-25 12:23:13 -06:00
Travis Abendshien
00dea27279 Merge pull request #46 from eltociear/patch-1
docs: update documentation.md
2024-04-25 10:39:42 -07:00
Ikko Eltociear Ashimine
a0dc34a5ad docs: update documentation.md
minor fix
2024-04-26 00:46:32 +09:00
DrRetro
2a46251831 Fixed slow down from refreshing all thumbnails for every added and removed tag. 2024-04-25 09:57:37 -04:00
DrRetro
92834800e2 Merge remote-tracking branch 'upstream/main' 2024-04-25 09:46:22 -04:00
DrRetro
794401ae58 Archive Button and Favorite Button Now work 2024-04-25 09:40:12 -04:00
DrRetro
b2fbc4b4a2 Large Changes to how code gets Field ID. 2024-04-25 09:34:14 -04:00
Travis Abendshien
13c2ca1ea5 Allows shortcut files to show in library
Shortcut files (.lnk, .url, .desktop) can now be added to the library (no thumbnail previews currently).
2024-04-25 01:41:32 -07:00
Travis Abendshien
e823bb5c93 Merge pull request #41 from Loran425/feature/modify-imports
Feature/modify imports
2024-04-24 22:37:27 -07:00
Travis Abendshien
7652289fe5 Merge branch 'main' into feature/modify-imports 2024-04-24 22:36:56 -07:00
Travis Abendshien
b9b23611f7 Merge pull request #43 from xarvex/file-open-1
Allow files to be opened in their respective programs in the background for Windows, MacOS, and Linux
2024-04-24 22:18:56 -07:00
Andrew Arneson
de09da1592 remove tagstudio prefix for source libraries 2024-04-24 23:18:16 -06:00
Xarvex
956ffd4663 Properly detach process on Windows 2024-04-25 00:07:14 -05:00
Xarvex
8b4b2507fa Account for start being a shell builtin on Windows 2024-04-24 23:50:26 -05:00
Xarvex
f125e5a50d Implement file opening for Linux and MacOS, allow Windows in background
Note: this is only tested on Linux
2024-04-24 23:37:36 -05:00
Andrew Arneson
0b1c097f97 update tagstudio.py refrences to tag_studio.py 2024-04-24 22:11:22 -06:00
Andrew Arneson
2b5697ea50 Remove wildcard Imports 2024-04-24 21:51:43 -06:00
Andrew Arneson
4e5b7b1c7d Fix reference to datetime 2024-04-24 21:51:22 -06:00
Andrew Arneson
0b4ccac5ff Import Humanfriendly format_size when importing format_timespan 2024-04-24 21:14:13 -06:00
Andrew Arneson
952ed8f27d Remove Unused Imports 2024-04-24 21:10:44 -06:00
Andrew Arneson
c0c18dabc1 Optimize & Sort Imports 2024-04-24 19:52:10 -06:00
Andrew Arneson
7b48e5e17e Prevent Import collisions
Rename tagstudio.py to tag_studio.py
2024-04-24 19:48:25 -06:00
Travis Abendshien
6e7567a192 Update launch.json
Updated launch.json to be cross-platform between Windows and UNIX systems
2024-04-24 17:12:40 -07:00
Travis Abendshien
ad6fefbe2b Merge pull request #35 from dakota-marshall/nixos-support
Add NixOS Dev Environment Support
2024-04-24 17:01:16 -07:00
Travis Abendshien
4a55af66ee Remove duplicate tag IDs when loading library
Tags with duplicate IDs inside a library save file are removed when opening the library. Cleans up the mess from #38.
2024-04-24 16:22:36 -07:00
Ripp_
f0148c7f35 🏷️ Add strict typing dicts for ts_library.json 2024-04-25 00:10:32 +01:00
Travis Abendshien
ac00890c90 Merge pull request #39 from Thesacraft/main
Update library.py to not duplicate default tags on save
2024-04-24 15:26:46 -07:00
DrRetro
9e61d45ea5 Rewrote Multi-Select to use field templates. 2024-04-24 18:11:41 -04:00
Theasacraft
432f4851f3 Update library.py to not duplicate default tags on save 2024-04-25 00:02:32 +02:00
Dakota Marshall
4184848f9c Update Nix flake pinned hash for python3.12 and QT 6.6.3 support 2024-04-24 16:51:06 -04:00
Dakota Marshall
bc0f8b991e Update install documentation for NixOS 2024-04-24 16:25:57 -04:00
Dakota Marshall
c4e8d40abe Update bash script to use env for nix support 2024-04-24 16:25:57 -04:00
Dakota Marshall
c7492b78d3 Add flake files for working Nix environment 2024-04-24 16:25:57 -04:00
Travis Abendshien
e69e4998e5 Merge branch 'main' of https://github.com/CyanVoxel/TagStudio 2024-04-24 13:14:21 -07:00
Travis Abendshien
0b9b4dac73 Updated Prerequisites to Python 3.12 2024-04-24 13:14:11 -07:00
Travis Abendshien
844f756dba Merge pull request #32 from Thesacraft/main
python 3.12 support
2024-04-24 13:13:21 -07:00
DrRetro
6e341e097b Merge pull request #1 from CyanVoxel/main
Update
2024-04-24 15:25:15 -04:00
DrRetro
def244b732 Fixed bug that occured with adding other fields to one entry. 2024-04-24 15:18:22 -04:00
Theasacraft
129d336357 Merge branch 'CyanVoxel:main' into main 2024-04-24 21:13:38 +02:00
Travis Abendshien
071100bf90 Merge pull request #33 from LennartCode/patch-1
docs(README.md): Removed typo.
2024-04-24 12:04:11 -07:00
Lennart S
e161290571 docs(README.md): Removed typo. 2024-04-24 20:59:41 +02:00
Theasacraft
e8ab8059cd Update requirements.txt to support python 3.12 2024-04-24 19:18:24 +02:00
Theasacraft
7ec29228b5 Update requirements.txt to also support python3.12 2024-04-24 19:15:17 +02:00
DrRetro
3974c1b031 Multi-Select Removing Tags 2024-04-24 12:09:07 -04:00
DrRetro
fba2f8f46b Multi-Select Tag Adding 2024-04-24 11:31:53 -04:00
Travis Abendshien
0529f1fe7e Fixed library creation bug inside of empty .TagStudio folder
Fixed a library creation bug where the program would malfunction when trying to create a new library from a folder called ".TagStudio".
2024-04-23 23:22:29 -07:00
Travis Abendshien
59be7c0cf9 Merge pull request #30 from Loran425/main
Allow network path libraries
2024-04-23 22:21:41 -07:00
Andrew Arneson
dfe2b57952 Allow network path libraries
Swap normalized path strip from using strip to using rstrip to avoid stripping leading slashes
2024-04-23 22:59:18 -06:00
Travis Abendshien
45e765862d Fixed forward slash being stripped from Unix paths 2024-04-23 20:10:16 -07:00
Travis Abendshien
cf1ce6bb24 Merge pull request #27 from Thesacraft/main
Prevent exceptions when no Library is Loaded
2024-04-23 17:33:09 -07:00
Theasacraft
e3e1c55fab Merge branch 'CyanVoxel:main' into main 2024-04-24 02:28:34 +02:00
Travis Abendshien
2e57e0397c Extended filetype support
Added rudimentary support for audio types, text types, spreadsheets, presentations, archives, and programs. These files will now be scanned and picked up when refreshing the library.
2024-04-23 17:10:23 -07:00
Travis Abendshien
5e33d07e39 Removed unused dependencies 2024-04-23 16:26:10 -07:00
Theasacraft
fffd9dba6c Update ts_qt.py to prevent exceptions in file menu
Before this Refresh Directorys/Save Library/Save Library Backup would throw an exception if no library was loaded this prevents that
2024-04-24 01:17:30 +02:00
Theasacraft
98a01aea80 Merge branch 'CyanVoxel:main' into main 2024-04-24 00:55:17 +02:00
Theasacraft
1489d56be7 Update ts_qt.py to not throw Errors when no library is open
Before this the Refresh/Save Library/Save Library Backup would throw an Error when no Library is open
2024-04-24 00:53:41 +02:00
Travis Abendshien
d974aa777c Removed (most) janky theme overriding (#5)
- Removed most of the temporary theming that was clashing with Qt, especially in system light mode
- Possibly fixed the "transparent menu" issue
2024-04-23 15:49:05 -07:00
Travis Abendshien
f67b26fbd6 Added SIGTERM handling (#13)
- Also removed miscellaneous whitespace
- Removed leftover print statement on startup
2024-04-23 13:31:53 -07:00
Travis Abendshien
a074bed540 Merge pull request #22 from Thesacraft/main
Update ts_qt.py to fix bootup bug
2024-04-23 11:29:28 -07:00
Theasacraft
a55e649d83 Update ts_qt.py to fix bootup bug
Fixes the visual bug
2024-04-23 20:20:13 +02:00
112 changed files with 16459 additions and 27727 deletions

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

@@ -0,0 +1,62 @@
name: Bug Report
description: File a bug or issue report.
title: '[Bug]: '
labels: ['Type: Bug']
body:
- type: markdown
attributes:
value: |
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,38 @@
name: Feature Request
description: Suggest a new feature.
title: '[Feature Request]: '
labels: ['Type: Enhancement']
body:
- type: markdown
attributes:
value: |
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?

51
.github/workflows/apprun.yaml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: PySide App Test
on: [ push, pull_request ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install system dependencies
run: |
# dont run update, it is slow
# sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-x11-0 \
x11-utils \
libyaml-dev \
libegl1-mesa \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libopengl0 \
libxcb-cursor0 \
libpulse0
- name: Install dependencies
run: |
pip install -Ur requirements.txt
- name: Run TagStudio app and check exit code
run: |
xvfb-run --server-args="-screen 0, 1920x1200x24 -ac +extension GLX +render -noreset" python tagstudio/tag_studio.py --ci -o /tmp/
exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "TagStudio ran successfully"
else
echo "TagStudio failed with exit code $exit_code"
exit 1
fi

39
.github/workflows/mypy.yaml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: MyPy
on: [ push, pull_request ]
jobs:
mypy:
name: Run MyPy
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: reviewdog/action-setup@v1
with:
reviewdog_version: latest
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
# 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
- uses: tsuyoshicho/action-mypy@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-check
fail_on_error: true
workdir: tagstudio
level: error
mypy_flags: --config-file ../pyproject.toml

106
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,106 @@
name: Release
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+*
jobs:
linux:
strategy:
matrix:
build-type: ['', portable]
include:
- build-type: ''
build-flag: ''
suffix: ''
- build-type: portable
build-flag: --portable
suffix: _portable
runs-on: ubuntu-22.04
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 -- ${{ matrix.build-flag }}
- run: tar czfC dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz dist tagstudio
- uses: actions/upload-artifact@v4
with:
name: tagstudio_linux_x86_64${{ matrix.suffix }}
path: dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz
macos:
strategy:
matrix:
os-version: ['12', '14']
include:
- 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
- uses: actions/upload-artifact@v4
with:
name: tagstudio_macos_${{ matrix.arch }}
path: dist/tagstudio_macos_${{ matrix.arch }}.tar.gz
windows:
strategy:
matrix:
build-type: ['', portable]
include:
- build-type: ''
build-flag: ''
suffix: ''
file-end: ''
- build-type: portable
build-flag: --portable
suffix: _portable
file-end: .exe
runs-on: windows-2019
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 -- ${{ matrix.build-flag }}
- run: Compress-Archive -Path dist/TagStudio${{ matrix.file-end }} -DestinationPath dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
- uses: actions/upload-artifact@v4
with:
name: tagstudio_windows_x86_64${{ matrix.suffix }}
path: dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
publish:
needs: [linux, macos, windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: softprops/action-gh-release@v2
with:
files: |
tagstudio_linux_x86_64/*
tagstudio_linux_x86_64_portable/*
tagstudio_macos_x86_64/*
tagstudio_macos_aarch64/*
tagstudio_windows_x86_64/*
tagstudio_windows_x86_64_portable/*

11
.github/workflows/ruff.yaml vendored Normal file
View File

@@ -0,0 +1,11 @@
name: Ruff
on: [ push, pull_request ]
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: chartboost/ruff-action@v1
with:
version: 0.4.2
args: 'format --check'

3
.gitignore vendored
View File

@@ -35,6 +35,7 @@ MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
!tagstudio.spec
# Installer logs
pip-log.txt
@@ -249,5 +250,5 @@ compile_commands.json
# TagStudio
.TagStudio
TagStudio.ini
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt

6
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.4.2
hooks:
- id: ruff-format

2
.vscode/launch.json vendored
View File

@@ -8,7 +8,7 @@
"name": "TagStudio",
"type": "python",
"request": "launch",
"program": "${workspaceRoot}\\TagStudio\\tagstudio.py",
"program": "${workspaceRoot}/tagstudio/tag_studio.py",
"console": "integratedTerminal",
"justMyCode": true,
"args": []

69
Build_MacOS_app.sh Executable file
View File

@@ -0,0 +1,69 @@
#! /usr/bin/env bash
# GETTING BASE DIR
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# SETTING UP CONSTANTS
TAGSTUDIO_NAME="TagStudio"
TAGSTUDIO_DIR="$SCRIPT_DIR/tagstudio"
TAGSTUDIO_DIR_RESOURCES="$TAGSTUDIO_DIR/resources"
TAGSTUDIO_ICON="$TAGSTUDIO_DIR/resources/icon.ico"
TAGSTUDIO_SRC="$TAGSTUDIO_DIR/src"
TAGSTUDIO_MAIN="$TAGSTUDIO_DIR/tag_studio.py"
DIST_PATH="$SCRIPT_DIR/dist"
BUILD_PATH="$SCRIPT_DIR/build"
LOGS_PATH="$BUILD_PATH/logs"
printf -- "🏁 Starting Script \n"
# CREATE VENV AND INSTALL REQUIREMENTS
printf -- "🐍 Creating Python virtual env\n"
python3 -m venv .venv
source .venv/bin/activate
if [ ! -d $LOGS_PATH ]; then
printf -- "📁 Creating Logs folder\n"
mkdir -p $LOGS_PATH;
fi
printf -- "💻 Installing Requirements \n"
pip install -r requirements.txt > "$LOGS_PATH/pip.log" 2>&1
pip install PyInstaller > "$LOGS_PATH/pip.log" 2>&1
if [[ "$OSTYPE" == "darwin"* ]]; then
printf -- "🍏 MacOS Detected \n"
SYS_CMD="--windowed"
OS=0
fi
SECONDS=0
# CREATE COMMAND
printf -- "⏳ Building App \n"
COMMAND=$( python -m PyInstaller \
--name "$TAGSTUDIO_NAME" \
--icon "$TAGSTUDIO_ICON" \
--add-data "$TAGSTUDIO_DIR_RESOURCES:./resources" \
--add-data "$TAGSTUDIO_SRC:./src" \
--distpath "$DIST_PATH" \
-p "$TAGSTUDIO_DIR" \
--noconsole \
--workpath "$BUILD_PATH" \
-y "$SYS_CMD" "$TAGSTUDIO_MAIN" \
> "$LOGS_PATH/pyinstaller.log" 2>&1 )
duration=$SECONDS
if $COMMAND; then
printf -- "✅ Build Successfull \n"
printf -- "$((duration)) seconds of build\n"
if [[ "$OS" == 0 ]]; then
printf -- "📁 Opening App folder \n"
open $DIST_PATH
fi
else
printf -- "❌ Error Building the app\nPlease read the logs\navailable at build/logs\n"
fi
printf -- "🏁 END OF TRANSMISSION"

31
Build_win.bat Normal file
View File

@@ -0,0 +1,31 @@
@echo off
set TAGSTUDIO_NAME=TagStudio
set TAGSTUDIO_DIR=tagstudio
set TAGSTUDIO_DIR_RESOURCES=%TAGSTUDIO_DIR%/resources
set TAGSTUDIO_ICON=%TAGSTUDIO_DIR%/resources/icon.ico
set TAGSTUDIO_SRC=%TAGSTUDIO_DIR%/src
set TAGSTUDIO_MAIN=%TAGSTUDIO_DIR%/tag_studio.py
set BUILD_MODE=--onedir
if "%1" == "--help" (
echo run "%~nx0" for normal Build
echo run "%~nx0 --portable" for Build packaged into one file
goto end
)
if "%1" == "--portable" (
echo Building portable executable...
set BUILD_MODE=--onefile
goto run
)
if not "%1" == "" (
echo Invalid argument run "%~nx0 --help" for help
goto end
)
:run
echo Building executable...
set COMMAND=PyInstaller --name "%TAGSTUDIO_NAME%" --icon "%TAGSTUDIO_ICON%" --add-data "%TAGSTUDIO_DIR_RESOURCES%:./resources" --add-data "%TAGSTUDIO_SRC%:./src" -p "%TAGSTUDIO_DIR%" --console %BUILD_MODE% "%TAGSTUDIO_MAIN%" -y
call .venv\Scripts\activate.bat
%COMMAND%
deactivate
:end

View File

@@ -5,7 +5,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [9.1.0-alpha] - 2024-04-22
## [9.2.0] - 2024-05-14
### Added
- Full macOS and Linux support
- Ability to apply tags to multiple selections at once
- Right-click context menu for opening files or their locations
- Support for all filetypes inside of the library
- Configurable filetype blacklist
- Option to automatically open last used library on startup
- Tool to convert folder structure to tag tree
- SIGTERM handling in console window
- Keyboard shortcuts for basic functions
- Basic support for plaintext thumbnails
- Default icon for files with no thumbnail support
- Menu action to close library
- All tags now show in the "Add Tag" panel by default
- Modal view to view and manage all library tags
- Build scripts for Windows and macOS
- Help menu option to visit the GitHub repository
- Toggleable "Recent Libraries" list in the entry side panel
### Fixed
- Fixed errors when performing actions with no library open
- Fixed bug where built-in tags were duplicated upon saving
- QThreads are now properly terminated on application exit
- Images with rotational EXIF data are now properly displayed
- Fixed "truncated" images causing errors
- Fixed images with large resolutions causing errors
### Changed
- Updated minimum Python version to 3.12
- Various UI improvements
- Improved legibility of the Light Theme (still a WIP)
- Updated Dark Theme
- Added hand cursor to several clickable elements
- Fixed network paths not being able to load
- Various code cleanup and refactoring
- New application icons
### Known Issues
- Using and editing multiple entry fields of the same type may result in incorrect field(s) being updated
- Adding Favorite or Archived tags via the thumbnail badges may apply the tag(s) to incorrect fields
- Searching for tag names with spaces does not currently function as intended
- A temporary workaround it to omit spaces in tag names when searching
- Sorting fields using the "Sort Fields" macro may result in edit icons being shown for incorrect fields
## [9.1.0] - 2024-04-22
### Added

172
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,172 @@
# 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`)_
### 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 _(Work in Progress)_
> [!IMPORTANT]
> Tests are not currently run as part of any automated workflow.
To run all the tests use `python -m pytest tests/` from the `tagstudio` folder.
## 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_

111
README.md
View File

@@ -1,4 +1,4 @@
# TagStudio (Preview/Alpha): A User-Focused Document Management System
# TagStudio (Alpha): A User-Focused Document Management System
<p align="center">
<img width="60%" src="github_header.png">
@@ -10,14 +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)
@@ -44,64 +48,33 @@ TagStudio is a photo & file organization application with an underlying system t
- Name, Author, Artist (Single-Line Text Fields)
- Description, Notes (Multiline Text Fields)
- Tags, Meta Tags, Content Tags (Tag Boxes)
- Crete rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from.
- Search for entries based on tags, metadata, or filename (using `filename: <query>`)
- Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from.
- Search for entries based on tags, ~~metadata~~ (TBA), or filenames/filetypes (using `filename: <query>`)
- 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
> [!CAUTION]
> TagStudio is only currently verified to work on Windows. I've run into issues with the Qt code running on Linux, but I don't know how severe these issues are. There's also likely bugs regarding filenames and portability of the databases across different OSes.
### Prerequisites
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.
- Python 3.9.6 - ~3.10 *(Not working on 3.12)*
### Creating the Virtual Environment
*Skip this step if launching from the .sh script on Linux.*
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`
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
### Launching
> [!NOTE]
> Depending on your system, Python may be called `python`, `py`, `python3`, or `py3`. These instructions use the alias `python3`.
> [!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
Optional arguments to pass to the program.
> `--open <path>` / `-o <path>`
> Path to a TagStudio Library folder to open on start.
#### Windows
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.
Alternatively, with the virtual environment loaded, run the python file at `tagstudio\tagstudio.py` from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tagstudio.py`.
> [!CAUTION]
> TagStudio on Linux & macOS likely won't function correctly at this time. If you're trying to run this in order to help test, debug, and improve compatibility, then charge on ahead!
#### macOS
With the virtual environment loaded, run the python file at "tagstudio/tagstudio.py" from your terminal. If you're in the project's root directory, simply run `python3 tagstudio/tagstudio.py`. When launching the program in the future, remember to activate the virtual environment each time before launching *(an easier method is currently being worked on).*
#### Linux
Run the "TagStudio.sh" script, and the program should launch! (Make sure that the script is marked as executable). 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 `sh TagStudio.sh`.
> `--config-file <path>` / `-c <path>`
> Path to the TagStudio config file to load.
## Usage
@@ -146,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.
@@ -177,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
@@ -193,14 +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.
## Launching/Building From Source
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.1.0) the project is in a useable state, however it lacks proper testing and quality of life features. Currently the program has only been verified to work properly on Windows, and is unlikely to properly run on Linux or macOS in its current state, however this functionality is a priority going forward with testers.
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/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
@@ -210,18 +187,16 @@ Of the several features I have planned for the project, these are broken up into
- Boolean Search
- Coexisting Text + Tag Search
- Searchable File Metadata
- Tag management view
- Applying metadata via multi-selection
- Comprehensive Tag management tab
- Easier ways to apply tags in bulk
- Tag Search Panel
- Recent Tags Panel
- Top Tags Panel
- Pinned Tags Panel
- Apply tags based on system folders
- Better (stable, performant) library grid view
- Improved entry relinking
- Cached thumbnails
- Collations
- Tag-like Groups
- Resizable thumbnail grid
- User-defined metadata fields
- Multiple directory support
@@ -231,13 +206,13 @@ Of the several features I have planned for the project, these are broken up into
- Better internal API for accessing Entries, Tags, Fields, etc. from the library.
- Proper testing workflow
- Continued code cleanup and modularization
- Reassessment of save file structure in order to prioritize portability (leading to exportable tags, presets, etc)
- Exportable/importable library data including "Tag Packs"
#### Future Features
- Support for multiple simultaneous users/clients
- Draggable files outside the program
- Ability to ignore specific files
- Comprehensive filetype whitelist
- A finished “macro system” for automatic tagging based on predetermined criteria.
- Different library views
- Date and time fields
@@ -245,20 +220,20 @@ Of the several features I have planned for the project, these are broken up into
- Audio waveform previews
- 3D object previews
- Additional previews for miscellaneous file types
- Exportable/sharable tags and settings
- Optional global tags and settings, spanning across libraries
- Importing & exporting libraries to/from other programs
- Port to a more performant language and modern frontend (Rust?, Tauri?, etc.)
- Plugin system
- Local OCR search
- Support for local machine learning-based tag suggestions for images
- Mobile version
- Mobile version _(FAR future)_
#### Features I Likely Wont Add/Pull
- Native Cloud Integration
- Native Cloud Integration
- There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Pointing TagStudio to one of these mounts should function similarly to what native integration would look like.
- Native ChatGPT/Non-Local LLM Integration
- This could mean different things depending on what you're intending. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Goals/Privacy](#goals)). I wouldn't, however, mind using **locally** hosted models to provide the *optional* ability for additional searching and tagging methods (especially when it comes to facial recognition).
- This could mean different things depending on what you're intending. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: [Goals/Privacy](#goals)). I wouldn't, however, mind using **locally** hosted models to provide the _optional_ ability for additional searching and tagging methods (especially when it comes to facial recognition).
### Why Is the Version Already v9?
@@ -266,10 +241,4 @@ 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.
As of writing I dont have a concrete style guide, just try to stay within or close enough to the PEP 8 style guide and/or match the style of the existing code.
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...)._

View File

@@ -1,5 +1,7 @@
#! /bin/bash
python3 -m venv .venv
#! /usr/bin/env bash
set -e
cd "$(dirname "$0")"
! [ -d .venv ] && python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python tagstudio/tagstudio.py
python tagstudio/tag_studio.py

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 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.

44
flake.lock generated Normal file
View File

@@ -0,0 +1,44 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1717602782,
"narHash": "sha256-pL9jeus5QpX5R+9rsp3hhZ+uplVHscNJh8n8VpqscM0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e8057b67ebf307f01bdcc8fba94d94f75039d1f6",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"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",
"qt6Nixpkgs": "qt6Nixpkgs"
}
}
},
"root": "root",
"version": 7
}

85
flake.nix Normal file
View File

@@ -0,0 +1,85 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
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 [
pkgs.gcc-unwrapped
pkgs.zlib
pkgs.libglvnd
pkgs.glib
pkgs.stdenv.cc.cc
pkgs.fontconfig
pkgs.libxkbcommon
pkgs.xorg.libxcb
pkgs.freetype
pkgs.dbus
pkgs.zstd
# For PySide6 Multimedia
pkgs.libpulseaudio
pkgs.libkrb5
qt6Pkgs.qt6.qtwayland
qt6Pkgs.qt6.full
qt6Pkgs.qt6.qtbase
];
buildInputs = with pkgs; [
cmake
gdb
zstd
python312Packages.pip
python312Full
python312Packages.virtualenv # run virtualenv .
python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip
libgcc
makeWrapper
bashInteractive
glib
libxkbcommon
freetype
binutils
dbus
coreutils
libGL
libGLU
fontconfig
xorg.libxcb
# this is for the shellhook portion
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 = ''
export QT_QPA_PLATFORM=wayland
export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH
# export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/
export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix}
bashdir=$(mktemp -d)
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
exec "$bashdir/bash"
'';
};
};
}

8
pyproject.toml Normal file
View File

@@ -0,0 +1,8 @@
[tool.ruff]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
[tool.mypy]
strict_optional = false
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
explicit_package_bases = true
warn_unused_ignores = true

5
requirements-dev.txt Normal file
View File

@@ -0,0 +1,5 @@
ruff==0.4.2
pre-commit==3.7.0
pytest==8.2.0
Pyinstaller==6.6.0
mypy==1.10.0

View File

@@ -1,12 +1,11 @@
click==8.1.3
climage==0.1.3
humanfriendly==10.0
opencv_python==4.8.0.74
opencv_python>=4.8.0.74,<=4.9.0.80
Pillow==10.3.0
pillow_avif_plugin==1.3.1
PySide6==6.5.1.1
PySide6_Addons==6.5.1.1
PySide6_Essentials==6.5.1.1
Requests==2.31.0
typing_extensions==3.10.0.0
ujson==5.8.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
typing_extensions>=3.10.0.0,<=4.11.0
ujson>=5.8.0,<=5.9.0
rawpy==0.21.0
pillow-heif==0.16.0
chardet==5.2.0

View File

@@ -1,2 +1,2 @@
@echo off
.venv\Scripts\python.exe .\TagStudio\tagstudio.py --ui qt %*
.venv\Scripts\python.exe .\TagStudio\tag_studio.py --ui qt %*

86
tagstudio.spec Normal file
View File

@@ -0,0 +1,86 @@
# -*- mode: python ; coding: utf-8 -*-
# vi: ft=python
from argparse import ArgumentParser
import sys
from PyInstaller.building.api import COLLECT, EXE, PYZ
from PyInstaller.building.build_main import Analysis
from PyInstaller.building.osx import BUNDLE
parser = ArgumentParser()
parser.add_argument('--portable', action='store_true')
options = parser.parse_args()
name = 'TagStudio' if sys.platform == 'win32' else 'tagstudio'
icon = None
if sys.platform == 'win32':
icon = 'tagstudio/resources/icon.ico'
elif sys.platform == 'darwin':
icon = 'tagstudio/resources/icon.icns'
a = Analysis(
['tagstudio/tag_studio.py'],
pathex=[],
binaries=[],
datas=[('tagstudio/resources', 'resources'), ('tagstudio/src', 'src')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
excludes=[],
runtime_hooks=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
include = [a.scripts]
if options.portable:
include += (a.binaries, a.datas)
exe = EXE(
pyz,
*include,
[],
bootloader_ignore_signals=False,
console=False,
hide_console='hide-early',
disable_windowed_traceback=False,
debug=False,
name=name,
exclude_binaries=not options.portable,
icon=icon,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None
)
coll = None if options.portable else COLLECT(
exe,
a.binaries,
a.datas,
name=name,
strip=False,
upx=True,
upx_exclude=[],
)
app = BUNDLE(
exe if coll is None else coll,
name='TagStudio.app',
icon=icon,
bundle_identifier='com.github.tagstudiodev',
version='0.0.0',
info_plist={
'NSAppleScriptEnabled': False,
'NSPrincipalClass': 'NSApplication',
}
)

View File

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 KiB

After

Width:  |  Height:  |  Size: 677 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
VERSION: str = "9.3.1" # Major.Minor.Patch
VERSION_BRANCH: str = "Pre-Release" # Usually "" or "Pre-Release"
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
BACKUP_FOLDER_NAME: str = "backups"
COLLAGE_FOLDER_NAME: str = "collages"
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",
]
RAW_IMAGE_TYPES: list[str] = [
".raw",
".dng",
".rw2",
".nef",
".arw",
".crw",
".cr2",
".cr3",
]
VIDEO_TYPES: list[str] = [
".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",
]
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",
]
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
+ VIDEO_TYPES
+ AUDIO_TYPES
+ DOC_TYPES
+ SPREADSHEET_TYPES
+ PRESENTATION_TYPES
+ ARCHIVE_TYPES
+ PROGRAM_TYPES
+ SHORTCUT_TYPES
)
BOX_FIELDS = ["tag_box", "text_box"]
TEXT_FIELDS = ["text_line", "text_box"]
DATE_FIELDS = ["datetime"]
TAG_COLORS = [
"",
"black",
"dark gray",
"gray",
"light gray",
"white",
"light pink",
"pink",
"red",
"red orange",
"orange",
"yellow orange",
"yellow",
"lime",
"light green",
"mint",
"green",
"teal",
"cyan",
"light blue",
"blue",
"blue violet",
"violet",
"purple",
"lavender",
"berry",
"magenta",
"salmon",
"auburn",
"dark brown",
"brown",
"light brown",
"blonde",
"peach",
"warm gray",
"cool gray",
"olive",
]

View File

@@ -0,0 +1,26 @@
import enum
class SettingItems(str, enum.Enum):
"""List of setting item names."""
START_LOAD_LAST = "start_load_last"
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

View File

@@ -2,6 +2,7 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
class FieldTemplate:
"""A TagStudio Library Field Template object."""
@@ -11,17 +12,17 @@ class FieldTemplate:
self.type = type
def __str__(self) -> str:
return f'\nID: {self.id}\nName: {self.name}\nType: {self.type}\n'
return f"\nID: {self.id}\nName: {self.name}\nType: {self.type}\n"
def __repr__(self) -> str:
return self.__str__()
def to_compressed_obj(self) -> dict:
"""An alternative to __dict__ that only includes fields containing non-default data."""
obj = {}
obj: dict = {}
# All Field fields (haha) are mandatory, so no value checks are done.
obj['id'] = self.id
obj['name'] = self.name
obj['type'] = self.type
obj["id"] = self.id
obj["name"] = self.name
obj["type"] = self.type
return obj

View File

@@ -0,0 +1,42 @@
from typing import TypedDict
from typing_extensions import NotRequired
class JsonLibary(TypedDict("", {"ts-version": str})):
# "ts-version": str
tags: "list[JsonTag]"
collations: "list[JsonCollation]"
fields: list # TODO
macros: "list[JsonMacro]"
entries: "list[JsonEntry]"
ext_list: list[str]
is_exclude_list: bool
ignored_extensions: NotRequired[list[str]] # deprecated
class JsonBase(TypedDict):
id: int
class JsonTag(JsonBase, total=False):
name: str
aliases: list[str]
color: str
shorthand: str
subtag_ids: list[int]
class JsonCollation(JsonBase, total=False):
title: str
e_ids_and_pages: list[list[int]]
sort_order: str
cover_id: int
class JsonEntry(JsonBase, total=False):
filename: str
path: str
fields: list[dict] # TODO
class JsonMacro(JsonBase, total=False): ... # TODO

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
from enum import Enum
class ColorType(Enum):
class ColorType(int, Enum):
PRIMARY = 0
TEXT = 1
BORDER = 2
@@ -14,234 +14,271 @@ class ColorType(Enum):
_TAG_COLORS = {
'': {ColorType.PRIMARY: '#1E1A33',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#2B2547',
ColorType.LIGHT_ACCENT: '#CDA7F7',
ColorType.DARK_ACCENT: '#1E1A33',
},
'black': {ColorType.PRIMARY: '#111018',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#18171e',
ColorType.LIGHT_ACCENT: '#b7b6be',
ColorType.DARK_ACCENT: '#03020a',
},
'dark gray': {ColorType.PRIMARY: '#24232a',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#2a2930',
ColorType.LIGHT_ACCENT: '#bdbcc4',
ColorType.DARK_ACCENT: '#07060e',
},
'gray': {ColorType.PRIMARY: '#53525a',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#5b5a62',
ColorType.LIGHT_ACCENT: '#cbcad2',
ColorType.DARK_ACCENT: '#191820',
},
'light gray': {ColorType.PRIMARY: '#aaa9b0',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#b6b4bc',
ColorType.LIGHT_ACCENT: '#cbcad2',
ColorType.DARK_ACCENT: '#191820',
},
'white': {ColorType.PRIMARY: '#f2f1f8',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#fefeff',
ColorType.LIGHT_ACCENT: '#ffffff',
ColorType.DARK_ACCENT: '#302f36',
},
'light pink': {ColorType.PRIMARY: '#ff99c4',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#ffaad0',
ColorType.LIGHT_ACCENT: '#ffcbe7',
ColorType.DARK_ACCENT: '#6c2e3b',
},
'pink': {ColorType.PRIMARY: '#ff99c4',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#ffaad0',
ColorType.LIGHT_ACCENT: '#ffcbe7',
ColorType.DARK_ACCENT: '#6c2e3b',
},
'magenta': {ColorType.PRIMARY: '#f6466f',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#f7587f',
ColorType.LIGHT_ACCENT: '#fba4bf',
ColorType.DARK_ACCENT: '#61152f',
},
'red': {ColorType.PRIMARY: '#e22c3c',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#b21f2d',
# ColorType.BORDER: '#e54252',
ColorType.LIGHT_ACCENT: '#f39caa',
ColorType.DARK_ACCENT: '#440d12',
},
'red orange': {ColorType.PRIMARY: '#e83726',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#ea4b3b',
ColorType.LIGHT_ACCENT: '#f5a59d',
ColorType.DARK_ACCENT: '#61120b',
},
'salmon': {ColorType.PRIMARY: '#f65848',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#f76c5f',
ColorType.LIGHT_ACCENT: '#fcadaa',
ColorType.DARK_ACCENT: '#6f1b16',
},
'orange': {ColorType.PRIMARY: '#ed6022',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#ef7038',
ColorType.LIGHT_ACCENT: '#f7b79b',
ColorType.DARK_ACCENT: '#551e0a',
},
'yellow orange': {ColorType.PRIMARY: '#fa9a2c',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#fba94b',
ColorType.LIGHT_ACCENT: '#fdd7ab',
ColorType.DARK_ACCENT: '#66330d',
},
'yellow': {ColorType.PRIMARY: '#ffd63d',
ColorType.TEXT: ColorType.DARK_ACCENT,
# ColorType.BORDER: '#ffe071',
ColorType.BORDER: '#e8af31',
ColorType.LIGHT_ACCENT: '#fff3c4',
ColorType.DARK_ACCENT: '#754312',
},
'mint': {ColorType.PRIMARY: '#4aed90',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#79f2b1',
ColorType.LIGHT_ACCENT: '#c8fbe9',
ColorType.DARK_ACCENT: '#164f3e',
},
'lime': {ColorType.PRIMARY: '#92e649',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#b2ed72',
ColorType.LIGHT_ACCENT: '#e9f9b7',
ColorType.DARK_ACCENT: '#405516',
},
'light green': {ColorType.PRIMARY: '#85ec76',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#a3f198',
ColorType.LIGHT_ACCENT: '#e7fbe4',
ColorType.DARK_ACCENT: '#2b5524',
},
'green': {ColorType.PRIMARY: '#28bb48',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#43c568',
ColorType.LIGHT_ACCENT: '#93e2c8',
ColorType.DARK_ACCENT: '#0d3828',
},
'teal': {ColorType.PRIMARY: '#1ad9b2',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#4de3c7',
ColorType.LIGHT_ACCENT: '#a0f3e8',
ColorType.DARK_ACCENT: '#08424b',
},
'cyan': {ColorType.PRIMARY: '#49e4d5',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#76ebdf',
ColorType.LIGHT_ACCENT: '#bff5f0',
ColorType.DARK_ACCENT: '#0f4246',
},
'light blue': {ColorType.PRIMARY: '#55bbf6',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#70c6f7',
ColorType.LIGHT_ACCENT: '#bbe4fb',
ColorType.DARK_ACCENT: '#122541',
},
'blue': {ColorType.PRIMARY: '#3b87f0',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#4e95f2',
ColorType.LIGHT_ACCENT: '#aedbfa',
ColorType.DARK_ACCENT: '#122948',
},
'blue violet': {ColorType.PRIMARY: '#5948f2',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#6258f3',
ColorType.LIGHT_ACCENT: '#9cb8fb',
ColorType.DARK_ACCENT: '#1b1649',
},
'violet': {ColorType.PRIMARY: '#874ff5',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#9360f6',
ColorType.LIGHT_ACCENT: '#c9b0fa',
ColorType.DARK_ACCENT: '#3a1860',
},
'purple': {ColorType.PRIMARY: '#bb4ff0',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#c364f2',
ColorType.LIGHT_ACCENT: '#dda7f7',
ColorType.DARK_ACCENT: '#531862',
},
'peach': {ColorType.PRIMARY: '#f1c69c',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#f4d4b4',
ColorType.LIGHT_ACCENT: '#fbeee1',
ColorType.DARK_ACCENT: '#613f2f',
},
'brown': {ColorType.PRIMARY: '#823216',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#8a3e22',
ColorType.LIGHT_ACCENT: '#cd9d83',
ColorType.DARK_ACCENT: '#3a1804',
},
'lavender': {ColorType.PRIMARY: '#ad8eef',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#b99ef2',
ColorType.LIGHT_ACCENT: '#d5c7fa',
ColorType.DARK_ACCENT: '#492b65',
},
'blonde': {ColorType.PRIMARY: '#efc664',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#f3d387',
ColorType.LIGHT_ACCENT: '#faebc6',
ColorType.DARK_ACCENT: '#6d461e',
},
'auburn': {ColorType.PRIMARY: '#a13220',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#aa402f',
ColorType.LIGHT_ACCENT: '#d98a7f',
ColorType.DARK_ACCENT: '#3d100a',
},
'light brown': {ColorType.PRIMARY: '#be5b2d',
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: '#c4693d',
ColorType.LIGHT_ACCENT: '#e5b38c',
ColorType.DARK_ACCENT: '#4c290e',
},
'dark brown': {ColorType.PRIMARY: '#4c2315',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#542a1c',
ColorType.LIGHT_ACCENT: '#b78171',
ColorType.DARK_ACCENT: '#211006',
},
'cool gray': {ColorType.PRIMARY: '#515768',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#5b6174',
ColorType.LIGHT_ACCENT: '#9ea1c3',
ColorType.DARK_ACCENT: '#181a37',
},
'warm gray': {ColorType.PRIMARY: '#625550',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#6c5e57',
ColorType.LIGHT_ACCENT: '#c0a392',
ColorType.DARK_ACCENT: '#371d18',
},
'olive': {ColorType.PRIMARY: '#4c652e',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#586f36',
ColorType.LIGHT_ACCENT: '#b4c17a',
ColorType.DARK_ACCENT: '#23300e',
},
'berry': {ColorType.PRIMARY: '#9f2aa7',
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: '#aa43b4',
ColorType.LIGHT_ACCENT: '#cc8fdc',
ColorType.DARK_ACCENT: '#41114a',
},
"": {
ColorType.PRIMARY: "#1e1e1e",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#333333",
ColorType.LIGHT_ACCENT: "#FFFFFF",
ColorType.DARK_ACCENT: "#222222",
},
"black": {
ColorType.PRIMARY: "#111018",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#18171e",
ColorType.LIGHT_ACCENT: "#b7b6be",
ColorType.DARK_ACCENT: "#03020a",
},
"dark gray": {
ColorType.PRIMARY: "#24232a",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#2a2930",
ColorType.LIGHT_ACCENT: "#bdbcc4",
ColorType.DARK_ACCENT: "#07060e",
},
"gray": {
ColorType.PRIMARY: "#53525a",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#5b5a62",
ColorType.LIGHT_ACCENT: "#cbcad2",
ColorType.DARK_ACCENT: "#191820",
},
"light gray": {
ColorType.PRIMARY: "#aaa9b0",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#b6b4bc",
ColorType.LIGHT_ACCENT: "#cbcad2",
ColorType.DARK_ACCENT: "#191820",
},
"white": {
ColorType.PRIMARY: "#f2f1f8",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#fefeff",
ColorType.LIGHT_ACCENT: "#ffffff",
ColorType.DARK_ACCENT: "#302f36",
},
"light pink": {
ColorType.PRIMARY: "#ff99c4",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#ffaad0",
ColorType.LIGHT_ACCENT: "#ffcbe7",
ColorType.DARK_ACCENT: "#6c2e3b",
},
"pink": {
ColorType.PRIMARY: "#ff99c4",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#ffaad0",
ColorType.LIGHT_ACCENT: "#ffcbe7",
ColorType.DARK_ACCENT: "#6c2e3b",
},
"magenta": {
ColorType.PRIMARY: "#f6466f",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#f7587f",
ColorType.LIGHT_ACCENT: "#fba4bf",
ColorType.DARK_ACCENT: "#61152f",
},
"red": {
ColorType.PRIMARY: "#e22c3c",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#b21f2d",
# ColorType.BORDER: '#e54252',
ColorType.LIGHT_ACCENT: "#f39caa",
ColorType.DARK_ACCENT: "#440d12",
},
"red orange": {
ColorType.PRIMARY: "#e83726",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#ea4b3b",
ColorType.LIGHT_ACCENT: "#f5a59d",
ColorType.DARK_ACCENT: "#61120b",
},
"salmon": {
ColorType.PRIMARY: "#f65848",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#f76c5f",
ColorType.LIGHT_ACCENT: "#fcadaa",
ColorType.DARK_ACCENT: "#6f1b16",
},
"orange": {
ColorType.PRIMARY: "#ed6022",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#ef7038",
ColorType.LIGHT_ACCENT: "#f7b79b",
ColorType.DARK_ACCENT: "#551e0a",
},
"yellow orange": {
ColorType.PRIMARY: "#fa9a2c",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#fba94b",
ColorType.LIGHT_ACCENT: "#fdd7ab",
ColorType.DARK_ACCENT: "#66330d",
},
"yellow": {
ColorType.PRIMARY: "#ffd63d",
ColorType.TEXT: ColorType.DARK_ACCENT,
# ColorType.BORDER: '#ffe071',
ColorType.BORDER: "#e8af31",
ColorType.LIGHT_ACCENT: "#fff3c4",
ColorType.DARK_ACCENT: "#754312",
},
"mint": {
ColorType.PRIMARY: "#4aed90",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#79f2b1",
ColorType.LIGHT_ACCENT: "#c8fbe9",
ColorType.DARK_ACCENT: "#164f3e",
},
"lime": {
ColorType.PRIMARY: "#92e649",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#b2ed72",
ColorType.LIGHT_ACCENT: "#e9f9b7",
ColorType.DARK_ACCENT: "#405516",
},
"light green": {
ColorType.PRIMARY: "#85ec76",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#a3f198",
ColorType.LIGHT_ACCENT: "#e7fbe4",
ColorType.DARK_ACCENT: "#2b5524",
},
"green": {
ColorType.PRIMARY: "#28bb48",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#43c568",
ColorType.LIGHT_ACCENT: "#93e2c8",
ColorType.DARK_ACCENT: "#0d3828",
},
"teal": {
ColorType.PRIMARY: "#1ad9b2",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#4de3c7",
ColorType.LIGHT_ACCENT: "#a0f3e8",
ColorType.DARK_ACCENT: "#08424b",
},
"cyan": {
ColorType.PRIMARY: "#49e4d5",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#76ebdf",
ColorType.LIGHT_ACCENT: "#bff5f0",
ColorType.DARK_ACCENT: "#0f4246",
},
"light blue": {
ColorType.PRIMARY: "#55bbf6",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#70c6f7",
ColorType.LIGHT_ACCENT: "#bbe4fb",
ColorType.DARK_ACCENT: "#122541",
},
"blue": {
ColorType.PRIMARY: "#3b87f0",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#4e95f2",
ColorType.LIGHT_ACCENT: "#aedbfa",
ColorType.DARK_ACCENT: "#122948",
},
"blue violet": {
ColorType.PRIMARY: "#5948f2",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#6258f3",
ColorType.LIGHT_ACCENT: "#9cb8fb",
ColorType.DARK_ACCENT: "#1b1649",
},
"violet": {
ColorType.PRIMARY: "#874ff5",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#9360f6",
ColorType.LIGHT_ACCENT: "#c9b0fa",
ColorType.DARK_ACCENT: "#3a1860",
},
"purple": {
ColorType.PRIMARY: "#bb4ff0",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#c364f2",
ColorType.LIGHT_ACCENT: "#dda7f7",
ColorType.DARK_ACCENT: "#531862",
},
"peach": {
ColorType.PRIMARY: "#f1c69c",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#f4d4b4",
ColorType.LIGHT_ACCENT: "#fbeee1",
ColorType.DARK_ACCENT: "#613f2f",
},
"brown": {
ColorType.PRIMARY: "#823216",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#8a3e22",
ColorType.LIGHT_ACCENT: "#cd9d83",
ColorType.DARK_ACCENT: "#3a1804",
},
"lavender": {
ColorType.PRIMARY: "#ad8eef",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#b99ef2",
ColorType.LIGHT_ACCENT: "#d5c7fa",
ColorType.DARK_ACCENT: "#492b65",
},
"blonde": {
ColorType.PRIMARY: "#efc664",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#f3d387",
ColorType.LIGHT_ACCENT: "#faebc6",
ColorType.DARK_ACCENT: "#6d461e",
},
"auburn": {
ColorType.PRIMARY: "#a13220",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#aa402f",
ColorType.LIGHT_ACCENT: "#d98a7f",
ColorType.DARK_ACCENT: "#3d100a",
},
"light brown": {
ColorType.PRIMARY: "#be5b2d",
ColorType.TEXT: ColorType.DARK_ACCENT,
ColorType.BORDER: "#c4693d",
ColorType.LIGHT_ACCENT: "#e5b38c",
ColorType.DARK_ACCENT: "#4c290e",
},
"dark brown": {
ColorType.PRIMARY: "#4c2315",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#542a1c",
ColorType.LIGHT_ACCENT: "#b78171",
ColorType.DARK_ACCENT: "#211006",
},
"cool gray": {
ColorType.PRIMARY: "#515768",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#5b6174",
ColorType.LIGHT_ACCENT: "#9ea1c3",
ColorType.DARK_ACCENT: "#181a37",
},
"warm gray": {
ColorType.PRIMARY: "#625550",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#6c5e57",
ColorType.LIGHT_ACCENT: "#c0a392",
ColorType.DARK_ACCENT: "#371d18",
},
"olive": {
ColorType.PRIMARY: "#4c652e",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#586f36",
ColorType.LIGHT_ACCENT: "#b4c17a",
ColorType.DARK_ACCENT: "#23300e",
},
"berry": {
ColorType.PRIMARY: "#9f2aa7",
ColorType.TEXT: ColorType.LIGHT_ACCENT,
ColorType.BORDER: "#aa43b4",
ColorType.LIGHT_ACCENT: "#cc8fdc",
ColorType.DARK_ACCENT: "#41114a",
},
}
def get_tag_color(type: ColorType, color: str):
def get_tag_color(type, color):
color = color.lower()
try:
if type == ColorType.TEXT:
@@ -249,4 +286,4 @@ def get_tag_color(type: ColorType, color: str):
else:
return _TAG_COLORS[color][type]
except KeyError:
return '#FF00FF'
return "#FF00FF"

View File

@@ -4,230 +4,207 @@
"""The core classes and methods of TagStudio."""
import os
from types import FunctionType
# from typing import Dict, Optional, TypedDict, List
import json
import os
from pathlib import Path
import traceback
import requests
# from bs4 import BeautifulSoup as bs
from src.core.library import *
from src.core.field_template import FieldTemplate
VERSION: str = '9.1.0' # Major.Minor.Patch
VERSION_BRANCH: str = 'Alpha' # 'Alpha', 'Beta', or '' for Full Release
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = '.TagStudio'
BACKUP_FOLDER_NAME: str = 'backups'
COLLAGE_FOLDER_NAME: str = 'collages'
LIBRARY_FILENAME: str = 'ts_library.json'
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']
VIDEO_TYPES: list[str] = ['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']
TEXT_TYPES: list[str] = ['txt', 'rtf', 'md',
'doc', 'docx', 'pdf', 'tex', 'odt', 'pages']
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']
ALL_FILE_TYPES: list[str] = IMAGE_TYPES + VIDEO_TYPES
BOX_FIELDS = ['tag_box', 'text_box']
TEXT_FIELDS = ['text_line', 'text_box']
DATE_FIELDS = ['datetime']
TAG_COLORS = ['', 'black', 'dark gray', 'gray', 'light gray', 'white', 'light pink',
'pink', 'red', 'red orange', 'orange', 'yellow orange', 'yellow',
'lime', 'light green', 'mint', 'green','teal', 'cyan', 'light blue',
'blue', 'blue violet', 'violet', 'purple', 'lavender', 'berry',
'magenta', 'salmon', 'auburn', 'dark brown', 'brown', 'light brown',
'blonde', 'peach', 'warm gray', 'cool gray', 'olive']
from src.core.library import Entry, Library
from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS
class TagStudioCore:
"""
Instantiate this to establish a TagStudio session.
Holds all TagStudio session data and provides methods to manage it.
"""
"""
Instantiate this to establish a TagStudio session.
Holds all TagStudio session data and provides methods to manage it.
"""
def __init__(self):
self.lib: Library = Library()
def __init__(self):
self.lib: Library = Library()
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
empty object if none is found.
"""
json_dump = {}
info = {}
_filepath: Path = Path(filepath)
_filepath = _filepath.parent / (_filepath.stem + ".json")
def get_gdl_sidecar(self, filepath: str, 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
empty object if none is found.
"""
json_dump = {}
info = {}
# 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 _filepath.is_file():
newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:]
_filepath = _filepath.parent / (newstem + ".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:]
try:
with open(_filepath, "r", encoding="utf8") as f:
json_dump = json.load(f)
try:
with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f:
json_dump = json.load(f)
if json_dump:
if source == "twitter":
info["content"] = json_dump["content"].strip()
info["date_published"] = json_dump["date"]
elif source == "instagram":
info["description"] = json_dump["description"].strip()
info["date_published"] = json_dump["date"]
elif source == "artstation":
info["title"] = json_dump["title"].strip()
info["artist"] = json_dump["user"]["full_name"].strip()
info["description"] = json_dump["description"].strip()
info["tags"] = json_dump["tags"]
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
info["date_published"] = json_dump["date"]
elif source == "newgrounds":
# info["title"] = json_dump["title"]
# info["artist"] = json_dump["artist"]
# info["description"] = json_dump["description"]
info["tags"] = json_dump["tags"]
info["date_published"] = json_dump["date"]
info["artist"] = json_dump["user"].strip()
info["description"] = json_dump["description"].strip()
info["source"] = json_dump["post_url"].strip()
# else:
# print(
# f'[INFO]: TagStudio does not currently support sidecar files for "{source}"')
if json_dump:
if source == "twitter":
info["content"] = json_dump["content"].strip()
info["date_published"] = json_dump["date"]
elif source == "instagram":
info["description"] = json_dump["description"].strip()
info["date_published"] = json_dump["date"]
elif source == "artstation":
info["title"] = json_dump["title"].strip()
info["artist"] = json_dump["user"]["full_name"].strip()
info["description"] = json_dump["description"].strip()
info["tags"] = json_dump["tags"]
# info["tags"] = [x for x in json_dump["mediums"]["name"]]
info["date_published"] = json_dump["date"]
elif source == "newgrounds":
# info["title"] = json_dump["title"]
# info["artist"] = json_dump["artist"]
# info["description"] = json_dump["description"]
info["tags"] = json_dump["tags"]
info["date_published"] = json_dump["date"]
info["artist"] = json_dump["user"].strip()
info["description"] = json_dump["description"].strip()
info["source"] = json_dump["post_url"].strip()
# else:
# print(
# f'[INFO]: TagStudio does not currently support sidecar files for "{source}"')
# except FileNotFoundError:
except:
# print(
# f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"')
pass
# except FileNotFoundError:
except:
# print(
# f'[INFO]: No sidecar file found at "{os.path.normpath(file_path + ".json")}"')
pass
return info
return info
# def scrape(self, entry_id):
# entry = self.lib.get_entry(entry_id)
# if entry.fields:
# urls: list[str] = []
# if self.lib.get_field_index_in_entry(entry, 21):
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
# for x in self.lib.get_field_index_in_entry(entry, 21)])
# if self.lib.get_field_index_in_entry(entry, 3):
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
# for x in self.lib.get_field_index_in_entry(entry, 3)])
# # try:
# if urls:
# for url in urls:
# url = "https://" + url if 'https://' not in url else url
# html_doc = requests.get(url).text
# soup = bs(html_doc, "html.parser")
# print(soup)
# input()
# def scrape(self, entry_id):
# entry = self.lib.get_entry(entry_id)
# if entry.fields:
# urls: list[str] = []
# if self.lib.get_field_index_in_entry(entry, 21):
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
# for x in self.lib.get_field_index_in_entry(entry, 21)])
# if self.lib.get_field_index_in_entry(entry, 3):
# urls.extend([self.lib.get_field_attr(entry.fields[x], 'content')
# for x in self.lib.get_field_index_in_entry(entry, 3)])
# # try:
# if urls:
# for url in urls:
# url = "https://" + url if 'https://' not in url else url
# html_doc = requests.get(url).text
# soup = bs(html_doc, "html.parser")
# print(soup)
# input()
# # except:
# # # print("Could not resolve URL.")
# # pass
# # except:
# # # print("Could not resolve URL.")
# # pass
def match_conditions(self, entry_id: int) -> None:
"""Matches defined conditions against a file to add Entry data."""
def match_conditions(self, entry_id: int) -> str:
"""Matches defined conditions against a file to add Entry data."""
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 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 str(Path(path_c).resolve()) in str(entry.path):
match = True
break
if match:
if fields := c.get("fields"):
for field in fields:
field_id = self.lib.get_field_attr(field, "id")
content = field[field_id]
cond_file = os.path.normpath(f'{self.lib.library_dir}/{TS_FOLDER_NAME}/conditions.json')
# TODO: Make this stored somewhere better instead of temporarily in this JSON file.
json_dump = {}
entry: Entry = self.lib.get_entry(entry_id)
try:
if os.path.isfile(cond_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:
match = True
break
if match:
if 'fields' in c.keys() and c['fields']:
for field in c['fields']:
if (
self.lib.get_field_obj(int(field_id))["type"]
== "tag_box"
):
existing_fields: list[int] = (
self.lib.get_field_index_in_entry(
entry, field_id
)
)
if existing_fields:
self.lib.update_entry_field(
entry_id,
existing_fields[0],
content,
"append",
)
else:
self.lib.add_field_to_entry(
entry_id, field_id
)
self.lib.update_entry_field(
entry_id, -1, content, "append"
)
field_id = self.lib.get_field_attr(
field, 'id')
content = field[field_id]
if (
self.lib.get_field_obj(int(field_id))["type"]
in TEXT_FIELDS
):
if not self.lib.does_field_content_exist(
entry_id, field_id, content
):
self.lib.add_field_to_entry(
entry_id, field_id
)
self.lib.update_entry_field(
entry_id, -1, content, "replace"
)
except:
print("Error in match_conditions...")
# input()
pass
if self.lib.get_field_obj(int(field_id))['type'] == 'tag_box':
existing_fields: list[int] = self.lib.get_field_index_in_entry(
entry, field_id)
if existing_fields:
self.lib.update_entry_field(
entry_id, existing_fields[0], content, 'append')
else:
self.lib.add_field_to_entry(
entry_id, field_id)
self.lib.update_entry_field(
entry_id, -1, content, 'append')
def build_url(self, entry_id: int, source: str):
"""Tries to rebuild a source URL given a specific filename structure."""
if self.lib.get_field_obj(int(field_id))['type'] in TEXT_FIELDS:
if not self.lib.does_field_content_exist(entry_id, field_id, content):
self.lib.add_field_to_entry(
entry_id, field_id)
self.lib.update_entry_field(
entry_id, -1, content, 'replace')
except:
print('Error in match_conditions...')
# input()
pass
source = source.lower().replace("-", " ").replace("_", " ")
if "twitter" in source:
return self._build_twitter_url(entry_id)
elif "instagram" in source:
return self._build_instagram_url(entry_id)
def build_url(self, entry_id: int, source: str) -> str:
"""Tries to rebuild a source URL given a specific filename structure."""
def _build_twitter_url(self, entry_id: int):
"""
Builds an Twitter URL given a specific filename structure.
Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD'
"""
try:
entry = self.lib.get_entry(entry_id)
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]}"
return url
except:
return ""
source = source.lower().replace('-', ' ').replace('_', ' ')
if 'twitter' in source:
return self._build_twitter_url(entry_id)
elif 'instagram' in source:
return self._build_instagram_url(entry_id)
def _build_twitter_url(self, entry_id: int):
"""
Builds an Twitter URL given a specific filename structure.
Method expects filename to be formatted as 'USERNAME_TWEET-ID_INDEX_YEAR-MM-DD'
"""
try:
entry = self.lib.get_entry(entry_id)
stubs = 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]}"
return url
except:
return ''
def _build_instagram_url(self, entry_id: int):
"""
Builds an Instagram URL given a specific filename structure.
Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD'
"""
try:
entry = self.lib.get_entry(entry_id)
stubs = 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,
# so unless you have the exact username (which can change) on hand to remove,
# your other best bet is to hope that the ID is only 11 characters long, which
# seems to more or less be the case... for now...
url = f"www.instagram.com/p/{stubs[-3][-11:]}"
return url
except:
return ''
def _build_instagram_url(self, entry_id: int):
"""
Builds an Instagram URL given a specific filename structure.
Method expects filename to be formatted as 'USERNAME_POST-ID_INDEX_YEAR-MM-DD'
"""
try:
entry = self.lib.get_entry(entry_id)
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,
# so unless you have the exact username (which can change) on hand to remove,
# your other best bet is to hope that the ID is only 11 characters long, which
# seems to more or less be the case... for now...
url = f"www.instagram.com/p/{stubs[-3][-11:]}"
return url
except:
return ""

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

@@ -2,12 +2,10 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import os
def clean_folder_name(folder_name: str) -> str:
cleaned_name = folder_name
invalid_chars = "<>:\"/\\|?*."
invalid_chars = '<>:"/\\|?*.'
for char in invalid_chars:
cleaned_name = cleaned_name.replace(char, '_')
cleaned_name = cleaned_name.replace(char, "_")
return cleaned_name

View File

@@ -2,10 +2,25 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
def strip_punctuation(string: str) -> str:
"""Returns a given string stripped of all punctuation characters."""
return string.replace('(', '').replace(')', '').replace('[', '') \
.replace(']', '').replace('{', '').replace('}', '').replace("'", '') \
.replace('`', '').replace('', '').replace('', '').replace('"', '') \
.replace('', '').replace('', '').replace('_', '').replace('-', '') \
.replace(' ', '').replace(' ', '')
return (
string.replace("(", "")
.replace(")", "")
.replace("[", "")
.replace("]", "")
.replace("{", "")
.replace("}", "")
.replace("'", "")
.replace("`", "")
.replace("", "")
.replace("", "")
.replace('"', "")
.replace("", "")
.replace("", "")
.replace("_", "")
.replace("-", "")
.replace(" ", "")
.replace(" ", "")
)

View File

@@ -2,11 +2,10 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
def strip_web_protocol(string: str) -> str:
"""Strips a leading web protocol (ex. \"https://\") as well as \"www.\" from a string."""
new_str = string
new_str = new_str.removeprefix('https://')
new_str = new_str.removeprefix('http://')
new_str = new_str.removeprefix('www.')
new_str = new_str.removeprefix('www2.')
return new_str
prefixes = ["https://", "http://", "www.", "www2."]
for prefix in prefixes:
string = string.removeprefix(prefix)
return string

View File

@@ -4,9 +4,8 @@
"""PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x"""
import sys
from PySide6.QtCore import Qt, QMargins, QPoint, QRect, QSize
from PySide6.QtWidgets import QApplication, QLayout, QPushButton, QSizePolicy, QWidget
from PySide6.QtWidgets import QLayout, QSizePolicy, QWidget
# class Window(QWidget):
@@ -22,143 +21,153 @@ from PySide6.QtWidgets import QApplication, QLayout, QPushButton, QSizePolicy, Q
# self.setWindowTitle("Flow Layout")
class FlowWidget(QWidget):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ignore_size: bool = False
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.ignore_size: bool = False
class FlowLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None):
super().__init__(parent)
if parent is not None:
self.setContentsMargins(QMargins(0, 0, 0, 0))
if parent is not None:
self.setContentsMargins(QMargins(0, 0, 0, 0))
self._item_list = []
self.grid_efficiency = False
self._item_list = []
self.grid_efficiency = False
def __del__(self):
item = self.takeAt(0)
while item:
item = self.takeAt(0)
def __del__(self):
item = self.takeAt(0)
while item:
item = self.takeAt(0)
def addItem(self, item):
self._item_list.append(item)
def addItem(self, item):
self._item_list.append(item)
def count(self):
return len(self._item_list)
def count(self):
return len(self._item_list)
def itemAt(self, index):
if 0 <= index < len(self._item_list):
return self._item_list[index]
def itemAt(self, index):
if 0 <= index < len(self._item_list):
return self._item_list[index]
return None
return None
def takeAt(self, index):
if 0 <= index < len(self._item_list):
return self._item_list.pop(index)
def takeAt(self, index):
if 0 <= index < len(self._item_list):
return self._item_list.pop(index)
return None
return None
def expandingDirections(self):
return Qt.Orientation(0)
def expandingDirections(self):
return Qt.Orientation(0)
def hasHeightForWidth(self):
return True
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
height = self._do_layout(QRect(0, 0, width, 0), True)
return height
def heightForWidth(self, width):
height = self._do_layout(QRect(0, 0, width, 0), True)
return height
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
self._do_layout(rect, False)
def setGridEfficiency(self, bool):
"""
Enables or Disables efficiencies when all objects are equally sized.
"""
self.grid_efficiency = bool
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
self._do_layout(rect, False)
def sizeHint(self):
return self.minimumSize()
def setGridEfficiency(self, bool):
"""
Enables or Disables efficiencies when all objects are equally sized.
"""
self.grid_efficiency = bool
def minimumSize(self):
if self.grid_efficiency:
if self._item_list:
return self._item_list[0].minimumSize()
else:
return QSize()
else:
size = QSize()
def sizeHint(self):
return self.minimumSize()
for item in self._item_list:
size = size.expandedTo(item.minimumSize())
def minimumSize(self):
if self.grid_efficiency:
if self._item_list:
return self._item_list[0].minimumSize()
else:
return QSize()
else:
size = QSize()
size += QSize(2 * self.contentsMargins().top(), 2 * self.contentsMargins().top())
return size
for item in self._item_list:
size = size.expandedTo(item.minimumSize())
def _do_layout(self, rect, test_only):
x = rect.x()
y = rect.y()
line_height = 0
spacing = self.spacing()
item = None
style = None
layout_spacing_x = None
layout_spacing_y = None
size += QSize(
2 * self.contentsMargins().top(), 2 * self.contentsMargins().top()
)
return size
if self.grid_efficiency:
if self._item_list:
item = self._item_list[0]
style = item.widget().style()
layout_spacing_x = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
layout_spacing_y = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
for i, item in enumerate(self._item_list):
# print(issubclass(type(item.widget()), FlowWidget))
# print(item.widget().ignore_size)
skip_count = 0
if (issubclass(type(item.widget()), FlowWidget) and item.widget().ignore_size):
skip_count += 1
def _do_layout(self, rect, test_only):
x = rect.x()
y = rect.y()
line_height = 0
spacing = self.spacing()
item = None
style = None
layout_spacing_x = None
layout_spacing_y = None
if (issubclass(type(item.widget()), FlowWidget) and not item.widget().ignore_size) or (not issubclass(type(item.widget()), FlowWidget)):
# print(f'Item {i}')
if not self.grid_efficiency:
style = item.widget().style()
layout_spacing_x = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
layout_spacing_y = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
space_x = spacing + layout_spacing_x
space_y = spacing + layout_spacing_y
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > rect.right() and line_height > 0:
x = rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
if self.grid_efficiency:
if self._item_list:
item = self._item_list[0]
style = item.widget().style()
layout_spacing_x = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
layout_spacing_y = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
for i, item in enumerate(self._item_list):
# print(issubclass(type(item.widget()), FlowWidget))
# print(item.widget().ignore_size)
skip_count = 0
if (
issubclass(type(item.widget()), FlowWidget)
and item.widget().ignore_size
):
skip_count += 1
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
if (
issubclass(type(item.widget()), FlowWidget)
and not item.widget().ignore_size
) or (not issubclass(type(item.widget()), FlowWidget)):
# print(f'Item {i}')
if not self.grid_efficiency:
style = item.widget().style()
layout_spacing_x = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
layout_spacing_y = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
space_x = spacing + layout_spacing_x
space_y = spacing + layout_spacing_y
next_x = x + item.sizeHint().width() + space_x
if next_x - space_x > rect.right() and line_height > 0:
x = rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
x = next_x
line_height = max(line_height, item.sizeHint().height())
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
# print(y + line_height - rect.y() * ((len(self._item_list) - skip_count) / len(self._item_list)))
# print(y + line_height - rect.y()) * ((len(self._item_list) - skip_count) / len(self._item_list))
return y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list))
x = next_x
line_height = max(line_height, item.sizeHint().height())
# print(y + line_height - rect.y() * ((len(self._item_list) - skip_count) / len(self._item_list)))
# print(y + line_height - rect.y()) * ((len(self._item_list) - skip_count) / len(self._item_list))
return (
y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list))
)
# if __name__ == "__main__":
# app = QApplication(sys.argv)
# main_win = Window()
# main_win.show()
# sys.exit(app.exec())
# sys.exit(app.exec())

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

@@ -0,0 +1,19 @@
# 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, QRunnable, QObject
class CustomRunnable(QRunnable, QObject):
done = Signal()
def __init__(self, function) -> None:
QRunnable.__init__(self)
QObject.__init__(self)
self.function = function
def run(self):
self.function()
self.done.emit()

View File

@@ -0,0 +1,152 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import subprocess
import shutil
import sys
import traceback
from pathlib import Path
from PySide6.QtWidgets import QLabel
from PySide6.QtCore import Qt
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
def open_file(path: str | Path, file_manager: bool = False):
"""Open a file in the default application or file explorer.
Args:
path (str): The path to the file to open.
file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS).
Defaults to False.
"""
_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)
if file_manager:
command_name = "explorer"
command_args = '/select,"' + normpath + '"'
# For some reason, if the args are passed in a list, this will error when the path has spaces, even while surrounded in double quotes
subprocess.Popen(
command_name + command_args,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
command_name = "start"
# first parameter is for title, NOT filepath
command_args = ["", normpath]
subprocess.Popen(
[command_name] + command_args,
shell=True,
close_fds=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.CREATE_BREAKAWAY_FROM_JOB,
)
else:
if sys.platform == "darwin":
command_name = "open"
command_args = [_path]
if file_manager:
# will reveal in Finder
command_args.append("-R")
else:
if file_manager:
command_name = "dbus-send"
# might not be guaranteed to launch default?
command_args = [
"--session",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:file://{_path}",
"string:",
]
else:
command_name = "xdg-open"
command_args = [_path]
command = shutil.which(command_name)
if command is not None:
subprocess.Popen([command] + command_args, close_fds=True)
else:
logging.info(f"Could not find {command_name} on system PATH")
except:
traceback.print_exc()
class FileOpenerHelper:
def __init__(self, filepath: str | Path):
"""Initialize the FileOpenerHelper.
Args:
filepath (str): The path to the file to open.
"""
self.filepath = str(filepath)
def set_filepath(self, filepath: str | Path):
"""Set the filepath to open.
Args:
filepath (str): The path to the file to open.
"""
self.filepath = str(filepath)
def open_file(self):
"""Open the file in the default application."""
open_file(self.filepath)
def open_explorer(self):
"""Open the file in the default file explorer."""
open_file(self.filepath, file_manager=True)
class FileOpenerLabel(QLabel):
def __init__(self, text, parent=None):
"""Initialize the FileOpenerLabel.
Args:
text (str): The text to display.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(text, parent)
def setFilePath(self, filepath):
"""Set the filepath to open.
Args:
filepath (str): The path to the file to open.
"""
self.filepath = filepath
def mousePressEvent(self, event):
"""Handle mouse press events.
On a left click, open the file in the default file explorer. On a right click, show a context menu.
Args:
event (QMouseEvent): The mouse press event.
"""
super().mousePressEvent(event)
if event.button() == Qt.LeftButton:
opener = FileOpenerHelper(self.filepath)
opener.open_explorer()
elif event.button() == Qt.RightButton:
# Show context menu
pass

View File

@@ -0,0 +1,21 @@
# 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, QObject
from typing import Callable
class FunctionIterator(QObject):
"""Iterates over a yielding function and emits progress as the 'value' signal.\n\nThread-Safe Guarantee™"""
value = Signal(object)
def __init__(self, function: Callable):
super().__init__()
self.iterable = function
def run(self):
for i in self.iterable():
self.value.emit(i)

View File

@@ -0,0 +1,65 @@
# 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, ImageEnhance, ImageChops
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))
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0] - 1), 0))
bl = image.getpixel((0, (image.size[1] - 1)))
br = image.getpixel(((image.size[0] - 1), (image.size[1] - 1)))
bg = Image.new(mode="RGB", size=(2, 2))
bg.paste(tl, (0, 0, 2, 2))
bg.paste(tr, (1, 0, 2, 2))
bg.paste(bl, (0, 1, 2, 2))
bg.paste(br, (1, 1, 2, 2))
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
bg.paste(
image,
box=(
(adj_size - image.size[0]) // 2,
(adj_size - image.size[1]) // 2,
),
)
bg.putalpha(mask)
final = bg
else:
image.putalpha(mask)
final = image
hl_soft = hl.copy()
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

@@ -12,265 +12,227 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from re import S
import time
from typing import Optional
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform, QAction)
from PySide6.QtWidgets import (QApplication, QComboBox, QFrame, QGridLayout,
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget, QSplitter, QMenu)
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, QCheckBox,
QSpacerItem)
from src.qt.pagination import Pagination
# from src.qt.qtacrylic.qtacrylic import WindowEffect
# from qframelesswindow import FramelessMainWindow, StandardTitleBar
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")
# 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)
def setupUi(self, MainWindow):
if not MainWindow.objectName():
MainWindow.setObjectName(u"MainWindow")
MainWindow.resize(1300, 720)
self.splitter = QSplitter()
self.splitter.setObjectName(u"splitter")
self.splitter.setHandleWidth(12)
# self._createMenuBar(MainWindow)
print(type(MainWindow))
self.centralwidget = QWidget(MainWindow)
self.centralwidget.setObjectName(u"centralwidget")
self.gridLayout = QGridLayout(self.centralwidget)
self.gridLayout.setObjectName(u"gridLayout")
# self.gridLayout.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.frame_container = QWidget()
self.frame_layout = QVBoxLayout(self.frame_container)
self.frame_layout.setSpacing(0)
# tb = StandardTitleBar(MainWindow)
# tb.setObjectName('TitleBar')
# # # self.setTitleBar(tb)
# hor = QVBoxLayout()
# self.gridLayout.setContentsMargins(0,0,0,0)
# self.gridLayout.addLayout(hor, 0, 0, 1, 1)
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)
# hor.addWidget(tb)
self.pagination = Pagination()
self.frame_layout.addWidget(self.pagination)
self.splitter = QSplitter()
self.splitter.setObjectName(u"splitter")
self.splitter.setHandleWidth(12)
self.horizontalLayout.addWidget(self.splitter)
self.splitter.addWidget(self.frame_container)
self.splitter.setStretchFactor(0, 1)
self.frame_container = QWidget()
# self.frame_container.setStyleSheet('background:red;')
self.frame_layout = QVBoxLayout(self.frame_container)
# self.frame_container.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.frame_layout.setSpacing(0)
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 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.scrollArea = QScrollArea()
# self.scrollArea.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# self.scrollArea.setStyleSheet('background:green;')
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.horizontalLayout_2.addWidget(self.backButton)
self.scrollArea.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
# self.scrollArea.setWindowFlag(Qt.WindowType.FramelessWindowHint)
self.scrollArea.setAttribute(
Qt.WidgetAttribute.WA_TranslucentBackground)
self.scrollArea.setStyleSheet('background:#00000000;')
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.page_bar_controls = QWidget()
# self.page_bar_controls.setStyleSheet('background:blue;')
# self.page_bar_controls.setMinimumHeight(32)
self.horizontalLayout_2.addWidget(self.forwardButton)
self.pagination = Pagination()
self.frame_layout.addWidget(self.pagination)
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.frame_layout.addWidget(self.page_bar_controls)
# self.frame_layout.addWidget(self.page_bar_controls)
self.horizontalLayout_2.addWidget(self.searchField)
# self.horizontalLayout.addWidget(self.scrollArea)
self.horizontalLayout.addWidget(self.splitter)
self.splitter.addWidget(self.frame_container)
self.splitter.setStretchFactor(0, 1)
self.searchButton = QPushButton(self.centralwidget)
self.searchButton.setObjectName(u"searchButton")
self.searchButton.setMinimumSize(QSize(0, 32))
self.searchButton.setFont(font2)
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 1)
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.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)
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.backButton)
self.retranslateUi(MainWindow)
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)
QMetaObject.connectSlotsByName(MainWindow)
# setupUi
self.horizontalLayout_2.addWidget(self.forwardButton)
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.searchField = QLineEdit(self.centralwidget)
self.searchField.setObjectName(u"searchField")
self.searchField.setMinimumSize(QSize(0, 32))
self.searchField.setStyleSheet(
'background:#55000000;'
'border-radius:6px;'
'border-style:solid;'
'border-width:1px;'
'border-color:#11FFFFFF;'
)
font2 = QFont()
font2.setPointSize(11)
font2.setBold(False)
self.searchField.setFont(font2)
def moveEvent(self, event) -> None:
# time.sleep(0.02) # sleep for 20ms
pass
self.horizontalLayout_2.addWidget(self.searchField)
def resizeEvent(self, event) -> None:
# time.sleep(0.02) # sleep for 20ms
pass
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.searchButton)
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.setMinimumWidth(128)
self.comboBox.setMaximumWidth(128)
self.gridLayout.addWidget(self.comboBox, 4, 0, 1, 1, Qt.AlignRight)
self.gridLayout_2.setContentsMargins(6, 6, 6, 6)
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)
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)
# self.dumpObjectTree()
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

@@ -0,0 +1,90 @@
# 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, Qt
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QComboBox,
)
from src.core.library import Library
class AddFieldModal(QWidget):
done = Signal(int)
def __init__(self, library: "Library"):
# [Done]
# - OR -
# [Cancel] [Save]
super().__init__()
self.lib = library
self.setWindowTitle(f"Add Field")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(400, 300)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel()
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
"font-weight:bold;" "font-size:14px;" "padding-top: 6px" ""
)
self.title_widget.setText("Add Field")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
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)
self.button_layout.addStretch(1)
# self.cancel_button = QPushButton()
# self.cancel_button.setText('Cancel')
self.cancel_button = QPushButton()
self.cancel_button.setText("Cancel")
self.cancel_button.clicked.connect(self.hide)
# self.cancel_button.clicked.connect(widget.reset)
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton()
self.save_button.setText("Add")
# self.save_button.setAutoDefault(True)
self.save_button.setDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_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.save_button)
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
# self.done.connect(lambda x: callback(x))
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.combo_box)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)

View File

@@ -0,0 +1,245 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QLabel,
QPushButton,
QLineEdit,
QScrollArea,
QFrame,
QTextEdit,
QComboBox,
)
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.core.constants import TAG_COLORS
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.qt.modals.tag_search import TagSearchPanel
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class BuildTagPanel(PanelWidget):
on_edit = Signal(Tag)
def __init__(self, library, tag_id: int = -1):
super().__init__()
self.lib: Library = library
# self.callback = callback
# self.tag_id = tag_id
self.tag = None
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Name -----------------------------------------------------------------
self.name_widget = QWidget()
self.name_layout = QVBoxLayout(self.name_widget)
self.name_layout.setStretch(1, 1)
self.name_layout.setContentsMargins(0, 0, 0, 0)
self.name_layout.setSpacing(0)
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.name_title = QLabel()
self.name_title.setText("Name")
self.name_layout.addWidget(self.name_title)
self.name_field = QLineEdit()
self.name_layout.addWidget(self.name_field)
# Shorthand ------------------------------------------------------------
self.shorthand_widget = QWidget()
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
self.shorthand_layout.setStretch(1, 1)
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
self.shorthand_layout.setSpacing(0)
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.shorthand_title = QLabel()
self.shorthand_title.setText("Shorthand")
self.shorthand_layout.addWidget(self.shorthand_title)
self.shorthand_field = QLineEdit()
self.shorthand_layout.addWidget(self.shorthand_field)
# Aliases --------------------------------------------------------------
self.aliases_widget = QWidget()
self.aliases_layout = QVBoxLayout(self.aliases_widget)
self.aliases_layout.setStretch(1, 1)
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
self.aliases_layout.setSpacing(0)
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.aliases_title = QLabel()
self.aliases_title.setText("Aliases")
self.aliases_layout.addWidget(self.aliases_title)
self.aliases_field = QTextEdit()
self.aliases_field.setAcceptRichText(False)
self.aliases_field.setMinimumHeight(40)
self.aliases_layout.addWidget(self.aliases_field)
# Subtags ------------------------------------------------------------
self.subtags_widget = QWidget()
self.subtags_layout = QVBoxLayout(self.subtags_widget)
self.subtags_layout.setStretch(1, 1)
self.subtags_layout.setContentsMargins(0, 0, 0, 0)
self.subtags_layout.setSpacing(0)
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.subtags_title = QLabel()
self.subtags_title.setText("Parent Tags")
self.subtags_layout.addWidget(self.subtags_title)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
# self.scroll_area.setMinimumHeight(60)
self.subtags_layout.addWidget(self.scroll_area)
self.subtags_add_button = QPushButton()
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 Parent Tags", "Add Parent Tags")
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
self.subtags_layout.addWidget(self.subtags_add_button)
# self.subtags_field = TagBoxWidget()
# self.subtags_field.setMinimumHeight(60)
# self.subtags_layout.addWidget(self.subtags_field)
# Shorthand ------------------------------------------------------------
self.color_widget = QWidget()
self.color_layout = QVBoxLayout(self.color_widget)
self.color_layout.setStretch(1, 1)
self.color_layout.setContentsMargins(0, 0, 0, 0)
self.color_layout.setSpacing(0)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel()
self.color_title.setText("Color")
self.color_layout.addWidget(self.color_title)
self.color_field = QComboBox()
self.color_field.setEditable(False)
self.color_field.setMaxVisibleItems(10)
self.color_field.setStyleSheet("combobox-popup:0;")
for color in TAG_COLORS:
self.color_field.addItem(color.title())
# self.color_field.setProperty("appearance", "flat")
self.color_field.currentTextChanged.connect(
lambda c: self.color_field.setStyleSheet(f"""combobox-popup:0;
font-weight:600;
color:{get_tag_color(ColorType.TEXT, c.lower())};
background-color:{get_tag_color(ColorType.PRIMARY, c.lower())};
""")
)
self.color_layout.addWidget(self.color_field)
# Add Widgets to Layout ================================================
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.shorthand_widget)
self.root_layout.addWidget(self.aliases_widget)
self.root_layout.addWidget(self.subtags_widget)
self.root_layout.addWidget(self.color_widget)
# self.parent().done.connect(self.update_tag)
if tag_id >= 0:
self.tag = self.lib.get_tag(tag_id)
else:
self.tag = Tag(-1, "New Tag", "", [], [], "")
self.set_tag(self.tag)
def add_subtag_callback(self, tag_id: int):
logging.info(f"adding {tag_id}")
# tag = self.lib.get_tag(self.tag_id)
# TODO: Create a single way to update tags and refresh library data
# new = self.build_tag()
self.tag.add_subtag(tag_id)
# self.tag = new
# self.lib.update_tag(new)
self.set_subtags()
# self.on_edit.emit(self.build_tag())
def remove_subtag_callback(self, tag_id: int):
logging.info(f"removing {tag_id}")
# tag = self.lib.get_tag(self.tag_id)
# TODO: Create a single way to update tags and refresh library data
# new = self.build_tag()
self.tag.remove_subtag(tag_id)
# self.tag = new
# self.lib.update_tag(new)
self.set_subtags()
# self.on_edit.emit(self.build_tag())
def set_subtags(self):
while self.scroll_layout.itemAt(0):
self.scroll_layout.takeAt(0).widget().deleteLater()
logging.info(f"Setting {self.tag.subtag_ids}")
c = QWidget()
l = QVBoxLayout(c)
l.setContentsMargins(0, 0, 0, 0)
l.setSpacing(3)
for tag_id in self.tag.subtag_ids:
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, True)
tw.on_remove.connect(
lambda checked=False, t=tag_id: self.remove_subtag_callback(t)
)
l.addWidget(tw)
self.scroll_layout.addWidget(c)
def set_tag(self, tag: Tag):
# tag = self.lib.get_tag(tag_id)
self.name_field.setText(tag.name)
self.shorthand_field.setText(tag.shorthand)
self.aliases_field.setText("\n".join(tag.aliases))
self.set_subtags()
self.color_field.setCurrentIndex(TAG_COLORS.index(tag.color.lower()))
# self.tag_id = tag.id
def build_tag(self) -> Tag:
# tag: Tag = self.tag
# if self.tag_id >= 0:
# tag = self.lib.get_tag(self.tag_id)
# else:
# tag = Tag(-1, '', '', [], [], '')
new_tag: Tag = Tag(
id=self.tag.id,
name=self.name_field.text(),
shorthand=self.shorthand_field.text(),
aliases=self.aliases_field.toPlainText().split("\n"),
subtags_ids=self.tag.subtag_ids,
color=self.color_field.currentText().lower(),
)
logging.info(f"built {new_tag}")
return new_tag
# NOTE: The callback and signal do the same thing, I'm currently
# transitioning from using callbacks to the Qt method of using signals.
# self.tag_updated.emit(new_tag)
# self.callback(new_tag)
# def on_return(self, callback, text:str):
# if text and self.first_tag_id >= 0:
# callback(self.first_tag_id)
# self.search_field.setText('')
# self.update_tags('')
# else:
# self.search_field.setFocus()
# self.parentWidget().hide()

View File

@@ -0,0 +1,107 @@
# 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 Signal, Qt, QThreadPool
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QListView,
)
from src.core.library import ItemType, Library
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
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 DeleteUnlinkedEntriesModal(QWidget):
done = Signal()
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.setWindowTitle("Delete Unlinked Entries")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
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.setText(f"""
Are you sure you want to delete the following {len(self.lib.missing_files)} entries?
""")
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.cancel_button = QPushButton()
self.cancel_button.setText("&Cancel")
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.delete_button = QPushButton()
self.delete_button.setText("&Delete")
self.delete_button.clicked.connect(self.hide)
self.delete_button.clicked.connect(lambda: self.delete_entries())
self.button_layout.addWidget(self.delete_button)
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)
def refresh_list(self):
self.desc_widget.setText(f"""
Are you sure you want to delete the following {len(self.lib.missing_files)} entries?
""")
self.model.clear()
for i in self.lib.missing_files:
self.model.appendRow(QStandardItem(str(i)))
def delete_entries(self):
iterator = FunctionIterator(self.lib.remove_missing_files)
pw = ProgressWidget(
window_title="Deleting Entries",
label_text="",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.missing_files),
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
iterator.value.connect(
lambda x: pw.update_label(
f"Deleting {x[0]+1}/{len(self.lib.missing_files)} Unlinked Entries"
)
)
iterator.value.connect(
lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1])
)
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))

View File

@@ -0,0 +1,113 @@
# 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, Qt
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("File Extensions")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(240, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
# 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.ext_list):
self.table.setItem(i, 0, QTableWidgetItem(ext))
def add_item(self):
self.table.insertRow(self.table.rowCount())
def save(self):
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.ext_list.append(ext.text())

View File

@@ -0,0 +1,171 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import os
import typing
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QFileDialog,
)
from src.core.library import Library
from src.qt.modals.mirror_entities import MirrorEntriesModal
# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class FixDupeFilesModal(QWidget):
# done = Signal(int)
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.count = -1
self.filename = ""
self.setWindowTitle(f"Fix Duplicate Files")
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.desc_widget.setText(
"""TagStudio supports importing DupeGuru results to manage duplicate files."""
)
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.dupe_count = QLabel()
self.dupe_count.setObjectName("dupeCountLabel")
self.dupe_count.setStyleSheet(
# 'background:blue;'
# 'text-align:center;'
"font-weight:bold;"
"font-size:14px;"
# 'padding-top: 6px'
""
)
self.dupe_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label = QLabel()
self.file_label.setObjectName("fileLabel")
# self.file_label.setStyleSheet(
# # 'background:blue;'
# # 'text-align:center;'
# 'font-weight:bold;'
# 'font-size:14px;'
# # 'padding-top: 6px'
# '')
# self.file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.file_label.setText("No DupeGuru File Selected")
self.open_button = QPushButton()
self.open_button.setText("&Load DupeGuru File")
self.open_button.clicked.connect(lambda: self.select_file())
self.mirror_button = QPushButton()
self.mirror_modal = MirrorEntriesModal(self.lib, self.driver)
self.mirror_modal.done.connect(lambda: self.refresh_dupes())
self.mirror_button.setText("&Mirror Entries")
self.mirror_button.clicked.connect(lambda: self.mirror_modal.show())
self.mirror_desc = QLabel()
self.mirror_desc.setWordWrap(True)
self.mirror_desc.setText(
"""Mirror the Entry data across each duplicate match set, combining all data while not removing or duplicating fields. This operation will not delete any files or data."""
)
# self.mirror_delete_button = QPushButton()
# self.mirror_delete_button.setText('Mirror && Delete')
self.advice_label = QLabel()
self.advice_label.setWordWrap(True)
self.advice_label.setText(
"""After mirroring, you're free to use DupeGuru to delete the unwanted files. Afterwards, use TagStudio's "Fix Unlinked Entries" feature in the Tools menu in order to delete the unlinked Entries."""
)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
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.dupe_count)
self.root_layout.addWidget(self.file_label)
self.root_layout.addWidget(self.open_button)
# self.mirror_delete_button.setHidden(True)
self.root_layout.addWidget(self.mirror_button)
self.root_layout.addWidget(self.mirror_desc)
# self.root_layout.addWidget(self.mirror_delete_button)
self.root_layout.addWidget(self.advice_label)
# self.root_layout.setStretch(1,2)
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.button_container)
self.set_dupe_count(self.count)
def select_file(self):
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_():
filename = qfd.selectedFiles()
if filename:
self.set_filename(filename[0])
def set_filename(self, filename: str):
if filename:
self.file_label.setText(filename)
else:
self.file_label.setText("No DupeGuru File Selected")
self.filename = filename
self.refresh_dupes()
self.mirror_modal.refresh_list()
def refresh_dupes(self):
self.lib.refresh_dupe_files(self.filename)
self.set_dupe_count(len(self.lib.dupe_files))
def set_dupe_count(self, count: int):
self.count = count
if self.count < 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(f"Duplicate File Matches: N/A")
elif self.count == 0:
self.mirror_button.setDisabled(True)
self.dupe_count.setText(f"Duplicate File Matches: {count}")
else:
self.mirror_button.setDisabled(False)
self.dupe_count.setText(f"Duplicate File Matches: {count}")

View File

@@ -0,0 +1,230 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import typing
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
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.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.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class FixUnlinkedEntriesModal(QWidget):
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
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.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.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_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.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")
self.delete_button = QPushButton()
self.delete_modal = DeleteUnlinkedEntriesModal(self.lib, self.driver)
self.delete_modal.done.connect(
lambda: self.set_missing_count(len(self.lib.missing_files))
)
self.delete_modal.done.connect(lambda: self.driver.update_thumbs())
self.delete_button.setText("De&lete Unlinked Entries")
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.done_button = QPushButton()
self.done_button.setText("&Done")
self.done_button.setDefault(True)
self.done_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.done_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.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.missing_count)
self.set_dupe_count(self.dupe_count)
def refresh_missing_files(self):
iterator = FunctionIterator(self.lib.refresh_missing_files)
pw = ProgressWidget(
window_title="Scanning Library",
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))
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.set_missing_count(len(self.lib.missing_files)),
self.delete_modal.refresh_list(),
self.refresh_dupe_entries(),
)
)
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 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.missing_count = count
if self.missing_count < 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
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_label.setText(f"Unlinked Entries: {count}")
else:
self.search_button.setDisabled(False)
self.delete_button.setDisabled(False)
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

@@ -0,0 +1,357 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import math
import typing
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QFrame,
)
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.qt.flowlayout import FlowLayout
# 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]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
def folders_to_tags(library: Library):
logging.info("Converting folders to Tags")
tree: dict = dict(dirs={})
def add_tag_to_tree(items: list[Tag]):
branch = tree
for tag in items:
if tag.name not in branch["dirs"]:
branch["dirs"][tag.name] = dict(dirs={}, tag=tag)
branch = branch["dirs"][tag.name]
def add_folders_to_tree(items: list[str]) -> Tag:
branch: dict = tree
for folder in items:
if folder not in branch["dirs"]:
new_tag = Tag(
-1,
folder,
"",
[],
([branch["tag"].id] if "tag" in branch else []),
"",
)
library.add_tag_to_library(new_tag)
branch["dirs"][folder] = dict(dirs={}, tag=new_tag)
branch = branch["dirs"][folder]
return branch["tag"]
for tag in library.tags:
reversed_tag = reverse_tag(library, tag, None)
add_tag_to_tree(reversed_tag)
for entry in library.entries:
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)
logging.info("Done")
def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]:
if list != None:
list.append(tag)
else:
list = [tag]
if len(tag.subtag_ids) == 0:
list.reverse()
return list
else:
for subtag_id in tag.subtag_ids:
subtag = library.get_tag(subtag_id)
return reverse_tag(library, subtag, list)
# =========== UI ===========
def generate_preview_data(library: Library):
tree: dict = dict(dirs={}, files=[])
def add_tag_to_tree(items: list[Tag]):
branch: dict = tree
for tag in items:
if tag.name not in branch["dirs"]:
branch["dirs"][tag.name] = dict(dirs={}, tag=tag, files=[])
branch = branch["dirs"][tag.name]
def add_folders_to_tree(items: list[str]) -> dict:
branch: dict = tree
for folder in items:
if folder not in branch["dirs"]:
new_tag = Tag(-1, folder, "", [], [], "green")
branch["dirs"][folder] = dict(dirs={}, tag=new_tag, files=[])
branch = branch["dirs"][folder]
return branch
for tag in library.tags:
reversed_tag = reverse_tag(library, tag, None)
add_tag_to_tree(reversed_tag)
for entry in library.entries:
folders = list(entry.path.parts)
if len(folders) == 1 and folders[0] == "":
continue
branch = add_folders_to_tree(folders)
if branch:
field_indexes = library.get_field_index_in_entry(entry, 6)
has_tag = False
for index in field_indexes:
content = library.get_field_attr(entry.fields[index], "content")
for tag_id in content:
tag = library.get_tag(tag_id)
if tag.name == branch["tag"].name:
has_tag = True
break
if not has_tag:
branch["files"].append(entry.filename)
def cut_branches_adding_nothing(branch: dict):
folders = set(branch["dirs"].keys())
for folder in folders:
cut = cut_branches_adding_nothing(branch["dirs"][folder])
if cut:
branch["dirs"].pop(folder)
if not "tag" in branch:
return
if branch["tag"].id == -1 or len(branch["files"]) > 0: # Needs to be first
return False
if len(branch["dirs"].keys()) == 0:
return True
cut_branches_adding_nothing(tree)
return tree
class FoldersToTagsModal(QWidget):
# done = Signal(int)
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.library = library
self.driver = driver
self.count = -1
self.filename = ""
self.setWindowTitle(f"Create Tags From Folders")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(640, 640)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)
self.title_widget = QLabel()
self.title_widget.setObjectName("title")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet(
"font-weight:bold;" "font-size:14px;" "padding-top: 6px"
)
self.title_widget.setText("Create Tags From Folders")
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.desc_widget = QLabel()
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
self.desc_widget.setText(
"""Creates tags based on your folder structure and applies them to your entries.\n The structure below shows all the tags that will be created and what entries they will be applied to."""
)
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.open_close_button_w = QWidget()
self.open_close_button_layout = QHBoxLayout(self.open_close_button_w)
self.open_all_button = QPushButton()
self.open_all_button.setText("Open All")
self.open_all_button.clicked.connect(lambda: self.set_all_branches(False))
self.close_all_button = QPushButton()
self.close_all_button.setText("Close All")
self.close_all_button.clicked.connect(lambda: self.set_all_branches(True))
self.open_close_button_layout.addWidget(self.open_all_button)
self.open_close_button_layout.addWidget(self.close_all_button)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
self.scroll_area.setWidget(self.scroll_contents)
self.apply_button = QPushButton()
self.apply_button.setText("&Apply")
self.apply_button.setMinimumWidth(100)
self.apply_button.clicked.connect(self.on_apply)
self.showEvent = self.on_open # type: ignore
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.open_close_button_w)
self.root_layout.addWidget(self.scroll_area)
self.root_layout.addWidget(
self.apply_button, alignment=Qt.AlignmentFlag.AlignCenter
)
def on_apply(self, event):
folders_to_tags(self.library)
self.close()
self.driver.preview_panel.update_widgets()
def on_open(self, event):
for i in reversed(range(self.scroll_layout.count())):
self.scroll_layout.itemAt(i).widget().setParent(None)
data = generate_preview_data(self.library)
for folder in data["dirs"].values():
test = TreeItem(folder, None)
self.scroll_layout.addWidget(test)
def set_all_branches(self, hidden: bool):
for i in reversed(range(self.scroll_layout.count())):
child = self.scroll_layout.itemAt(i).widget()
if type(child) == TreeItem:
child.set_all_branches(hidden)
class TreeItem(QWidget):
def __init__(self, data: dict, parentTag: Tag):
super().__init__()
self.setStyleSheet("QLabel{font-size: 13px}")
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(20, 0, 0, 0)
self.root_layout.setSpacing(1)
self.test = QWidget()
self.root_layout.addWidget(self.test)
self.tag_layout = FlowLayout(self.test)
self.label = QLabel()
self.tag_layout.addWidget(self.label)
self.tag_widget = ModifiedTagWidget(data["tag"], parentTag)
self.tag_widget.bg_button.clicked.connect(lambda: self.hide_show())
self.tag_layout.addWidget(self.tag_widget)
self.children_widget = QWidget()
self.children_layout = QVBoxLayout(self.children_widget)
self.root_layout.addWidget(self.children_widget)
self.populate(data)
def hide_show(self):
self.children_widget.setHidden(not self.children_widget.isHidden())
self.label.setText(">" if self.children_widget.isHidden() else "v")
def populate(self, data: dict):
for folder in data["dirs"].values():
item = TreeItem(folder, data["tag"])
self.children_layout.addWidget(item)
for file in data["files"]:
label = QLabel()
label.setText(" -> " + file)
self.children_layout.addWidget(label)
if len(data["files"]) == 0 and len(data["dirs"].values()) == 0:
self.hide_show()
else:
self.label.setText("v")
def set_all_branches(self, hidden: bool):
for i in reversed(range(self.children_layout.count())):
child = self.children_layout.itemAt(i).widget()
if type(child) == TreeItem:
child.set_all_branches(hidden)
self.children_widget.setHidden(hidden)
self.label.setText(">" if self.children_widget.isHidden() else "v")
class ModifiedTagWidget(
QWidget
): # Needed to be modified because the original searched the display name in the library where it wasn't added yet
def __init__(self, tag: Tag, parentTag: Tag) -> None:
super().__init__()
self.tag = tag
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName("baseLayout")
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
if parentTag != None:
text = f"{tag.name} ({parentTag.name})".replace("&", "&&")
else:
text = tag.name.replace("&", "&&")
self.bg_button.setText(text)
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(2, 2, 2, 2)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22)
self.bg_button.setStyleSheet(
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, tag.color)};"
f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
f"border-radius: 6px;"
f"border-style:inset;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};"
f"}}"
)
self.base_layout.addWidget(self.bg_button)
self.setMinimumSize(50, 20)

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

@@ -0,0 +1,140 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from time import sleep
import typing
from PySide6.QtCore import Signal, Qt, QThreadPool
from PySide6.QtGui import QStandardItemModel, QStandardItem
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QListView,
)
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 MirrorEntriesModal(QWidget):
done = Signal()
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.setWindowTitle(f"Mirror Entries")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
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.setText(f"""
Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries?
""")
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
self.cancel_button = QPushButton()
self.cancel_button.setText("&Cancel")
self.cancel_button.setDefault(True)
self.cancel_button.clicked.connect(self.hide)
self.button_layout.addWidget(self.cancel_button)
self.mirror_button = QPushButton()
self.mirror_button.setText("&Mirror")
self.mirror_button.clicked.connect(self.hide)
self.mirror_button.clicked.connect(lambda: self.mirror_entries())
self.button_layout.addWidget(self.mirror_button)
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)
def refresh_list(self):
self.desc_widget.setText(f"""
Are you sure you want to mirror the following {len(self.lib.dupe_files)} Entries?
""")
self.model.clear()
for i in self.lib.dupe_files:
self.model.appendRow(QStandardItem(str(i)))
def mirror_entries(self):
# pb = QProgressDialog('', None, 0, len(self.lib.dupe_files))
# # pb.setMaximum(len(self.lib.missing_files))
# pb.setFixedSize(432, 112)
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
# pb.setWindowTitle('Mirroring Entries')
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
# pb.show()
# r = CustomRunnable(lambda: self.mirror_entries_runnable(pb))
# r.done.connect(lambda: self.done.emit())
# r.done.connect(lambda: self.driver.preview_panel.refresh())
# # r.done.connect(lambda: self.model.clear())
# # QThreadPool.globalInstance().start(r)
# r.run()
iterator = FunctionIterator(self.mirror_entries_runnable)
pw = ProgressWidget(
window_title="Mirroring Entries",
label_text=f"Mirroring 1/{len(self.lib.dupe_files)} Entries...",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.dupe_files),
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x + 1))
iterator.value.connect(
lambda x: pw.update_label(
f"Mirroring {x+1}/{len(self.lib.dupe_files)} Entries..."
)
)
r = CustomRunnable(lambda: iterator.run())
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.driver.preview_panel.update_widgets(),
self.done.emit(),
)
)
def mirror_entries_runnable(self):
mirrored: list = []
for i, dupe in enumerate(self.lib.dupe_files):
# pb.setValue(i)
# pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries')
entry_id_1 = self.lib.get_entry_id_from_filepath(dupe[0])
entry_id_2 = self.lib.get_entry_id_from_filepath(dupe[1])
self.lib.mirror_entry_fields([entry_id_1, entry_id_2])
sleep(0.005)
yield i
for d in mirrored:
self.lib.dupe_files.remove(d)
# self.driver.filter_items('')
# self.done.emit()

View File

@@ -0,0 +1,61 @@
# 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 RelinkUnlinkedEntries(QObject):
done = Signal()
def __init__(self, library: "Library", driver: "QtDriver"):
super().__init__()
self.lib = library
self.driver = driver
self.fixed = 0
def repair_entries(self):
iterator = FunctionIterator(self.lib.fix_missing_files)
pw = ProgressWidget(
window_title="Relinking Entries",
label_text="",
cancel_button_text=None,
minimum=0,
maximum=len(self.lib.missing_files),
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
iterator.value.connect(
lambda x: (
self.increment_fixed() if x[1] else (),
pw.update_label(
f"Attempting to Relink {x[0]+1}/{len(self.lib.missing_files)} Entries, {self.fixed} Successfully Relinked"
),
)
)
r = CustomRunnable(lambda: iterator.run())
r.done.connect(
lambda: (pw.hide(), pw.deleteLater(), self.done.emit(), self.reset_fixed())
)
QThreadPool.globalInstance().start(r)
def increment_fixed(self):
self.fixed += 1
def reset_fixed(self):
self.fixed = 0

View File

@@ -0,0 +1,146 @@
# 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, Qt, QSize
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLineEdit,
QScrollArea,
QFrame,
)
from src.core.library import Library
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.qt.modals.build_tag import BuildTagPanel
class TagDatabasePanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library):
super().__init__()
self.lib: Library = library
# self.callback = callback
self.first_tag_id = -1
self.tag_limit = 30
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.search_field = QLineEdit()
self.search_field.setObjectName("searchField")
self.search_field.setMinimumSize(QSize(0, 32))
self.search_field.setPlaceholderText("Search Tags")
self.search_field.textEdited.connect(
lambda x=self.search_field.text(): self.update_tags(x)
)
self.search_field.returnPressed.connect(
lambda checked=False: self.on_return(self.search_field.text())
)
# self.content_container = QWidget()
# self.content_layout = QHBoxLayout(self.content_container)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setStyleSheet('background: #000000;')
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
)
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# sa.setMaximumWidth(self.preview_size[0])
self.scroll_area.setWidget(self.scroll_contents)
# self.add_button = QPushButton()
# self.root_layout.addWidget(self.add_button)
# self.add_button.setText('Add Tag')
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
# # self.setLayout(self.root_layout)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
self.update_tags("")
# def reset(self):
# self.search_field.setText('')
# self.update_tags('')
# self.search_field.setFocus()
def on_return(self, text: str):
if text and self.first_tag_id >= 0:
# callback(self.first_tag_id)
self.search_field.setText("")
self.update_tags("")
else:
self.search_field.setFocus()
self.parentWidget().hide()
def update_tags(self, query: str):
# TODO: Look at recycling rather than deleting and reinitializing
while self.scroll_layout.itemAt(0):
self.scroll_layout.takeAt(0).widget().deleteLater()
# If there is a query, get a list of tag_ids that match, otherwise return all
if query:
tags = self.lib.search_tags(query, include_cluster=True)[
: self.tag_limit - 1
]
else:
# Get tag ids to keep this behaviorally identical
tags = [t.id for t in self.lib.tags]
first_id_set = False
for tag_id in tags:
if not first_id_set:
self.first_tag_id = tag_id
first_id_set = True
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
tw.on_edit.connect(
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
)
row.addWidget(tw)
self.scroll_layout.addWidget(container)
self.search_field.setFocus()
def edit_tag(self, tag_id: int):
btp = BuildTagPanel(self.lib, tag_id)
# btp.on_edit.connect(lambda x: self.edit_tag_callback(x))
self.edit_modal = PanelModal(
btp,
self.lib.get_tag(tag_id).display_name(self.lib),
"Edit Tag",
done_callback=(self.update_tags(self.search_field.text())),
has_save=True,
)
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
# TODO Check Warning: Expected type 'BuildTagPanel', got 'PanelWidget' instead
self.edit_modal.saved.connect(lambda: self.edit_tag_callback(btp))
self.edit_modal.show()
def edit_tag_callback(self, btp: BuildTagPanel):
self.lib.update_tag(btp.build_tag())
self.update_tags(self.search_field.text())
# def enterEvent(self, event: QEnterEvent) -> None:
# self.search_field.setFocus()
# return super().enterEvent(event)
# self.focusOutEvent

View File

@@ -0,0 +1,159 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import math
from PySide6.QtCore import Signal, Qt, QSize
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLineEdit,
QScrollArea,
QFrame,
)
from src.core.library import Library
from src.core.palette import ColorType, get_tag_color
from src.qt.widgets.panel import PanelWidget
from src.qt.widgets.tag import TagWidget
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class TagSearchPanel(PanelWidget):
tag_chosen = Signal(int)
def __init__(self, library):
super().__init__()
self.lib: Library = library
# self.callback = callback
self.first_tag_id = None
self.tag_limit = 100
# self.selected_tag: int = 0
self.setMinimumSize(300, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.search_field = QLineEdit()
self.search_field.setObjectName("searchField")
self.search_field.setMinimumSize(QSize(0, 32))
self.search_field.setPlaceholderText("Search Tags")
self.search_field.textEdited.connect(
lambda x=self.search_field.text(): self.update_tags(x)
)
self.search_field.returnPressed.connect(
lambda checked=False: self.on_return(self.search_field.text())
)
# self.content_container = QWidget()
# self.content_layout = QHBoxLayout(self.content_container)
self.scroll_contents = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_contents)
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
# self.scroll_area.setStyleSheet('background: #000000;')
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
)
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
# sa.setMaximumWidth(self.preview_size[0])
self.scroll_area.setWidget(self.scroll_contents)
# self.add_button = QPushButton()
# self.root_layout.addWidget(self.add_button)
# self.add_button.setText('Add Tag')
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
# # self.setLayout(self.root_layout)
self.root_layout.addWidget(self.search_field)
self.root_layout.addWidget(self.scroll_area)
# def reset(self):
# self.search_field.setText('')
# self.update_tags('')
# self.search_field.setFocus()
def on_return(self, text: str):
if text and self.first_tag_id is not None:
# callback(self.first_tag_id)
self.tag_chosen.emit(self.first_tag_id)
self.search_field.setText("")
self.update_tags()
else:
self.search_field.setFocus()
self.parentWidget().hide()
def update_tags(self, query: str = ""):
# for c in self.scroll_layout.children():
# c.widget().deleteLater()
while self.scroll_layout.count():
# 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]
self.first_tag_id = found_tags[0] if found_tags else None
for tag_id in found_tags:
c = QWidget()
l = QHBoxLayout(c)
l.setContentsMargins(0, 0, 0, 0)
l.setSpacing(3)
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), False, False)
ab = QPushButton()
ab.setMinimumSize(23, 23)
ab.setMaximumSize(23, 23)
ab.setText("+")
ab.setStyleSheet(
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, self.lib.get_tag(tag_id).color)};"
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
f"color: {get_tag_color(ColorType.TEXT, self.lib.get_tag(tag_id).color)};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, self.lib.get_tag(tag_id).color)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f"padding-bottom: 5px;"
# f'padding-left: 4px;'
f"font-size: 20px;"
f"}}"
f"QPushButton::hover"
f"{{"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};"
f"color: {get_tag_color(ColorType.DARK_ACCENT, self.lib.get_tag(tag_id).color)};"
f"background: {get_tag_color(ColorType.LIGHT_ACCENT, self.lib.get_tag(tag_id).color)};"
f"}}"
)
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
l.addWidget(tw)
l.addWidget(ab)
self.scroll_layout.addWidget(c)
self.search_field.setFocus()
# def enterEvent(self, event: QEnterEvent) -> None:
# self.search_field.setFocus()
# return super().enterEvent(event)
# self.focusOutEvent

View File

@@ -5,547 +5,460 @@
"""A pagination widget created for TagStudio."""
# I never want to see this code again.
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtCore import QFile, QObject, QThread, Signal, QRunnable, Qt, QThreadPool, QSize, QEvent, QMimeData
from PySide6.QtCore import QObject, Signal, QSize
from PySide6.QtGui import QIntValidator
from PySide6.QtWidgets import (
QWidget,
QHBoxLayout,
QPushButton,
QLabel,
QLineEdit,
QSizePolicy,
)
# class NumberEdit(QLineEdit):
# def __init__(self, parent=None) -> None:
# super().__init__(parent)
# self.textChanged
class Pagination(QWidget, QObject):
"""Widget containing controls for navigating between pages of items."""
index = Signal(int)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.page_count: int = 0
self.current_page_index: int = 0
self.buffer_page_count: int = 4
self.button_size = QSize(32, 24)
"""Widget containing controls for navigating between pages of items."""
# ------------ UI EXAMPLE --------------
# [<] [1]...[3][4] [5] [6][7]...[42] [>]
# ^^^^ <-- 2 Buffer Pages
# Center Page Number is Editable Text
# --------------------------------------
index = Signal(int)
# [----------- ROOT LAYOUT ------------]
self.setHidden(True)
self.root_layout = QHBoxLayout(self)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
self.root_layout.setContentsMargins(0,6,0,0)
self.root_layout.setSpacing(3)
# self.setMinimumHeight(32)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.page_count: int = 0
self.current_page_index: int = 0
self.buffer_page_count: int = 4
self.button_size = QSize(32, 24)
# [<] ----------------------------------
self.prev_button = QPushButton()
self.prev_button.setText('<')
self.prev_button.setMinimumSize(self.button_size)
self.prev_button.setMaximumSize(self.button_size)
# ------------ UI EXAMPLE --------------
# [<] [1]...[3][4] [5] [6][7]...[42] [>]
# ^^^^ <-- 2 Buffer Pages
# Center Page Number is Editable Text
# --------------------------------------
# --- [1] ------------------------------
self.start_button = QPushButton()
self.start_button.setMinimumSize(self.button_size)
self.start_button.setMaximumSize(self.button_size)
# self.start_button.setStyleSheet('background:cyan;')
# self.start_button.setMaximumHeight(self.button_size.height())
# [----------- ROOT LAYOUT ------------]
self.setHidden(True)
self.root_layout = QHBoxLayout(self)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
self.root_layout.setContentsMargins(0, 6, 0, 0)
self.root_layout.setSpacing(3)
# self.setMinimumHeight(32)
# ------ ... ---------------------------
self.start_ellipses = QLabel()
self.start_ellipses.setMinimumSize(self.button_size)
self.start_ellipses.setMaximumSize(self.button_size)
# self.start_ellipses.setMaximumHeight(self.button_size.height())
self.start_ellipses.setText('. . .')
# [<] ----------------------------------
self.prev_button = QPushButton()
self.prev_button.setText("<")
self.prev_button.setMinimumSize(self.button_size)
self.prev_button.setMaximumSize(self.button_size)
# --------- [3][4] ---------------------
self.start_buffer_container = QWidget()
self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
self.start_buffer_layout.setContentsMargins(0,0,0,0)
self.start_buffer_layout.setSpacing(3)
# self.start_buffer_container.setStyleSheet('background:blue;')
# --- [1] ------------------------------
self.start_button = QPushButton()
self.start_button.setMinimumSize(self.button_size)
self.start_button.setMaximumSize(self.button_size)
# self.start_button.setStyleSheet('background:cyan;')
# self.start_button.setMaximumHeight(self.button_size.height())
# ---------------- [5] -----------------
self.current_page_field = QLineEdit()
self.current_page_field.setMinimumSize(self.button_size)
self.current_page_field.setMaximumSize(self.button_size)
self.validator = Validator(1, self.page_count)
self.current_page_field.setValidator(self.validator)
self.current_page_field.returnPressed.connect(lambda: self._goto_page(int(self.current_page_field.text())-1))
# self.current_page_field.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
# self.current_page_field.setMaximumHeight(self.button_size.height())
# self.current_page_field.setMaximumWidth(self.button_size.width())
# ------ ... ---------------------------
self.start_ellipses = QLabel()
self.start_ellipses.setMinimumSize(self.button_size)
self.start_ellipses.setMaximumSize(self.button_size)
# self.start_ellipses.setMaximumHeight(self.button_size.height())
self.start_ellipses.setText(". . .")
# -------------------- [6][7] ----------
self.end_buffer_container = QWidget()
self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
self.end_buffer_layout.setContentsMargins(0,0,0,0)
self.end_buffer_layout.setSpacing(3)
# self.end_buffer_container.setStyleSheet('background:orange;')
# --------- [3][4] ---------------------
self.start_buffer_container = QWidget()
self.start_buffer_layout = QHBoxLayout(self.start_buffer_container)
self.start_buffer_layout.setContentsMargins(0, 0, 0, 0)
self.start_buffer_layout.setSpacing(3)
# self.start_buffer_container.setStyleSheet('background:blue;')
# -------------------------- ... -------
self.end_ellipses = QLabel()
self.end_ellipses.setMinimumSize(self.button_size)
self.end_ellipses.setMaximumSize(self.button_size)
# self.end_ellipses.setMaximumHeight(self.button_size.height())
self.end_ellipses.setText('. . .')
# ---------------- [5] -----------------
self.current_page_field = QLineEdit()
self.current_page_field.setMinimumSize(self.button_size)
self.current_page_field.setMaximumSize(self.button_size)
self.validator = Validator(1, self.page_count)
self.current_page_field.setValidator(self.validator)
self.current_page_field.returnPressed.connect(
lambda: self._goto_page(int(self.current_page_field.text()) - 1)
)
# self.current_page_field.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
# self.current_page_field.setMaximumHeight(self.button_size.height())
# self.current_page_field.setMaximumWidth(self.button_size.width())
# ----------------------------- [42] ---
self.end_button = QPushButton()
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;')
# -------------------- [6][7] ----------
self.end_buffer_container = QWidget()
self.end_buffer_layout = QHBoxLayout(self.end_buffer_container)
self.end_buffer_layout.setContentsMargins(0, 0, 0, 0)
self.end_buffer_layout.setSpacing(3)
# self.end_buffer_container.setStyleSheet('background:orange;')
# ---------------------------------- [>]
self.next_button = QPushButton()
self.next_button.setText('>')
self.next_button.setMinimumSize(self.button_size)
self.next_button.setMaximumSize(self.button_size)
# Add Widgets to Root Layout
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.prev_button)
self.root_layout.addWidget(self.start_button)
self.root_layout.addWidget(self.start_ellipses)
self.root_layout.addWidget(self.start_buffer_container)
self.root_layout.addWidget(self.current_page_field)
self.root_layout.addWidget(self.end_buffer_container)
self.root_layout.addWidget(self.end_ellipses)
self.root_layout.addWidget(self.end_button)
self.root_layout.addWidget(self.next_button)
self.root_layout.addStretch(1)
# -------------------------- ... -------
self.end_ellipses = QLabel()
self.end_ellipses.setMinimumSize(self.button_size)
self.end_ellipses.setMaximumSize(self.button_size)
# self.end_ellipses.setMaximumHeight(self.button_size.height())
self.end_ellipses.setText(". . .")
self._populate_buffer_buttons()
# self.update_buttons(page_count=9, index=0)
# ----------------------------- [42] ---
self.end_button = QPushButton()
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;')
def update_buttons(self, page_count:int, index:int, emit:bool=True):
# Screw it
for i in range(0, 10):
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# ---------------------------------- [>]
self.next_button = QPushButton()
self.next_button.setText(">")
self.next_button.setMinimumSize(self.button_size)
self.next_button.setMaximumSize(self.button_size)
if page_count <= 1:
# Hide everything if there are only one or less pages.
# [-------------- HIDDEN --------------]
self.setHidden(True)
# elif page_count > 1 and page_count < 7:
# # Only show Next/Prev, current index field, and both start and end
# # buffers (the end may be odd).
# # [<] [1][2][3][4][5][6] [>]
# self.start_button.setHidden(True)
# self.start_ellipses.setHidden(True)
# self.end_ellipses.setHidden(True)
# self.end_button.setHidden(True)
# elif page_count > 1:
# self.start_button.setHidden(False)
# self.start_ellipses.setHidden(False)
# self.end_ellipses.setHidden(False)
# self.end_button.setHidden(False)
# Add Widgets to Root Layout
self.root_layout.addStretch(1)
self.root_layout.addWidget(self.prev_button)
self.root_layout.addWidget(self.start_button)
self.root_layout.addWidget(self.start_ellipses)
self.root_layout.addWidget(self.start_buffer_container)
self.root_layout.addWidget(self.current_page_field)
self.root_layout.addWidget(self.end_buffer_container)
self.root_layout.addWidget(self.end_ellipses)
self.root_layout.addWidget(self.end_button)
self.root_layout.addWidget(self.next_button)
self.root_layout.addStretch(1)
# self.start_button.setText('1')
# self.assign_click(self.start_button, 0)
# self.end_button.setText(str(page_count))
# self.assign_click(self.end_button, page_count-1)
elif page_count > 1:
self._populate_buffer_buttons()
# self.update_buttons(page_count=9, index=0)
# Enable/Disable Next+Prev Buttons
if index == 0:
self.prev_button.setDisabled(True)
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
else:
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
self._assign_click(self.prev_button, index-1)
self.prev_button.setDisabled(False)
if index == page_count-1:
self.next_button.setDisabled(True)
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
else:
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
self._assign_click(self.next_button, index+1)
self.next_button.setDisabled(False)
# Set Ellipses Sizes
if page_count == 8:
if index == 0:
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.end_ellipses.setMinimumWidth(self.button_size.width())
self.end_ellipses.setMaximumWidth(self.button_size.width())
if index == page_count-1:
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.start_ellipses.setMinimumWidth(self.button_size.width())
self.start_ellipses.setMaximumWidth(self.button_size.width())
elif page_count == 9:
if index == 0:
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == 1:
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.end_ellipses.setMinimumWidth(self.button_size.width())
self.end_ellipses.setMaximumWidth(self.button_size.width())
if index == page_count-1:
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == page_count-2:
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.start_ellipses.setMinimumWidth(self.button_size.width())
self.start_ellipses.setMaximumWidth(self.button_size.width())
elif page_count == 10:
if index == 0:
self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
elif index == 1:
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == 2:
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.end_ellipses.setMinimumWidth(self.button_size.width())
self.end_ellipses.setMaximumWidth(self.button_size.width())
if index == page_count-1:
self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
elif index == page_count-2:
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == page_count-3:
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.start_ellipses.setMinimumWidth(self.button_size.width())
self.start_ellipses.setMaximumWidth(self.button_size.width())
elif page_count == 11:
if index == 0:
self.end_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
self.end_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
elif index == 1:
self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
elif index == 2:
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == 3:
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.end_ellipses.setMinimumWidth(self.button_size.width())
self.end_ellipses.setMaximumWidth(self.button_size.width())
if index == page_count-1:
self.start_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
self.start_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
elif index == page_count-2:
self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
elif index == page_count-3:
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == page_count-4:
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.start_ellipses.setMinimumWidth(self.button_size.width())
self.start_ellipses.setMaximumWidth(self.button_size.width())
elif page_count > 11:
if index == 0:
self.end_ellipses.setMinimumWidth(self.button_size.width()*7 + 18)
self.end_ellipses.setMaximumWidth(self.button_size.width()*7 + 18)
elif index == 1:
self.end_ellipses.setMinimumWidth(self.button_size.width()*6 + 15)
self.end_ellipses.setMaximumWidth(self.button_size.width()*6 + 15)
elif index == 2:
self.end_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
self.end_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
elif index == 3:
self.end_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
self.end_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
elif index == 4:
self.end_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.end_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == 5:
self.end_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.end_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.end_ellipses.setMinimumWidth(self.button_size.width())
self.end_ellipses.setMaximumWidth(self.button_size.width())
if index == page_count-1:
self.start_ellipses.setMinimumWidth(self.button_size.width()*7 + 18)
self.start_ellipses.setMaximumWidth(self.button_size.width()*7 + 18)
elif index == page_count-2:
self.start_ellipses.setMinimumWidth(self.button_size.width()*6 + 15)
self.start_ellipses.setMaximumWidth(self.button_size.width()*6 + 15)
elif index == page_count-3:
self.start_ellipses.setMinimumWidth(self.button_size.width()*5 + 12)
self.start_ellipses.setMaximumWidth(self.button_size.width()*5 + 12)
elif index == page_count-4:
self.start_ellipses.setMinimumWidth(self.button_size.width()*4 + 9)
self.start_ellipses.setMaximumWidth(self.button_size.width()*4 + 9)
elif index == page_count-5:
self.start_ellipses.setMinimumWidth(self.button_size.width()*3 + 6)
self.start_ellipses.setMaximumWidth(self.button_size.width()*3 + 6)
elif index == page_count-6:
self.start_ellipses.setMinimumWidth(self.button_size.width()*2 + 3)
self.start_ellipses.setMaximumWidth(self.button_size.width()*2 + 3)
else:
self.start_ellipses.setMinimumWidth(self.button_size.width())
self.start_ellipses.setMaximumWidth(self.button_size.width())
# Enable/Disable Ellipses
# if index <= max(self.buffer_page_count, 5)+1:
if index <= self.buffer_page_count+1:
self.start_ellipses.setHidden(True)
# self.start_button.setHidden(True)
else:
self.start_ellipses.setHidden(False)
# self.start_button.setHidden(False)
# self.start_button.setText('1')
self._assign_click(self.start_button, 0)
# if index >=(page_count-max(self.buffer_page_count, 5)-2):
if index >= (page_count-self.buffer_page_count-2):
self.end_ellipses.setHidden(True)
# self.end_button.setHidden(True)
else:
self.end_ellipses.setHidden(False)
# self.end_button.setHidden(False)
# self.end_button.setText(str(page_count))
# self.assign_click(self.end_button, page_count-1)
# Hide/Unhide Start+End Buttons
if index != 0:
self.start_button.setText('1')
self._assign_click(self.start_button, 0)
self.start_button.setHidden(False)
# self.start_buffer_layout.setContentsMargins(3,0,0,0)
else:
self.start_button.setHidden(True)
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
if index != page_count-1:
self.end_button.setText(str(page_count))
self._assign_click(self.end_button, page_count-1)
self.end_button.setHidden(False)
# self.end_buffer_layout.setContentsMargins(0,0,3,0)
else:
self.end_button.setHidden(True)
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
def update_buttons(self, page_count: int, index: int, emit: bool = True):
# Guard
if index < 0:
raise ValueError("Negative index detected")
if index == 0 or index == 1:
self.start_buffer_container.setHidden(True)
else:
self.start_buffer_container.setHidden(False)
if index == page_count-1 or index == page_count-2:
self.end_buffer_container.setHidden(True)
else:
self.end_buffer_container.setHidden(False)
# Screw it
for i in range(0, 10):
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# for i in range(0, self.buffer_page_count):
# self.start_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 --------------]
self.setHidden(True)
# elif page_count > 1 and page_count < 7:
# # Only show Next/Prev, current index field, and both start and end
# # buffers (the end may be odd).
# # [<] [1][2][3][4][5][6] [>]
# self.start_button.setHidden(True)
# self.start_ellipses.setHidden(True)
# self.end_ellipses.setHidden(True)
# self.end_button.setHidden(True)
# elif page_count > 1:
# self.start_button.setHidden(False)
# self.start_ellipses.setHidden(False)
# self.end_ellipses.setHidden(False)
# self.end_button.setHidden(False)
# Current Field and Buffer Pages
sbc = 0
# for i in range(0, max(self.buffer_page_count*2, 11)):
for i in range(0, page_count):
# for j in range(0, self.buffer_page_count+1):
# self.start_buffer_layout.itemAt(j).widget().setHidden(True)
# if i == 1:
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# elif i == page_count-2:
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# Set Field
if i == index:
# print(f'Current Index: {i}')
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
sbc += 1
self.current_page_field.setText((str(i+1)))
# elif index == page_count-1:
# self.start_button.setText(str(page_count))
# self.start_button.setText('1')
# self.assign_click(self.start_button, 0)
# self.end_button.setText(str(page_count))
# self.assign_click(self.end_button, page_count-1)
start_offset = max(0, (index-4)-4)
end_offset = min(page_count-1, (index+4)-4)
if i < index:
# if i != 0 and ((i-self.buffer_page_count) >= 0 or i <= self.buffer_page_count):
if (i != 0) and i >= index-4:
# print(f' Start i: {i}')
# print(f'Start Offset: {start_offset}')
# print(f' Requested i: {i-start_offset}')
# print(f'Setting Text "{str(i+1)}" for Local Start i:{i-start_offset}, Global i:{i}')
self.start_buffer_layout.itemAt(i-start_offset).widget().setHidden(False)
self.start_buffer_layout.itemAt(i-start_offset).widget().setText(str(i+1))
self._assign_click(self.start_buffer_layout.itemAt(i-start_offset).widget(), i)
sbc += 1
else:
if self.start_buffer_layout.itemAt(i):
# print(f'Removing S-Start {i}')
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
# print(f'Removing S-End {i}')
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
elif i > index:
# if i != page_count-1:
if i != page_count-1 and i <= index+4:
# print(f'End Buffer: {i}')
# print(f' End i: {i}')
# print(f' End Offset: {end_offset}')
# print(f'Requested i: {i-end_offset}')
# print(f'Requested i: {end_offset-sbc-i}')
# if self.start_buffer_layout.itemAt(i):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# print(f'Setting Text "{str(i+1)}" for Local End i:{i-end_offset}, Global i:{i}')
self.end_buffer_layout.itemAt(i-end_offset).widget().setHidden(False)
self.end_buffer_layout.itemAt(i-end_offset).widget().setText(str(i+1))
self._assign_click(self.end_buffer_layout.itemAt(i-end_offset).widget(), i)
else:
# if self.start_buffer_layout.itemAt(i-1):
# print(f'Removing E-Start {i-1}')
# self.start_buffer_layout.itemAt(i-1).widget().setHidden(True)
# if self.start_buffer_layout.itemAt(i-start_offset):
# print(f'Removing E-Start Offset {i-end_offset}')
# self.start_buffer_layout.itemAt(i-end_offset).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
# print(f'Removing E-End {i}')
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
for j in range(0,self.buffer_page_count):
if self.end_buffer_layout.itemAt(i-end_offset+j):
# print(f'Removing E-End-Offset {i-end_offset+j}')
self.end_buffer_layout.itemAt(i-end_offset+j).widget().setHidden(True)
elif page_count > 1:
# Enable/Disable Next+Prev Buttons
if index == 0:
self.prev_button.setDisabled(True)
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
else:
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
self._assign_click(self.prev_button, index - 1)
self.prev_button.setDisabled(False)
# if self.end_buffer_layout.itemAt(i+1):
# print(f'Removing T-End {i+1}')
# self.end_buffer_layout.itemAt(i+1).widget().setHidden(True)
if self.start_buffer_layout.itemAt(i-1):
# print(f'Removing T-Start {i-1}')
self.start_buffer_layout.itemAt(i-1).widget().setHidden(True)
if index == end_page:
self.next_button.setDisabled(True)
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
else:
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
self._assign_click(self.next_button, index + 1)
self.next_button.setDisabled(False)
# if index == 0 or index == 1:
# print(f'Removing Start i: {i}')
# if self.start_buffer_layout.itemAt(i):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# elif index == page_count-1 or index == page_count-2 or index == page_count-3 or index == page_count-4:
# print(f' Removing End i: {i}')
# if self.end_buffer_layout.itemAt(i):
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# Set Ellipses Sizes
# I do not know where these magic values were derived from, but
# this is better than the chain elif's that were here before
if 8 <= page_count <= 11:
end_scale = max(1, page_count - index - 6)
srt_scale = max(1, index - 5)
elif page_count > 11:
end_scale = max(1, 7 - index)
srt_scale = max(1, (7 - (end_page - index)))
if page_count >= 8:
end_size = self.button_size.width() * end_scale + (3 * (end_scale - 1))
srt_size = self.button_size.width() * srt_scale + (3 * (srt_scale - 1))
self.end_ellipses.setMinimumWidth(end_size)
self.end_ellipses.setMaximumWidth(end_size)
self.start_ellipses.setMinimumWidth(srt_size)
self.start_ellipses.setMaximumWidth(srt_size)
# else:
# print(f'Truncate: {i}')
# if self.start_buffer_layout.itemAt(i):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# if self.end_buffer_layout.itemAt(i):
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# if i < self.buffer_page_count:
# print(f'start {i}')
# if i == 0:
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# self.current_page_field.setText((str(i+1)))
# else:
# self.start_buffer_layout.itemAt(i).widget().setHidden(False)
# self.start_buffer_layout.itemAt(i).widget().setText(str(i+1))
# elif i >= self.buffer_page_count and i < count:
# print(f'end {i}')
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(False)
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setText(str(i+1))
# else:
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(True)
# Enable/Disable Ellipses
# if index <= max(self.buffer_page_count, 5)+1:
if index <= self.buffer_page_count + 1:
self.start_ellipses.setHidden(True)
# self.start_button.setHidden(True)
else:
self.start_ellipses.setHidden(False)
# self.start_button.setHidden(False)
# self.start_button.setText('1')
self._assign_click(self.start_button, 0)
# if index >=(page_count-max(self.buffer_page_count, 5)-2):
if index >= (page_count - self.buffer_page_count - 2):
self.end_ellipses.setHidden(True)
# self.end_button.setHidden(True)
else:
self.end_ellipses.setHidden(False)
# self.end_button.setHidden(False)
# self.end_button.setText(str(page_count))
# self.assign_click(self.end_button, page_count-1)
self.setHidden(False)
# elif page_count >= 7:
# # Show everything, except truncate the buffers as needed.
# # [<] [1]...[3] [4] [5]...[7] [>]
# self.start_button.setHidden(False)
# self.start_ellipses.setHidden(False)
# self.end_ellipses.setHidden(False)
# self.end_button.setHidden(False)
# Hide/Unhide Start+End Buttons
if index != 0:
self.start_button.setText("1")
self._assign_click(self.start_button, 0)
self.start_button.setHidden(False)
# self.start_buffer_layout.setContentsMargins(3,0,0,0)
else:
self.start_button.setHidden(True)
# self.start_buffer_layout.setContentsMargins(0,0,0,0)
if index != page_count - 1:
self.end_button.setText(str(page_count))
self._assign_click(self.end_button, page_count - 1)
self.end_button.setHidden(False)
# self.end_buffer_layout.setContentsMargins(0,0,3,0)
else:
self.end_button.setHidden(True)
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
# if index == 0:
# self.prev_button.setDisabled(True)
# self.start_buffer_layout.setContentsMargins(0,0,3,0)
# else:
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
# self.assign_click(self.prev_button, index-1)
# self.prev_button.setDisabled(False)
# if index == page_count-1:
# self.next_button.setDisabled(True)
# self.end_buffer_layout.setContentsMargins(3,0,0,0)
# else:
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
# self.assign_click(self.next_button, index+1)
# self.next_button.setDisabled(False)
if index == 0 or index == 1:
self.start_buffer_container.setHidden(True)
else:
self.start_buffer_container.setHidden(False)
# self.start_button.setText('1')
# self.assign_click(self.start_button, 0)
# self.end_button.setText(str(page_count))
# self.assign_click(self.end_button, page_count-1)
if index == page_count - 1 or index == page_count - 2:
self.end_buffer_container.setHidden(True)
else:
self.end_buffer_container.setHidden(False)
# self.setHidden(False)
# for i in range(0, self.buffer_page_count):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
self.validator.setTop(page_count)
# if self.current_page_index != index:
if emit:
print(f'[PAGINATION] Emitting {index}')
self.index.emit(index)
self.current_page_index = index
self.page_count = page_count
def _goto_page(self, index:int):
# print(f'GOTO PAGE: {index}')
self.update_buttons(self.page_count, index)
# Current Field and Buffer Pages
sbc = 0
# for i in range(0, max(self.buffer_page_count*2, 11)):
for i in range(0, page_count):
# for j in range(0, self.buffer_page_count+1):
# self.start_buffer_layout.itemAt(j).widget().setHidden(True)
# if i == 1:
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# elif i == page_count-2:
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# Set Field
if i == index:
# print(f'Current Index: {i}')
if self.start_buffer_layout.itemAt(i):
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
sbc += 1
self.current_page_field.setText((str(i + 1)))
# elif index == page_count-1:
# self.start_button.setText(str(page_count))
start_offset = max(0, (index - 4) - 4)
end_offset = min(page_count - 1, (index + 4) - 4)
if i < index:
# if i != 0 and ((i-self.buffer_page_count) >= 0 or i <= self.buffer_page_count):
if (i != 0) and i >= index - 4:
# print(f' Start i: {i}')
# print(f'Start Offset: {start_offset}')
# print(f' Requested i: {i-start_offset}')
# print(f'Setting Text "{str(i+1)}" for Local Start i:{i-start_offset}, Global i:{i}')
self.start_buffer_layout.itemAt(
i - start_offset
).widget().setHidden(False)
self.start_buffer_layout.itemAt(
i - start_offset
).widget().setText(str(i + 1)) # type: ignore
self._assign_click(
self.start_buffer_layout.itemAt(i - start_offset).widget(), # type: ignore
i,
)
sbc += 1
else:
if self.start_buffer_layout.itemAt(i):
# print(f'Removing S-Start {i}')
self.start_buffer_layout.itemAt(i).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
# print(f'Removing S-End {i}')
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
elif i > index:
# if i != page_count-1:
if i != page_count - 1 and i <= index + 4:
# print(f'End Buffer: {i}')
# print(f' End i: {i}')
# print(f' End Offset: {end_offset}')
# print(f'Requested i: {i-end_offset}')
# print(f'Requested i: {end_offset-sbc-i}')
# if self.start_buffer_layout.itemAt(i):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# print(f'Setting Text "{str(i+1)}" for Local End i:{i-end_offset}, Global i:{i}')
self.end_buffer_layout.itemAt(
i - end_offset
).widget().setHidden(False)
self.end_buffer_layout.itemAt(i - end_offset).widget().setText( # type: ignore
str(i + 1)
)
self._assign_click(
self.end_buffer_layout.itemAt(i - end_offset).widget(), # type: ignore
i,
)
else:
# if self.start_buffer_layout.itemAt(i-1):
# print(f'Removing E-Start {i-1}')
# self.start_buffer_layout.itemAt(i-1).widget().setHidden(True)
# if self.start_buffer_layout.itemAt(i-start_offset):
# print(f'Removing E-Start Offset {i-end_offset}')
# self.start_buffer_layout.itemAt(i-end_offset).widget().setHidden(True)
if self.end_buffer_layout.itemAt(i):
# print(f'Removing E-End {i}')
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
for j in range(0, self.buffer_page_count):
if self.end_buffer_layout.itemAt(i - end_offset + j):
# print(f'Removing E-End-Offset {i-end_offset+j}')
self.end_buffer_layout.itemAt(
i - end_offset + j
).widget().setHidden(True)
# if self.end_buffer_layout.itemAt(i+1):
# print(f'Removing T-End {i+1}')
# self.end_buffer_layout.itemAt(i+1).widget().setHidden(True)
if self.start_buffer_layout.itemAt(i - 1):
# print(f'Removing T-Start {i-1}')
self.start_buffer_layout.itemAt(i - 1).widget().setHidden(True)
# if index == 0 or index == 1:
# print(f'Removing Start i: {i}')
# if self.start_buffer_layout.itemAt(i):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# elif index == page_count-1 or index == page_count-2 or index == page_count-3 or index == page_count-4:
# print(f' Removing End i: {i}')
# if self.end_buffer_layout.itemAt(i):
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# else:
# print(f'Truncate: {i}')
# if self.start_buffer_layout.itemAt(i):
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# if self.end_buffer_layout.itemAt(i):
# self.end_buffer_layout.itemAt(i).widget().setHidden(True)
# if i < self.buffer_page_count:
# print(f'start {i}')
# if i == 0:
# self.start_buffer_layout.itemAt(i).widget().setHidden(True)
# self.current_page_field.setText((str(i+1)))
# else:
# self.start_buffer_layout.itemAt(i).widget().setHidden(False)
# self.start_buffer_layout.itemAt(i).widget().setText(str(i+1))
# elif i >= self.buffer_page_count and i < count:
# print(f'end {i}')
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(False)
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setText(str(i+1))
# else:
# self.end_buffer_layout.itemAt(i-self.buffer_page_count).widget().setHidden(True)
self.setHidden(False)
# elif page_count >= 7:
# # Show everything, except truncate the buffers as needed.
# # [<] [1]...[3] [4] [5]...[7] [>]
# self.start_button.setHidden(False)
# self.start_ellipses.setHidden(False)
# self.end_ellipses.setHidden(False)
# self.end_button.setHidden(False)
# if index == 0:
# self.prev_button.setDisabled(True)
# self.start_buffer_layout.setContentsMargins(0,0,3,0)
# else:
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
# self.assign_click(self.prev_button, index-1)
# self.prev_button.setDisabled(False)
# if index == page_count-1:
# self.next_button.setDisabled(True)
# self.end_buffer_layout.setContentsMargins(3,0,0,0)
# else:
# self.end_buffer_layout.setContentsMargins(3,0,3,0)
# self.assign_click(self.next_button, index+1)
# self.next_button.setDisabled(False)
# self.start_button.setText('1')
# self.assign_click(self.start_button, 0)
# self.end_button.setText(str(page_count))
# self.assign_click(self.end_button, page_count-1)
# self.setHidden(False)
self.validator.setTop(page_count)
# if self.current_page_index != index:
if emit:
print(f"[PAGINATION] Emitting {index}")
self.index.emit(index)
self.current_page_index = index
self.page_count = page_count
def _goto_page(self, index: int):
# print(f'GOTO PAGE: {index}')
self.update_buttons(self.page_count, index)
def _assign_click(self, button: QPushButton, index):
try:
button.clicked.disconnect()
except RuntimeError:
pass
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
def _populate_buffer_buttons(self):
for i in range(max(self.buffer_page_count * 2, 5)):
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
# button.setMaximumHeight(self.button_size.height())
self.start_buffer_layout.addWidget(button)
for i in range(max(self.buffer_page_count * 2, 5)):
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
# button.setMaximumHeight(self.button_size.height())
self.end_buffer_layout.addWidget(button)
def _assign_click(self, button:QPushButton, index):
try:
button.clicked.disconnect()
except RuntimeError:
pass
button.clicked.connect(lambda checked=False, i=index: self._goto_page(i))
def _populate_buffer_buttons(self):
for i in range(max(self.buffer_page_count*2, 5)):
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
# button.setMaximumHeight(self.button_size.height())
self.start_buffer_layout.addWidget(button)
for i in range(max(self.buffer_page_count*2, 5)):
button = QPushButton()
button.setMinimumSize(self.button_size)
button.setMaximumSize(self.button_size)
button.setHidden(True)
# button.setMaximumHeight(self.button_size.height())
self.end_buffer_layout.addWidget(button)
class Validator(QIntValidator):
def __init__(self, bottom: int, top: int, parent=None) -> None:
super().__init__(bottom, top, parent)
def fixup(self, input: str) -> str:
# print(input)
input = input.strip('0')
print(input)
return super().fixup(str(self.top()) if input else '1')
def __init__(self, bottom: int, top: int, parent=None) -> None:
super().__init__(bottom, top, parent)
def fixup(self, input: str) -> str:
# print(input)
input = input.strip("0")
print(input)
return super().fixup(str(self.top()) if input else "1")

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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,13 @@
<!-- TO COMPILE: pyside6-rcc resources.qrc -o resources.py (in \tagstudio\src\qt)-->
<!-- TO COMPILE: pyside6-rcc resources.qrc -o resources_rc.py (in \tagstudio\src\qt)-->
<RCC>
<qresource prefix="/">
<file alias = "images/star_icon_empty_128.png">../../resources/qt/images/star_icon_empty_128.png</file>
<file alias = "images/star_icon_filled_128.png">../../resources/qt/images/star_icon_filled_128.png</file>
<file alias = "images/box_icon_empty_128.png">../../resources/qt/images/box_icon_empty_128.png</file>
<file alias = "images/box_icon_filled_128.png">../../resources/qt/images/box_icon_filled_128.png</file>
<file alias = "images/edit_icon_128.png">../../resources/qt/images/edit_icon_128.png</file>
<file alias = "images/trash_icon_128.png">../../resources/qt/images/trash_icon_128.png</file>
<file alias = "images/clipboard_icon_128.png">../../resources/qt/images/clipboard_icon_128.png</file>
<!-- <file alias = "images/edit_icon_128.png">../../resources/qt/images/edit_icon_128.png</file> -->
<!-- <file alias = "images/trash_icon_128.png">../../resources/qt/images/trash_icon_128.png</file> -->
<!-- <file alias = "images/clipboard_icon_128.png">../../resources/qt/images/clipboard_icon_128.png</file> -->
<file alias = "images/splash.png">../../resources/qt/images/splash.png</file>
<!-- <file>../../CommonClasses/PNGWriter/report_text/GothamBlackRegular.otf</file>
<file>../../CommonClasses/PNGWriter/report_text/GothamBold.otf</file>
<file>../../CommonClasses/PNGWriter/report_text/GothamBook.otf</file>
<file>../../CommonClasses/PNGWriter/report_text/GothamLight.otf</file>
<file>../../CommonClasses/PNGWriter/report_text/GothamMedium.otf</file>
<file>../../CommonClasses/PNGWriter/report_text/Spanish</file>
<file>../../CommonClasses/PNGWriter/report_text/viewmind.png</file> -->
</qresource>
</RCC>

View File

@@ -1,456 +1,11 @@
# Resource object code (Python 3)
# Created by: object code
# Created by: The Resource Compiler for Qt version 6.5.1
# Created by: The Resource Compiler for Qt version 6.6.3
# WARNING! All changes made in this file will be lost!
from PySide6 import QtCore
qt_resource_data = b"\
\x00\x00\x0b\x9e\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
\x00\x00\x04\xb3iTXtXML:com.\
adobe.xmp\x00\x00\x00\x00\x00<?\
xpacket begin=\x22\xef\
\xbb\xbf\x22 id=\x22W5M0MpCe\
hiHzreSzNTczkc9d\
\x22?>\x0a<x:xmpmeta x\
mlns:x=\x22adobe:ns\
:meta/\x22 x:xmptk=\
\x22XMP Core 5.5.0\x22\
>\x0a <rdf:RDF xmln\
s:rdf=\x22http://ww\
w.w3.org/1999/02\
/22-rdf-syntax-n\
s#\x22>\x0a <rdf:Desc\
ription rdf:abou\
t=\x22\x22\x0a xmlns:e\
xif=\x22http://ns.a\
dobe.com/exif/1.\
0/\x22\x0a xmlns:ti\
ff=\x22http://ns.ad\
obe.com/tiff/1.0\
/\x22\x0a xmlns:pho\
toshop=\x22http://n\
s.adobe.com/phot\
oshop/1.0/\x22\x0a \
xmlns:xmp=\x22http:\
//ns.adobe.com/x\
ap/1.0/\x22\x0a xml\
ns:xmpMM=\x22http:/\
/ns.adobe.com/xa\
p/1.0/mm/\x22\x0a x\
mlns:stEvt=\x22http\
://ns.adobe.com/\
xap/1.0/sType/Re\
sourceEvent#\x22\x0a \
exif:PixelXDime\
nsion=\x22128\x22\x0a e\
xif:PixelYDimens\
ion=\x22128\x22\x0a exi\
f:ColorSpace=\x221\x22\
\x0a tiff:ImageWi\
dth=\x22128\x22\x0a tif\
f:ImageLength=\x221\
28\x22\x0a tiff:Reso\
lutionUnit=\x222\x22\x0a \
tiff:XResoluti\
on=\x2296.0\x22\x0a tif\
f:YResolution=\x229\
6.0\x22\x0a photosho\
p:ColorMode=\x223\x22\x0a\
photoshop:ICC\
Profile=\x22sRGB IE\
C61966-2.1\x22\x0a x\
mp:ModifyDate=\x222\
023-09-26T10:26:\
39-07:00\x22\x0a xmp\
:MetadataDate=\x222\
023-09-26T10:26:\
39-07:00\x22>\x0a <x\
mpMM:History>\x0a \
<rdf:Seq>\x0a \
<rdf:li\x0a s\
tEvt:action=\x22pro\
duced\x22\x0a stE\
vt:softwareAgent\
=\x22Affinity Photo\
1.8.5\x22\x0a st\
Evt:when=\x222023-0\
9-26T10:26:39-07\
:00\x22/>\x0a </rdf\
:Seq>\x0a </xmpMM\
:History>\x0a </rd\
f:Description>\x0a \
</rdf:RDF>\x0a</x:x\
mpmeta>\x0a<?xpacke\
t end=\x22r\x22?>{r\xf7\xcc\x00\
\x00\x01\x81iCCPsRGB IEC6\
1966-2.1\x00\x00(\x91u\x91\xcbK\
BA\x14\x87\xbf\xb4\xb2'\x06E\xb4h!Q\xad4\
\xac@j\xd3B\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8\
]\xeeUB\xda\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\
\x0dZ\x07AQ\x04\xd1\xb6\xd6EmJn\xe7j\xa0\
D\xce0\xe7|\xf3\x9bs\x0e3g\xc0\x12L)i\
\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\x0b\xa1E\x87\xed\x85\
F:\xa9\x17k\x09+\xba:\x1b\x98\x08Ru|\xde\
Sc\xfa[\x97Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8\
i\x10\x1eST-+<%<\xb3\x96UM\xde\x11\
\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\
\xc4\xaf&'J\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecH\
Tp\xa4\x82\x95\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\
\xfb\x98/i\x89e\xe6\x03\xe2{du\xa3\xe3g\x12\
/\x0e\xa6\x19\xc7\x87\x87AF\xc5zp1\xc4\x80\xec\
\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8aX\x95<\x1a+$\
H\x92\xc5)jN\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\
\xfb\xff\xb7\xafz|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\
\xde\x07\xb6m(l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\
\x97\x99r\xfe\xea!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\
\x01\xe7We-\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\
\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\
\xcf~\xcf9y\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\
\xdb\x97\x7f\x00\x00<g\xb7\xa5uLy\x00\x00\x00\x09\
pHYs\x00\x00\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\
\x1b\x00\x00\x05\x04IDATx\x9c\xed\x9cAh\x5c\
U\x14\x86\xbf\xa36\x22&\xee*i\x1apaAQ\
W\xa9\xb1\xba\xb3\xed\xa6\xb4\xa4\xb8,\xb8\x127\xba\xd2\
\xb5n\xac\xe8\xb2\xcb\xba\x88A(\x18\xb7\x16E\xc5U\
\xdcU\xadYU\x05\xa5\x82\xd0v\xaavUMA\xa3\
\xcdq1/A\xa4\x99yi\xef\xcb\xb9\x93\xf3\x7f\xf0\
$\xc3\xff\xce\x9c\xfb\xcd}3\xf7\xdd\x09\xec@\xdc\
}\xda\xdd?p\xf7\x9e\xdf9\xbd&k:\xfayu\
\x81E\x17P\x1aw\xbf\x0f\xf8\x19x\xb0p\xf4o\xc0\
Cf\xf6g\xe1\xdcP\xee\x8a.\xa0\x03NP~\xf0\
i2Ot\x90+J\xe2\xee\xe7\x0bL\xfb\x9b\xf1u\
\xf4\xf3+\xcdN\xbc\x04x\x97\xf9f\xb6\xa3z\xb6\x13\
/\x01b\x0bH\x80\xe4H\x80\xe4t~=k>?\
?\x07\x1c\x05\xf6\x01{\x80\xf1\xae\xcf;\xa2\xac\x00W\
\x81\x8b\xc0\xa7\xc0Y3\xbb\xdc\xe5\x09;\x13\xc0\xdd\xa7\
\x80\x93\xc0\x0b\xc0\xdd]\x9dg\x87s\x13x\x0fx\xc3\
\xccz]\x9c\xa0\x13\x01\xdc\xfd8\xf0>0\xd1E~\
B\xfe\x00\x9e7\xb3\x8fK\x07\x17\x7f\x0f\xe0\xee/\x03\
\x1f\xa2\xc1/\xc9\x04p\xd6\xdd_*\x1d\x5ct\x06p\
\xf7'\x80e`\xacd\xae\xd8`\x15\xd8of\xdf\x96\
\x0a,6\x03\xb8\xfb=\xc0\x194\xf8]2\x06\x9ci\
z]\x84\x92\x97\x80\xd7\x81\x99\x82y\xe2\xd6\xcc\x00\xaf\
\x95\x0a+r\x09p\xf7\xdd\xc0\x15`W\x89<1\x94\
\xbf\x81\xbdfv\xedN\x83J\xcd\x00O\xa1\xc1\xdfN\
v\x01\xb3%\x82J\x09\xf0d\xa1\x1c\xd1\x9e\x22=\x97\
\x00\xa3KU\x02\xec/\x94#\xdaS\x95\x00bD)\
%\xc0r\xa1\x1c\xd1\x9eoJ\x84\x94\x12\xa0H1b\
KH\x80\xe4\x14\xe9\xb9\x16\x82F\x93\xba\x16\x82\x9aB\
\xde.\x91%Z\xf1V\x89\xc1\x87\x82w\x03\x9b\x1b\x14\
_\xa1\xfb\x01]\xb3\x0c<mf\xff\x94\x08\xd3\xed\xe0\
\xd1b\x15\x981\xb3\xefJ\x05\x16]\x07h\xeeS\xbf\
\x0a\xac\x95\xcc\x15@\xbf\xa7\xaf\x94\x1c|\xe8nK\xd8\
\x1c\xb0\x88v\x05\x95bt\xb6\x84\x014\x85>\x0a\xbc\
K\x7fc\xa3\xb8=n\x02\xf3\xc0#]\x0c>l\xcf\
\xb6\xf0\xbd\xf4\xb7\x85\x1f\x03\x1e\x06\xa6\xd0\xb6\xf0\xcdX\
\x01z\xc0O\xc0'\xf4\xb7\x85_\xe9\xf2\x84\xe1\xdfs\
\xeb\xfa\xbb|\xb5\x13\xfd]C\xdd\x0cJ\x8e\x04H\x8e\
\x04H\xce(\x08\xe0\xc0\x020G\xff\x0d\xe4T\xf3\xf3\
B\xf3\xb7\x9d\x96\x97\x8b!\xff\x91\xa3\xe7\xee\x87\xdc\x9d\
M\x8e\xc3\xbe\xb5\x7f\x04U]^t\xff\xc3\x19\xd0\x9b\
\xb5!\xcd\xfdo\x93\xd7Z\x0cV\x95y\xd1\xfd\x0fg\
@\x83\xe7[4w\xfd\x98o1`U\xe6E\xf7?\
\x9c\x01\x0d>\xb0\x85\x06\x1fh1`U\xe6E\xf7\xbf\
\xe6\x85\xa0\x09\xfa+cm\x18\xa7\xbf^>\x88*\xf3\
\xa2\x17\x82j\x16`\xab\xb5\x0d{5U\x99\x17-\xc0\
(|\x0c\x14\x1d\x22\x01\x92#\x01\x92#\x01\x92#\x01\
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\
\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92\
#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\x01\x92#\
\x01\x92#\x01\x92#\x01\x92#\x01\x92S\xb3\x00\xe3\x85\
\x1f[{^\x085\x0b\xf0x\xe1\xc7\xd6\x9e\x17B\x0d\
\x02\xacl\xf2\xfb\x17\xb7\x90\xd1\xe6\xb15\xe6m\xf6\xdc\
\xf3\xe0\xee?\xfa\xadYs\xf7C\xee\xce\x90\xe3p\xf3\
\xd8a\xd4\x98\xf7Ct\xff\xc3q\xf7/\x064\xb9\xd7\
4pPs\xaf\xb6\x18\xacZ\xf3\x96\xa2\xfb\x1f\x8e\xbb\
\x9f\x1e\xd2\xe45w_p\xf79w\xdf\xd3\x1cs\xcd\
\xef\xda\xbcRk\xce;\x1d\xdd\x7f\x8b.\xc0\xdd\x8f\x00\
\x9fE\xd7\x11\xc4\x113\xfb<\xb2\x80\x1a\x04\x18\x03\xae\
\x01\x0fD\xd7\xb2\xcd\xfc\x0e\xec6\xb3\xd5\xc8\x22\xc2?\
\x054\x0dX\x8c\xae#\x80\xc5\xe8\xc1\x87\x0af\x00\x00\
w\x9f\x04.\x02\xf7G\xd7\xb2M\xdc\x00\xf6\x99\xd9/\
\xd1\x85\x84\xcf\x00\x00M#NE\xd7\xb1\x8d\x9c\xaaa\
\xf0\xa1\x92\x19\x00\xc0\xdd\xef\x05\x96\x80g\xa2k\xe9\x98\
s\xc0A3\xfb+\xba\x10\xa8H\x00\xd8\xb8\x14\x9c\x07\
\xa6\xa3k\xe9\x88K\xc0\xac\x99\xfd\x1a]\xc8:U\x5c\
\x02\xd6i\xa6\xc5\xa3\xc0\xe5\xe8Z:\xe0\x12p\xac\xa6\
\xc1\x87\xca\x04\x000\xb3\x0b\xc0,\xf0et-\x059\
G\xff\x95\x7f!\xba\x90\xffS\x9d\x00\xb01\x13<\x0b\
\xbcI\xff\x1d\xf3\xa8r\x038I\xff\x9a_\xd5+\x7f\
dp\xf7Iw\x7f\xc7\xdd\xaf\xdf\xc62m\x14\xd7\xbd\
_\xf3dt\xff\x86Q\xd5\x9b\xc0Ax\x7f\xc5\xf0 \
p\x1cx\x0c\x98j\x8e\xe8\xcd\x16+@\xaf9\xbe\x07\
>\x02\x96jX\xe4i\xc3\xbf\xe5\xe8\x86\x9f\xd1G`\
\xf3\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x0f\xeb\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
\x00\x00\x04\xb3iTXtXML:com.\
adobe.xmp\x00\x00\x00\x00\x00<?\
xpacket begin=\x22\xef\
\xbb\xbf\x22 id=\x22W5M0MpCe\
hiHzreSzNTczkc9d\
\x22?>\x0a<x:xmpmeta x\
mlns:x=\x22adobe:ns\
:meta/\x22 x:xmptk=\
\x22XMP Core 5.5.0\x22\
>\x0a <rdf:RDF xmln\
s:rdf=\x22http://ww\
w.w3.org/1999/02\
/22-rdf-syntax-n\
s#\x22>\x0a <rdf:Desc\
ription rdf:abou\
t=\x22\x22\x0a xmlns:e\
xif=\x22http://ns.a\
dobe.com/exif/1.\
0/\x22\x0a xmlns:ti\
ff=\x22http://ns.ad\
obe.com/tiff/1.0\
/\x22\x0a xmlns:pho\
toshop=\x22http://n\
s.adobe.com/phot\
oshop/1.0/\x22\x0a \
xmlns:xmp=\x22http:\
//ns.adobe.com/x\
ap/1.0/\x22\x0a xml\
ns:xmpMM=\x22http:/\
/ns.adobe.com/xa\
p/1.0/mm/\x22\x0a x\
mlns:stEvt=\x22http\
://ns.adobe.com/\
xap/1.0/sType/Re\
sourceEvent#\x22\x0a \
exif:PixelXDime\
nsion=\x22128\x22\x0a e\
xif:PixelYDimens\
ion=\x22128\x22\x0a exi\
f:ColorSpace=\x221\x22\
\x0a tiff:ImageWi\
dth=\x22128\x22\x0a tif\
f:ImageLength=\x221\
28\x22\x0a tiff:Reso\
lutionUnit=\x222\x22\x0a \
tiff:XResoluti\
on=\x2296.0\x22\x0a tif\
f:YResolution=\x229\
6.0\x22\x0a photosho\
p:ColorMode=\x223\x22\x0a\
photoshop:ICC\
Profile=\x22sRGB IE\
C61966-2.1\x22\x0a x\
mp:ModifyDate=\x222\
023-09-26T10:35:\
58-07:00\x22\x0a xmp\
:MetadataDate=\x222\
023-09-26T10:35:\
58-07:00\x22>\x0a <x\
mpMM:History>\x0a \
<rdf:Seq>\x0a \
<rdf:li\x0a s\
tEvt:action=\x22pro\
duced\x22\x0a stE\
vt:softwareAgent\
=\x22Affinity Photo\
1.8.5\x22\x0a st\
Evt:when=\x222023-0\
9-26T10:35:58-07\
:00\x22/>\x0a </rdf\
:Seq>\x0a </xmpMM\
:History>\x0a </rd\
f:Description>\x0a \
</rdf:RDF>\x0a</x:x\
mpmeta>\x0a<?xpacke\
t end=\x22r\x22?>=\xbdO\xa5\x00\
\x00\x01\x81iCCPsRGB IEC6\
1966-2.1\x00\x00(\x91u\x91\xcbK\
BA\x14\x87\xbf\xb4\xb2'\x06E\xb4h!Q\xad4\
\xac@j\xd3B\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8\
]\xeeUB\xda\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\
\x0dZ\x07AQ\x04\xd1\xb6\xd6EmJn\xe7j\xa0\
D\xce0\xe7|\xf3\x9bs\x0e3g\xc0\x12L)i\
\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\x0b\xa1E\x87\xed\x85\
F:\xa9\x17k\x09+\xba:\x1b\x98\x08Ru|\xde\
Sc\xfa[\x97Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8\
i\x10\x1eST-+<%<\xb3\x96UM\xde\x11\
\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\
\xc4\xaf&'J\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecH\
Tp\xa4\x82\x95\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\
\xfb\x98/i\x89e\xe6\x03\xe2{du\xa3\xe3g\x12\
/\x0e\xa6\x19\xc7\x87\x87AF\xc5zp1\xc4\x80\xec\
\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8aX\x95<\x1a+$\
H\x92\xc5)jN\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\
\xfb\xff\xb7\xafz|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\
\xde\x07\xb6m(l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\
\x97\x99r\xfe\xea!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\
\x01\xe7We-\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\
\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\
\xcf~\xcf9y\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\
\xdb\x97\x7f\x00\x00<g\xb7\xa5uLy\x00\x00\x00\x09\
pHYs\x00\x00\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\
\x1b\x00\x00\x09QIDATx\x9c\xed\x9dm\xac\x1d\
E\x1d\xc6\x7f\xd3\xd2\xa4\x5cL\xe9[\xf4\xb6@bK\
jR+\x82\x97\x06M\xd4\x8a\xa0I\x05y\x11\xd2(\
*\xa9\x16\x1a4\xdaJ\xd5o\xb6A\x81\xa4FD\x10\
\xd1\x1a4HDI\x08E\xaa\xb5\x85/J\x14\x14$\
\x08\xb4\xe5\xe5\x03m\xb1\xb1\xedM\x0d\xa5\xd0\xda\x97\xdb\
\xda>~\x98\xad\x9eN\xf7\xbc\xdc=\xb3\xbbs\xce\xcc\
/\xd9\xe4\xec\x9eyy\xe6\xbf\xcf\x9e\x99\x9d\xdd\xb3k\
\xe8c$M\x06\x16\x01s\xb3\xe5\xec\x0e\xb3n\x01\x9e\
\xcd\x96{\x8d1o\x94\xa30Q\x1a\x92.\x934\xac\
\xee\x19\x96tY\xdd\xedI\x8c\x02I\xb7z\xd8\xf1.\
\xb7\xd4\xdd\xaeD\x07H\x9a'\xe9X\x09\x068&i\
^\xdd\xed\xf3\x8d\xa9[\x80O$\x0d\x00\x9b\x80\x99%\
U\xb1\x158\xc7\x18s\xa0\xa4\xf2+\xe7\x94\xba\x05x\
\xe6\x22:\xdb\xf9\xaf\x01O`\x07y`\x07\x88\xf3\x80\
w\xb6\xc97\x13\xf8(\xb0\xae\xa0\xbeD\x99H\xfav\
\x9b\x9f\xf1\xc3\x92n\x96t\x8a$\x9ce\x9c\xa4[\xb2\
4\xad\xb8\xa9\xeev&\x9a im\x9b>\xfc\x92\x9c\
\x1d\xef.\x97\xaa\xf5\x18bm\xdd\xed\xf4I\xadc\x00\
I\xa7\x02\xb3\x81\xe9\xc04`R\x83\xa6\xd7\x80]\x0d\
\xc9\xcf\x02>\xdc\xa6\xc8\xf7\x00\xa75\xf9n5pk\
\x87\xd2V\x00W7\xf9n?\xf0b\x9b\xfcO\x00\xff\
lX\x7f\x070#\xfb,`\x0f0\x0c\xec\x04^6\
\xc6\x1c\xeaPW\xef#\xe94I\x0b%=\x22i\x7f\
\x8b#m\xb1sd.h\xf3\xd3\xdc\x8a\xa3\x92&v\
p\xf4\x1f_&ey\x8a\xb2\xc0)oq\x8b\xb4\xfb\
ec\xb1PR3\xf3\x96\xc6\x98\xaa*\x92\xedw\xbf\
\x8c\x9de\xbb\x0f\xb8\x12\x18\xa8\xa8\xfaW\x817G\x91\
~\x0f\xb0\xb9$-.\x03\xd8X\xdc\x07l\x96\xf4%\
I\x95\x0d\xce+1\x80\xa4w\x03\x1b\x81\x9f`\x7f\x0e\
\xab\xe6\x85\x02y\x9e\xf7\xae\xa2=\x83\xc0*`c\x16\
\xb3\xd2)\xddi\x92.\x05\x1e\x00&4I\xb2\x03x\
\x09\xdb'\xbe\x0e\x1c\xcd\xb6o\xf0(c\xb0@\x9ei\
\x1e\xeb\xdf\x00|/\xfb<\x16\x98\x9a\x95?\x078#\
'\xfdl\xe0)I\xd7\x18c\xd6{\xd4Q-\x92>\
\xd3\xa4/= \xe96IC\x92:\x1a\x88\xaa\xbb1\
\xc0[\xea\xbc\xffG\x92\x91\xb4\xb7\x8b\xfa\x16t\xd8&\
#\x1b\x83\xdbdc\xe2rT\xd2\xa7\xbb\xdb\x0b5!\
in\x93F\xdd/iz\x81\xf2\xba1\x80dO\xef\
:5\xc0'\xbb\xac\xab#\x038\xed\x9b.\x1b\x1b\x97\
\x03\x92\xe6\x8e\xb6\xbcZ\x914E\xd2v\xa7!\xff\x91\
\xb4D\x1d\x1e\xf19evk\x80\xed\xea\xecL`\xa2\
\xa4\x1d]\xd65j\x03dm4\x92\x96\xea\xe4_\xcd\
\xed\x92\xa6\x14)\xb3\x16$\xdd\x99\x13\x94\x85]\x96\xd9\
\xad\x01$\xe9qIg\xa8\xf9\xce?3K\xd3-\x85\
\x0c\xd0\xd0\xd6/\xe4\x94yG7eV\x86\xa4\x19\x92\
F\x1c\xf1\xb7\xb7\xc93 i\xbe\xa4\xe5\x92\xd6H\xda\
&{^\xeck\x1e\xa0\x91=\xb2G\xd9\x1cIc\xb2\
eN\xb6m\x8f\xa7:\xdcy\x80\x85\xb2mZ#\xdb\
\xc6\xf9\xb2\x17\xaeZ\xc5\xe4v\xa7\xcc\x11I3Z\xe5\
\x09\x02I\xab\x1c\xe1\xdb$\x8do\x91\xfeBI[s\
\x82\xe8s\x22\xa8\x19\xfb\xb2\xc57\x9dL\x04m\x95\xf4\
\x91\x16q\x19/\x1b\xbbFV\xf9\xde_^\xe7\x01$\
\x8d\x01>\xe5l^\x9e7\xd5){\xd4\xdf\x05\xfc\x91\
\xffO\x93V\xcd\xdb\xb2\xa5\x0ef\x00\x8fK\xfa\xa1r\
~\x0d\xb2\x98\xadp6_\xa9\x82c\xa8f\xf8\x9e\x08\
\xfa\x00'N\xf4\xec\x05\x1el\x92\xf6~`\x09}v\
O\xc2(1\xc0R\xe0\x97M\xbe\x7f\x10\xd8\xd7\xb0>\
\x88\x8d\xb17|\x1b\xe0\x22g}\xbd1\xe6\xb0\x9bH\
\xd2g\x81\xab<\xd7\xdd\xcb\x5c-\xe9\x1aw\xa31f\
\x04p'\x82.\xf6Y\xb1o\x03\x9c\xe5\xac\xff\xd5M\
i\x1a\xf0#\xcf\xf5\xf6\x03wK\xca\x9b\xb1tc\
x\xa6\xcfJ}\x1b\xc0\x9d\xe0\xd9\x99\x93f\x190\xb9\
@\xd9*\x90\xa7.\x8ah\x9d\x8c\x8d\x8d\xcb\x0eg}\
\xd4\x93h\xad\xf0m\x80\xb7;\xeb\xc39i.(X\
\xf6\xb6\x82\xf9\xea\xe0\x1f\x05\xf3\xe5\xc5\xc6\x8d\xa1\xd7\x8b\
i\xbe\x0d\xe0^\x5c\x1ai\x5c\xc9F\xb0\xef+X\xf6\
F\xe0H\xc1\xbcUr\x18\xab\xb5\x08y\xd7FF\x9c\
u\xaf\x17\xf0*\xbb\x1f \xe3]4\xbf*\xd8\x8e\x11\
\xe0a\x8fZ\xca\xe27X\x13\x14a\x020\xcb\xa3\x96\
\xb6Tm\x80n\x070K8\xf16\xb1\xd0\xd8\x05|\
\xb5\xcb2\xdc\x81t\xa9Tm\x80\xd1\x9c\xf3O\xcd\xd9\
\xf6:\xf0q\x8a\xdd\xe0Q6/`\xb5\xed\xce\xf9.\
\xaf-\xcd\xa8t^$\xe4\xff\x05\x0c5\xd9\xbe\x09;\
X\xfa\x22\xf0A\xec\x98\xa2\xae\xd9\xbc\x7fc\xef\x1c\xfa\
\x0b\xf0\x0b\x9a\x8fQ\xce\xafL\xd1(\x09\xd9\x00\x1f\x02\
N\x05\x0e\xe6|w\x04\xb8'[Bg\x00k\xd4 \
\xa9\xba\x0b\x18\x0d\x83\xc0\xca\xbaEx`%\xc5nI\
\xab\x84\x90\x0d\x00v\x9e\xfc\x86\xbaEt\xc1\x0d\xd8\x81\
k\xb0\x84n\x00\x03\xfc\x14x\x14\xb8\x90\xe2\xa7\x90U\
2\x01\xab\xf51\xac\xf6\xa0/v\x85<\x06hd~\
\xb6\x08\xd8N\xb8\x13B\xe3\xb0\xa7\xbaA\xef\xf4Fz\
\xc5\x00\xc71T|\x9e\xdc\xef\x84\xde\x05$J&\x19\
r\x92\x01\x22'\x19 r\x92\x01\x22'\x19 r\
\x92\x01\x22'\x19 r\x92\x01\x22'\x19 r\x92\x01\
\x22'\x19 r\x92\x01\x22'\x19 r\x92\x01\x22'\
\xb4\xfb\x01v\x03\xdf\xa4\xf8?kB\xe7\xbd\xc0\xf7\x81\
`\x9e\xf7\x13\x9a\x01\xee\xc4>1\xb3_y\x0e{C\
\xcb\xcdu\x0b9Nh]\xc0cu\x0b\xa8\x80G\xeb\
\x16\xd0Hh\x068V\xb7\x80\x0a\x08\xaa\x8d\xa1\x19 \
Q1\xc9\x00\x91\x13\x9a\x01\xc6\xd6-\xa0\x02\x82jc\
h\x06\xb8\xbcn\x01\x15pE\xdd\x02\x1a\x09\xed4p\
)\xf6e\x0d\x1b\xe9\xadg\x02u\x82\xc1\xce\x03\x04\xf5\
W\xb1\xd0\x0c0\x01h\xf9X\xd9\x84_B\xeb\x02\x12\
\x15\x93\x0c\x109\xc9\x00\x91\x93\x0c\x109\xc9\x00\x91\x93\
\x0c\x109\xc9\x00\x91\x13\xda<\xc0^\xe0;\xf8}g\
`H\x9c\x0b\xdcD@\x8f\xba\x09\xcd\x00w\x01?\xa8\
[D\x89\xfc\x01\xfbT\xf0o\xd5-\xe48\xa1u\x01\
\xbf\xab[@\x05\xfc\xb6n\x01\x8d\x84f\x80\xa3\xed\x93\
\xf4<A\xb514\x03$*&\x19 rB3@\
hz\xca \xa86\x06%\x06\xfb0\xc8~\xe7\x13u\
\x0bh$\xb4\xd3\xc0\x1b\xb1/I\xea\xe7\x1bB\xbeV\
\xb7\x90FB3\xc0\x14\xe0\xde\xbaE\xc4Dh]@\
\xa2b\x92\x01\x22'\x19 r\x92\x01\x22'\x19 r\
\x92\x01\x22'\x19 rB\x9b\x078\x88\xfdcH?\
\xdf\x10\xf2\x0d\xec\xeb\xf0\x82 4\x03\xdc\x0d\xac\xa8[\
D\x89\xac\x06\xc6c\x1f\x83\x13\x04\xa1u\x01\xbd\xf0r\
\xe8nY]\xb7\x80FB3@\xa8o\x03\xf3IP\
m\x0c\xcd\x00\x89\x8aI\x06\x88\x9cd\x80\xc8\x09\xcd\x00\
\x17\xd7-\xa0\x02>V\xb7\x80FB;\x0d\xfc:\xf0\
*\xfd}C\xc8\xb2\xba\x854\x12\x9a\x01\x06\x81G\xea\
\x16\x11\x13\xa1u\x01\x89\x8aI\x06\x88\x9cd\x80\xc8I\
\x06\x88\x9cd\x80\xc8I\x06\x88\x9cd\x80\xc8I\x06\x88\
\x9cd\x80\xc8I\x06\x88\x9cd\x80\xc8I\x06\x88\x9cd\
\x80\xc8I\x06\x88\x9c\xaa\x0dp\xa8\xe2\xfaz\x91Jc\
T\xb5\x01^\xa4\xffn\xf4\xf0\x89\x80MUVX\xb6\
\x01\xcc\x09+\xc6\xbc\x09l)\xb9\xce^f\xb31\xe6\
-g\x9b\xc9M\xe9\x09\xdf\x06\xd8\xe7\xacO\xcaI\xf3\
w\xcfu\xf6\x13y\xb1\x99\xec\xac\xbb1\xee\x0a\xdf\x06\
\x18v\xd6\xa7\xe7\xa4Y\xeb\xb9\xce~\x22/6n\x0c\
w\xfa\xac\xd0\xb7\x01\x5cqg\xbb\x09\x8c1\xbf\x06\xd6\
{\xae\xb7\x1fXg\x8cy g\xbb\x1b\xc3\xa0\x0d\xf0\
\x92\xb3~I\x93t\x8b\xb1\xef\x07LX\xf6`c\x92\
\x87\x1b\xc3\x97}V\xec\xdb\x00\xeb8\xf1\xed\xd8\xe7K\
\x9a\xe1&2\xc6\xec\x04\x16\x01\x07<\xd7\xdf\x8b\xec\x07\
\x16\x19c\xdc\xee\x93,vC\x0d\x9b\x8e\x01\xbf\xf7Y\
\xb9W\x03\x18cv\x01O;\x9b\x977I\xbb\x06{\
\x9f\xfc\x93>5\xf4\x18O\x02\xe7f\xb1\xc8\xc3\xfd\xab\
\xfcS\xc6\x98\x7f\x95\xac\xa9;$]\xa7\x139*\xe9\
\xbc\x16\xe9\xc7HZ&\xe9yIG\xd4\xff\x1c\x91m\
\xeb\x8d\x92\x9a\x1e\x80\x92\xce\x93t\xcc\xc9{\x9d\xef\xfd\
\xe5\xfd\x1cS\xd2X\xecd\xc6\xec\x86\xcd[\x80\xf7\x1b\
cv\xb7\xc9;\x1e\xfb\x14\x8d!\x02z\xad\x8a'\xf6\
\x02\xcf\x01\x1b\x8c1-g\xfb$M\x01\x9e\x01f6\
l~\x058\xc7\x18\x13\xd4\xfb\x06r\x91ty\x8e\xf3\
\xff$\xe9\xf4\xba\xb5\x85\x8e\xa4\xd3\xb3X\xb9\xf4\xd6\x9b\
\xd5%\xfd,\xa7\x11\xafH\x9aU\xb7\xb6P\x914+\
\x8b\x91\xcb=uk\x1b5\x92\xc6I\xfasNc\x0e\
JZ)ib\xdd\x1aCA\xd2DI\xdf\xcdb\x93\
\xf7\xcb9\xaen\x8d\x85\x904U\xd2\xdfr\x1a%I\
\xfb$=$\xe9ZIC\x92\x06e\xc7\x0f}\x8d\xa4\
\xb1Y[\x87\xb2\xb6?\x94\xc5\x22\x8f\xa7%M-S\
O\xa9\x17\x1a\xe0\x7f\x03\xbb\x9f\x03\x9f+\xbb\xae>\xe3\
W\xc0\xe2v\x03\xc6n)\xfdr\xb01\xe6\x901\xe6\
\xf3\xc0\xf5\x9c|\xad q2\xc3\xc0\xf5\xc6\x98k\xcb\
\xde\xf9P\xc1/@#\x92\x06\xb0\x0fH\xf8\x0a0\xad\
\xca\xba{\x80a\xe0\xc7\xc0\x1d\xc6\x98\xcafH+5\
\xc0q$\x19\xe0\x02\xe0\x0a`.\xf6\x8a\xd74N\xbe\
\xf4\xd9\xaf\xbc\x81\xdd\xe1;\x81g\xb1/\x93|\xc6\x18\
S\xf9\xcd2\xff\x05\xbdH\xeb\x8d\xef9$\xce\x00\x00\
\x00\x00IEND\xaeB`\x82\
\x00\x00\x22\x7f\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -1005,198 +560,6 @@ z\x03\xff\x0a\xf8\x5cc\xfexFf\xe7uI\xc6\xc7\
=\x82\xf7\x0b\xc0\xe3\xc0\xcd\xc0y\xe0%\xe0\xcf\xb7\xfa\
\xb8\x8a\xab\xb8\x8a\xcd\x82\xff\x0fR\xb9\xc7\x85|\xfd\
\xc5\xb0\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x0b\xd2\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
\x00\x00\x04\xb3iTXtXML:com.\
adobe.xmp\x00\x00\x00\x00\x00<?\
xpacket begin=\x22\xef\
\xbb\xbf\x22 id=\x22W5M0MpCe\
hiHzreSzNTczkc9d\
\x22?>\x0a<x:xmpmeta x\
mlns:x=\x22adobe:ns\
:meta/\x22 x:xmptk=\
\x22XMP Core 5.5.0\x22\
>\x0a <rdf:RDF xmln\
s:rdf=\x22http://ww\
w.w3.org/1999/02\
/22-rdf-syntax-n\
s#\x22>\x0a <rdf:Desc\
ription rdf:abou\
t=\x22\x22\x0a xmlns:e\
xif=\x22http://ns.a\
dobe.com/exif/1.\
0/\x22\x0a xmlns:ti\
ff=\x22http://ns.ad\
obe.com/tiff/1.0\
/\x22\x0a xmlns:pho\
toshop=\x22http://n\
s.adobe.com/phot\
oshop/1.0/\x22\x0a \
xmlns:xmp=\x22http:\
//ns.adobe.com/x\
ap/1.0/\x22\x0a xml\
ns:xmpMM=\x22http:/\
/ns.adobe.com/xa\
p/1.0/mm/\x22\x0a x\
mlns:stEvt=\x22http\
://ns.adobe.com/\
xap/1.0/sType/Re\
sourceEvent#\x22\x0a \
exif:PixelXDime\
nsion=\x22128\x22\x0a e\
xif:PixelYDimens\
ion=\x22128\x22\x0a exi\
f:ColorSpace=\x221\x22\
\x0a tiff:ImageWi\
dth=\x22128\x22\x0a tif\
f:ImageLength=\x221\
28\x22\x0a tiff:Reso\
lutionUnit=\x222\x22\x0a \
tiff:XResoluti\
on=\x2296.0\x22\x0a tif\
f:YResolution=\x229\
6.0\x22\x0a photosho\
p:ColorMode=\x223\x22\x0a\
photoshop:ICC\
Profile=\x22sRGB IE\
C61966-2.1\x22\x0a x\
mp:ModifyDate=\x222\
023-09-26T10:22:\
11-07:00\x22\x0a xmp\
:MetadataDate=\x222\
023-09-26T10:22:\
11-07:00\x22>\x0a <x\
mpMM:History>\x0a \
<rdf:Seq>\x0a \
<rdf:li\x0a s\
tEvt:action=\x22pro\
duced\x22\x0a stE\
vt:softwareAgent\
=\x22Affinity Photo\
1.8.5\x22\x0a st\
Evt:when=\x222023-0\
9-26T10:22:11-07\
:00\x22/>\x0a </rdf\
:Seq>\x0a </xmpMM\
:History>\x0a </rd\
f:Description>\x0a \
</rdf:RDF>\x0a</x:x\
mpmeta>\x0a<?xpacke\
t end=\x22r\x22?>\x84\x19G\xf4\x00\
\x00\x01\x81iCCPsRGB IEC6\
1966-2.1\x00\x00(\x91u\x91\xcbK\
BA\x14\x87\xbf\xb4\xb2'\x06E\xb4h!Q\xad4\
\xac@j\xd3B\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8\
]\xeeUB\xda\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\
\x0dZ\x07AQ\x04\xd1\xb6\xd6EmJn\xe7j\xa0\
D\xce0\xe7|\xf3\x9bs\x0e3g\xc0\x12L)i\
\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\x0b\xa1E\x87\xed\x85\
F:\xa9\x17k\x09+\xba:\x1b\x98\x08Ru|\xde\
Sc\xfa[\x97Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8\
i\x10\x1eST-+<%<\xb3\x96UM\xde\x11\
\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\
\xc4\xaf&'J\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecH\
Tp\xa4\x82\x95\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\
\xfb\x98/i\x89e\xe6\x03\xe2{du\xa3\xe3g\x12\
/\x0e\xa6\x19\xc7\x87\x87AF\xc5zp1\xc4\x80\xec\
\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8aX\x95<\x1a+$\
H\x92\xc5)jN\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\
\xfb\xff\xb7\xafz|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\
\xde\x07\xb6m(l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\
\x97\x99r\xfe\xea!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\
\x01\xe7We-\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\
\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\
\xcf~\xcf9y\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\
\xdb\x97\x7f\x00\x00<g\xb7\xa5uLy\x00\x00\x00\x09\
pHYs\x00\x00\x0e\xc4\x00\x00\x0e\xc4\x01\x95+\x0e\
\x1b\x00\x00\x058IDATx\x9c\xed\x9bK\x8b\x1c\
U\x00\x85\xcf\xcd\xc4<@D\xc4\x8d\x10q\xa3bD\
\xc5\x08\x12D0\x1b]\x88n\xb2PA\x08\x82[E\
\xd0\x9d\x0b\x17\xfa\x07\x5cI@\xb7\x09b\x16\xd9\xa8\x88\
Y\x08&\x111\x88\x1a\x18P#\x86(D\x02\xf1\x11\
\xc1\xbc\x93\xf9\x5c\xd4\xb43\xd3Vu\xdf\x9ez\xdc\xaa\
\xdb\xe7\x83\xda\xf4\xf4t\xdf9\xdf\xa9{\xeftW\x05\
\x99\xe4\x007H\xda.\xe9\x1eI?KZ\x0c!\x5c\
N;*\xd3:\xc0\xdd\xc0!\xe0\x12k\xb9\x0a\x1c\x05\
v\xa4\x1e\xa3i\x01`\x01x\x0d\xb8\xc8d\xae\x02o\
\x01\x9bR\x8f\xd94\xc4\xb2\xfc}S\xc4\x8f\xf3\x09\xb0\
%\xf5\xd8MM\xd6)\xdf%\xc8\x81\x9a\xf2]\x82!\
\xd3\x90|\x97`\x884,\xdf%\x18\x12-\xc9w\x09\
\x86\xc0\x8c\xf2\x7f\x04\xf6\x00\xf7\x01\x8f\x03\xef\x02\xd7]\
\x82\x812\xa3\xfc\x03\xc0\x16@c\xc7S\xc0\x05\x97`\
`\xcc(\xff\x83\xe5\xe7\x8f\xcb\x1f\x1dO\xb8\x04\x03\xa2\
a\xf9.\xc1\x90hI\xbeK0\x04Z\x96\xef\x12\xf4\
\x99\x8e\xe4\xbb\x04}\xa4c\xf9\xa3\xe3\xe5\xc8\xf7s\x09\
\xda$\x91\xfc\xd1q\xca%HHb\xf9\x02\x0eF\xbe\
\xb7K\xd04=\x90/\xe0\xf0\x0c\x05p\x09\x9a\xa2'\
\xf2\xb7\x01\xe7g,\x80KP\x97\x9e\xc8_\xa0\xb8\x86\
p\xbd\xb8\x04\xeb\xa1G\xf2\x0f\xd4\x90\xbf\xba\x04\xbe\xc6\
0\x96\xcc\xe4\x8fx3u\xae\x83\x80<\xe5Cq\xb5\
\xb1/9\x9f\x04\xf9\xca\x1fq$u\xc6\xbd\x85\xfc\xe5\
\x03\x5c\x016\xa7\xce\xbaw0\x1f\xf2G<\x94:\xef\
^\xc1|\xc9\x07x6u\xe6\xbd\x81\xf9\x93\x0fp\x7f\
\xea\xdc{\x01\xf3)\xff\x12\xc5]\xc9\xf3\x0d\xf3)\x1f\
\xe0\xd3\xd4\xd9'\x87\xf9\x95\x7f\x11\xb8+u\xfeIa\
~\xe5\x03\xbc\x9a:\xff\xa40\xdf\xf2\xf7\x01\x0b\xa9\x1d\
$\x03\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\x8f\x0c\xcb\
\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\x8f\x0c\
\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\x8f\
\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\xcb\
\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0|\
\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\xb0\
|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?'\
\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,?\
'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0,\
?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\xb0\
,?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\xc8\
\xb0,?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\xfc\
\xc8\xb0,?'\xb0|\xcb\x8f\x0c\xcb\xf2s\x02\xcb\xb7\
\xfc\xc8\xb0,?7\x80\x87#\xc3\xb2\xfc\x81\xb0a\xc6\
\xe7\xef\x8ax\xce\x01I\xcfK\xba>\xfbp&\xb2 \
\xe9}I\xcf4\xfc\xba\xd3\xd8/\xe9\x85\x10B\xd3\x7f\
\xcf\xf0\x00>\x9cr\xa6\xf8\xcc\xcf\x95e\x09\xe7&\x84\
e\xf99\x03<8!,\xcb\xcf\x1d\xe0\x95\x8a\xb0N\
a\xf9\x83e\x96M`\xd5\x06\xf0#y\xc3\x977@\
\x00\xceV\x9c1O\xe23?o\x80{+B;\x0f\
l\xc1\xf2\x07K\xec\x12\xf0X\xc5\xe3\x9fI\xba\xd4\xd0\
X<\xed' \xb6\x00U\xeb\xff\xc7\x0d\x8d\xc3\xf2\xfb\
\x0a\xc5\xfa\x7f\xbad\xda\x5c\x02n\xc7\xd3~\xde\x00w\
V\x04x\x1c\xcb\x1f<1K@\xd5\xfa_w\xfa\xf7\
\xb4\xdf\x03b\x0a\xd0\xc6\xfao\xf9C\x81\xe2\x93\xbeq\
~\x076\xe0i?o\x80;*\xc2\xdc\xcf\xfa\xe4\xdf\
\x02\x1clQr\x15\x96_\xc1\xb4%\xa0\xa9\xf5\x7f\x83\
\xa4\x97$\xfd$i\xf7\x8c\xbf[\x17O\xfb\xeb\x05x\
\xaf\xe4l\xbaFq&\xc7\x9e\xf5\xbb(\xfecH\x81\
\xcf\xfc:\x00'JB=J\x9c\xf8m\x14_\x13/\
u \xba\x0c\xcb\xaf\x03p[E\xb0\xaf3Y\xfcf\
\xe0\x0d\xe0\x9f\xd6\x15Wc\xf9u\x01\x9e\xab\x08\xf7\x01\
\xaa\xe5\xef\x06Nv x\x12\x96\xdf\x04\xc0;%\xe1\
\xfeB\xb9\xf8\xed\xc0\xa1\xae\x0cO\xc0\xf2\x9b\x02X,\
\x09x/k\xc5\xdf\x04\xbc\x0d\x5c\xe9Lq5\x96\xdf\
\x14\xc0\xad\x15!?M!>\x00/\x02g:\x92;\
\x0d\xcbo\x12\x8a\xb5|\x9c\x0b\xc0V`'p\xacK\
\xbbS\xb0\xfc\x1al\xacx\xbc\xec\xf3\xff\xef%\xed\x95\
\xb4G\xb3\xdfP\xd2\x16\xfe\x90\xa7&\xa1\xecA\xe0\x1b\
I;:\x1e\xcb\xacX~\x03\xfc\xaf\x00\xc0\xcd\x92\xfe\
,\xfbY\x8f\xb0\xfc\x86(\x9b\xca\x1f\x95\xe5\xcf\x0de\
\x05\x88\xb9\x014\x15\x96\xdf0e\x9b\xc0\xaao\x00S\
\xf0\x97\xa4#\x92\x0eK\xfa\x5c\xd2\xb7\x96\xdf,k\xa6\
z\xe0FI\xe7T\x5c\xb1\x93\x82\xb3*D\x8f\x84/\
\x86\x10\x96\x12\x8de.\x18\x9f\x01\x1eQ\xb7\xf2\x7fS\
!z$\xfd\x87\x10\x02\x1d\xbe\xff\xdc3^\x80\xb6\xd7\
\xffSZ+\xfc\xa4\x85\xa7e\xbc\x00M\xaf\xff'\xb4\
\x22\xfbp\x08\xe1\xd7\x86_\xdf\xd4\xe4\xbf=\x00\xb0U\
\xc5\xfa\xbf\xa9\xc6\xeb-j\xad\xf03\xf5\x86g\xdaf\
\xf5\x0c\xb0S\xb3\xc9_\x92\xf4\x9dV\x84\x1f\x09!\xfc\
\xd1\xe0\xd8L\x07\xac.\xc0\xb4\xe9\xff\x9a\xa4\xaf\xb5\xb2\
C\xff\x22\x84\xf0w[\x033\xdd\xb0\xba\x00\xe3\x1b\xc0\
\xcb\x92\xbe\xd2\x8a\xf0/C\x08\xe7\xbb\x1a\x98\xe9\x86 \
I\xc0&I\xa7%\x1d\xd7\xca.\xfdX\x08\xa1\xa9[\
\xbfM\x9f\x016.\x97\xc0\x183O\xfc\x0b` \xde\
Vl\x1c\xb1\x12\x00\x00\x00\x00IEND\xaeB`\
\x82\
\x00\x03\x95\x93\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -16837,26 +16200,11 @@ qt_resource_name = b"\
\x07\x03}\xc3\
\x00i\
\x00m\x00a\x00g\x00e\x00s\
\x00\x12\
\x01M\xb0\xa7\
\x00t\
\x00r\x00a\x00s\x00h\x00_\x00i\x00c\x00o\x00n\x00_\x001\x002\x008\x00.\x00p\x00n\
\x00g\
\x00\x16\
\x05\xc3U\xa7\
\x00c\
\x00l\x00i\x00p\x00b\x00o\x00a\x00r\x00d\x00_\x00i\x00c\x00o\x00n\x00_\x001\x002\
\x008\x00.\x00p\x00n\x00g\
\x00\x18\
\x07\xf3~\xc7\
\x00s\
\x00t\x00a\x00r\x00_\x00i\x00c\x00o\x00n\x00_\x00f\x00i\x00l\x00l\x00e\x00d\x00_\
\x001\x002\x008\x00.\x00p\x00n\x00g\
\x00\x11\
\x0e9\xdd\x87\
\x00e\
\x00d\x00i\x00t\x00_\x00i\x00c\x00o\x00n\x00_\x001\x002\x008\x00.\x00p\x00n\x00g\
\
\x00\x0a\
\x08\x94\x19\x07\
\x00s\
@@ -16881,24 +16229,18 @@ qt_resource_name = b"\
qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x08\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x01\x1a\x00\x00\x00\x00\x00\x01\x00\x03\xf2%\
\x00\x00\x00\x96\x00\x00\x00\x00\x00\x01\x00\x03\xca\xbe\
\x00\x00\x01\x8a\xfb\xb4\xd6\xbe\
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x8a\xd2\x87V\xef\
\x00\x00\x00\xe6\x00\x00\x00\x00\x00\x01\x00\x03\xdf\x81\
\x00\x00\x00b\x00\x00\x00\x00\x00\x01\x00\x03\xb8\x1a\
\x00\x00\x01\x8a\xfb\xc6t\x9f\
\x00\x00\x01N\x00\x00\x00\x00\x00\x01\x00\x04\x0c\x00\
\x00\x00\x00\xca\x00\x00\x00\x00\x00\x01\x00\x03\xe4\x99\
\x00\x00\x01\x8a\xfb\xb4\xc1\x95\
\x00\x00\x00<\x00\x00\x00\x00\x00\x01\x00\x00\x0b\xa2\
\x00\x00\x01\x8a\xd2\x8f\xdf\xf1\
\x00\x00\x00n\x00\x00\x00\x00\x00\x01\x00\x00\x1b\x91\
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x8a\xfb\xc6\x86\xda\
\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00I\xea\
\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\
\x00\x00\x01\x8e\xfd%\xc3\xc7\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x00>\x14\
\x00\x00\x01\x8a\xd2\x83?\x9d\
"
def qInitResources():

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,180 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import traceback
from pathlib import Path
import cv2
from PIL import Image, ImageChops, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import (
QObject,
QThread,
Signal,
QRunnable,
Qt,
QThreadPool,
QSize,
QEvent,
QTimer,
QSettings,
)
from src.core.library import Library
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
class CollageIconRenderer(QObject):
rendered = Signal(Image.Image)
done = Signal()
def __init__(self, library: Library):
QObject.__init__(self)
self.lib = library
def render(
self,
entry_id,
size: tuple[int, int],
data_tint_mode,
data_only_mode,
keep_aspect,
):
entry = self.lib.get_entry(entry_id)
filepath = self.lib.library_dir / entry.path / entry.filename
file_type = os.path.splitext(filepath)[1].lower()[1:]
color: str = ""
try:
if data_tint_mode or data_only_mode:
color = "#000000" # Black (Default)
if entry.fields:
has_any_tags: bool = False
has_content_tags: bool = False
has_meta_tags: bool = False
for field in entry.fields:
if self.lib.get_field_attr(field, "type") == "tag_box":
if self.lib.get_field_attr(field, "content"):
has_any_tags = True
if self.lib.get_field_attr(field, "id") == 7:
has_content_tags = True
elif self.lib.get_field_attr(field, "id") == 8:
has_meta_tags = True
if has_content_tags and has_meta_tags:
color = "#28bb48" # Green
elif has_any_tags:
color = "#ffd63d" # Yellow
# color = '#95e345' # Yellow-Green
else:
# color = '#fa9a2c' # Yellow-Orange
color = "#ed8022" # Orange
else:
color = "#e22c3c" # Red
if data_only_mode:
pic = Image.new("RGB", size, color)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
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(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 filepath.suffix.lower() in IMAGE_TYPES:
try:
with Image.open(
str(self.lib.library_dir / entry.path / entry.filename)
) as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = pic.convert(mode="RGB")
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except DecompressionBombError as e:
logging.info(f"[ERROR] One of the images was too big ({e})")
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),
)
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
with Image.fromarray(frame, mode="RGB") as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = ImageChops.hard_light(
pic, Image.new("RGB", size, color)
)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except (UnidentifiedImageError, FileNotFoundError):
logging.info(
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
)
with Image.open(
str(
Path(__file__).parents[2]
/ "resources/qt/images/thumb_broken_512.png"
)
) as pic:
pic.thumbnail(size)
if data_tint_mode and color:
pic = pic.convert(mode="RGB")
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except KeyboardInterrupt:
# self.quit(save=False, backup=True)
run = False
# clear()
logging.info("\n")
logging.info(f"{INFO} Collage operation cancelled.")
clear_scr = False
except:
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
traceback.print_exc()
logging.info("Continuing...")
self.done.emit()
# logging.info('Done!')
def get_file_color(self, ext: str):
if ext.lower().replace(".", "", 1) == "gif":
return "\033[93m"
if ext.lower().replace(".", "", 1) in IMAGE_TYPES:
return "\033[37m"
elif ext.lower().replace(".", "", 1) in VIDEO_TYPES:
return "\033[96m"
elif ext.lower().replace(".", "", 1) in DOC_TYPES:
return "\033[92m"
else:
return "\033[97m"

View File

@@ -0,0 +1,206 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
import os
from types import FunctionType, MethodType
from pathlib import Path
from typing import Optional, cast, Callable, Any
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
class FieldContainer(QWidget):
# TODO: reference a resources folder rather than path.parents[3]?
clipboard_icon_128: Image.Image = Image.open(
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(
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(
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()
def __init__(self, title: str = "Field", inline: bool = True) -> None:
super().__init__()
# self.mode:str = mode
self.setObjectName("fieldContainer")
# self.item = item
self.title: str = title
self.inline: bool = inline
# self.editable:bool = editable
self.copy_callback: FunctionType = None
self.edit_callback: FunctionType = None
self.remove_callback: Callable = None
button_size = 24
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
self.root_layout = QVBoxLayout(self)
self.root_layout.setObjectName("baseLayout")
self.root_layout.setContentsMargins(0, 0, 0, 0)
# self.setStyleSheet('background-color:red;')
self.inner_layout = QVBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(0, 0, 0, 0)
self.inner_layout.setSpacing(0)
self.inner_container = QWidget()
self.inner_container.setObjectName("innerContainer")
self.inner_container.setLayout(self.inner_layout)
self.root_layout.addWidget(self.inner_container)
self.title_container = QWidget()
# self.title_container.setStyleSheet('background:black;')
self.title_layout = QHBoxLayout(self.title_container)
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_layout.setObjectName("fieldLayout")
self.title_layout.setContentsMargins(0, 0, 0, 0)
self.title_layout.setSpacing(0)
self.inner_layout.addWidget(self.title_container)
self.title_widget = QLabel()
self.title_widget.setMinimumHeight(button_size)
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
# self.title_widget.setStyleSheet('background-color:orange;')
self.title_widget.setText(title)
# self.inner_layout.addWidget(self.title_widget)
self.title_layout.addWidget(self.title_widget)
self.title_layout.addStretch(2)
self.copy_button = QPushButton()
self.copy_button.setMinimumSize(button_size, button_size)
self.copy_button.setMaximumSize(button_size, button_size)
self.copy_button.setFlat(True)
self.copy_button.setIcon(
QPixmap.fromImage(ImageQt.ImageQt(self.clipboard_icon_128))
)
self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.copy_button)
self.copy_button.setHidden(True)
self.edit_button = QPushButton()
self.edit_button.setMinimumSize(button_size, button_size)
self.edit_button.setMaximumSize(button_size, button_size)
self.edit_button.setFlat(True)
self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.edit_button)
self.edit_button.setHidden(True)
self.remove_button = QPushButton()
self.remove_button.setMinimumSize(button_size, button_size)
self.remove_button.setMaximumSize(button_size, button_size)
self.remove_button.setFlat(True)
self.remove_button.setIcon(
QPixmap.fromImage(ImageQt.ImageQt(self.trash_icon_128))
)
self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.title_layout.addWidget(self.remove_button)
self.remove_button.setHidden(True)
self.field_container = QWidget()
self.field_container.setObjectName("fieldContainer")
self.field_layout = QHBoxLayout()
self.field_layout.setObjectName("fieldLayout")
self.field_layout.setContentsMargins(0, 0, 0, 0)
self.field_container.setLayout(self.field_layout)
# self.field_container.setStyleSheet('background-color:#666600;')
self.inner_layout.addWidget(self.field_container)
# self.set_inner_widget(mode)
def set_copy_callback(self, callback: Optional[MethodType]):
try:
self.copy_button.clicked.disconnect()
except RuntimeError:
pass
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Optional[MethodType]):
try:
self.edit_button.clicked.disconnect()
except RuntimeError:
pass
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Optional[Callable]):
try:
self.remove_button.clicked.disconnect()
except RuntimeError:
pass
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
def set_inner_widget(self, widget: "FieldWidget"):
# widget.setStyleSheet('background-color:green;')
# self.inner_container.dumpObjectTree()
# logging.info('')
if self.field_layout.itemAt(0):
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
# self.field_layout.removeItem(self.field_layout.itemAt(0))
self.field_layout.itemAt(0).widget().deleteLater()
self.field_layout.addWidget(widget)
def get_inner_widget(self) -> Optional["FieldWidget"]:
if self.field_layout.itemAt(0):
return cast(FieldWidget, self.field_layout.itemAt(0).widget())
return None
def set_title(self, title: str):
self.title = title
self.title_widget.setText(title)
def set_inline(self, inline: bool):
self.inline = inline
# def set_editable(self, editable:bool):
# self.editable = editable
def enterEvent(self, event: QEnterEvent) -> None:
# if self.field_layout.itemAt(1):
# self.field_layout.itemAt(1).
# NOTE: You could pass the hover event to the FieldWidget if needed.
if self.copy_callback:
self.copy_button.setHidden(False)
if self.edit_callback:
self.edit_button.setHidden(False)
if self.remove_callback:
self.remove_button.setHidden(False)
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
if self.copy_callback:
self.copy_button.setHidden(True)
if self.edit_callback:
self.edit_button.setHidden(True)
if self.remove_callback:
self.remove_button.setHidden(True)
return super().leaveEvent(event)
class FieldWidget(QWidget):
field = dict
def __init__(self, title) -> None:
super().__init__()
# self.item = item
self.title = title

View File

@@ -0,0 +1,497 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
import os
import time
import typing
from types import FunctionType
from pathlib import Path
from typing import Optional
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QSize, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QBoxLayout,
QCheckBox,
)
from src.core.library import ItemType, Library, Entry
from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.widgets.thumb_renderer import ThumbRenderer
from src.qt.widgets.thumb_button import ThumbButton
if typing.TYPE_CHECKING:
from src.qt.widgets.preview_panel import PreviewPanel
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)
class ItemThumb(FlowWidget):
"""
The thumbnail widget for a library item (Entry, Collation, Tag Group, etc.).
"""
update_cutoff: float = time.time()
collation_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[3] / "resources/qt/images/collation_icon_128.png")
)
collation_icon_128.load()
tag_group_icon_128: Image.Image = Image.open(
str(Path(__file__).parents[3] / "resources/qt/images/tag_group_icon_128.png")
)
tag_group_icon_128.load()
small_text_style = (
f"background-color:rgba(0, 0, 0, 192);"
f"font-family:Oxanium;"
f"font-weight:bold;"
f"font-size:12px;"
f"border-radius:3px;"
f"padding-top: 4px;"
f"padding-right: 1px;"
f"padding-bottom: 1px;"
f"padding-left: 1px;"
)
med_text_style = (
f"background-color:rgba(0, 0, 0, 192);"
f"font-family:Oxanium;"
f"font-weight:bold;"
f"font-size:18px;"
f"border-radius:3px;"
f"padding-top: 4px;"
f"padding-right: 1px;"
f"padding-bottom: 1px;"
f"padding-left: 1px;"
)
def __init__(
self,
mode: Optional[ItemType],
library: Library,
panel: "PreviewPanel",
thumb_size: tuple[int, int],
):
"""Modes: entry, collation, tag_group"""
super().__init__()
self.lib = library
self.panel = panel
self.mode = mode
self.item_id: int = -1
self.isFavorite: bool = False
self.isArchived: bool = False
self.thumb_size: tuple[int, int] = thumb_size
self.setMinimumSize(*thumb_size)
self.setMaximumSize(*thumb_size)
check_size = 24
# self.setStyleSheet('background-color:red;')
# +----------+
# | ARC FAV| Top Right: Favorite & Archived Badges
# | |
# | |
# |EXT #| Lower Left: File Type, Tag Group Icon, or Collation Icon
# +----------+ Lower Right: Collation Count, Video Length, or Word Count
# Thumbnail ============================================================
# +----------+
# |*--------*|
# || ||
# || ||
# |*--------*|
# +----------+
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName("baseLayout")
# self.base_layout.setRowStretch(1, 2)
self.base_layout.setContentsMargins(0, 0, 0, 0)
# +----------+
# |[~~~~~~~~]|
# | |
# | |
# | |
# +----------+
self.top_layout = QHBoxLayout()
self.top_layout.setObjectName("topLayout")
# self.top_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# self.top_layout.setColumnStretch(1, 2)
self.top_layout.setContentsMargins(6, 6, 6, 6)
self.top_container = QWidget()
self.top_container.setLayout(self.top_layout)
self.base_layout.addWidget(self.top_container)
# +----------+
# |[~~~~~~~~]|
# | ^ |
# | | |
# | v |
# +----------+
self.base_layout.addStretch(2)
# +----------+
# |[~~~~~~~~]|
# | ^ |
# | v |
# |[~~~~~~~~]|
# +----------+
self.bottom_layout = QHBoxLayout()
self.bottom_layout.setObjectName("bottomLayout")
# self.bottom_container.setAlignment(Qt.AlignmentFlag.AlignBottom)
# self.bottom_layout.setColumnStretch(1, 2)
self.bottom_layout.setContentsMargins(6, 6, 6, 6)
self.bottom_container = QWidget()
self.bottom_container.setLayout(self.bottom_layout)
self.base_layout.addWidget(self.bottom_container)
# self.root_layout = QGridLayout(self)
# self.root_layout.setObjectName('rootLayout')
# self.root_layout.setColumnStretch(1, 2)
# self.root_layout.setRowStretch(1, 2)
# self.root_layout.setContentsMargins(6,6,6,6)
# # root_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.thumb_button = ThumbButton(self, thumb_size)
self.renderer = ThumbRenderer()
self.renderer.updated.connect(
lambda ts, i, s, ext: (
self.update_thumb(ts, image=i),
self.update_size(ts, size=s),
self.set_extension(ext), # type: ignore
)
)
self.thumb_button.setFlat(True)
# self.bg_button.setStyleSheet('background-color:blue;')
# self.bg_button.setLayout(self.root_layout)
self.thumb_button.setLayout(self.base_layout)
# self.bg_button.setMinimumSize(*thumb_size)
# self.bg_button.setMaximumSize(*thumb_size)
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper("")
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.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
# Static Badges ========================================================
# Item Type Badge ------------------------------------------------------
# Used for showing the Tag Group / Collation icons.
# Mutually exclusive with the File Extension Badge.
self.item_type_badge = QLabel()
self.item_type_badge.setObjectName("itemBadge")
self.item_type_badge.setPixmap(
QPixmap.fromImage(
ImageQt.ImageQt(
ItemThumb.collation_icon_128.resize(
(check_size, check_size), Image.Resampling.BILINEAR
)
)
)
)
self.item_type_badge.setMinimumSize(check_size, check_size)
self.item_type_badge.setMaximumSize(check_size, check_size)
# self.root_layout.addWidget(self.item_type_badge, 2, 0)
self.bottom_layout.addWidget(self.item_type_badge)
# File Extension Badge -------------------------------------------------
# Mutually exclusive with the File Extension Badge.
self.ext_badge = QLabel()
self.ext_badge.setObjectName("extBadge")
# self.ext_badge.setText('MP4')
# self.ext_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.ext_badge.setStyleSheet(ItemThumb.small_text_style)
# self.type_badge.setAlignment(Qt.AlignmentFlag.AlignRight)
# self.root_layout.addWidget(self.ext_badge, 2, 0)
self.bottom_layout.addWidget(self.ext_badge)
# self.type_badge.setHidden(True)
# bl_layout.addWidget(self.type_badge)
self.bottom_layout.addStretch(2)
# Count Badge ----------------------------------------------------------
# Used for Tag Group + Collation counts, video length, word count, etc.
self.count_badge = QLabel()
self.count_badge.setObjectName("countBadge")
# self.count_badge.setMaximumHeight(17)
self.count_badge.setText("-:--")
# self.count_badge.setAlignment(Qt.AlignmentFlag.AlignVCenter)
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
# self.count_badge.setAlignment(Qt.AlignmentFlag.AlignBottom)
# self.root_layout.addWidget(self.count_badge, 2, 2)
self.bottom_layout.addWidget(
self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom
)
self.top_layout.addStretch(2)
# Intractable Badges ===================================================
self.cb_container = QWidget()
# check_badges.setStyleSheet('background-color:cyan;')
self.cb_layout = QHBoxLayout()
self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft)
self.cb_layout.setContentsMargins(0, 0, 0, 0)
self.cb_layout.setSpacing(6)
self.cb_container.setLayout(self.cb_layout)
# self.cb_container.setHidden(True)
# self.root_layout.addWidget(self.check_badges, 0, 2)
self.top_layout.addWidget(self.cb_container)
# Favorite Badge -------------------------------------------------------
self.favorite_badge = QCheckBox()
self.favorite_badge.setObjectName("favBadge")
self.favorite_badge.setToolTip("Favorite")
self.favorite_badge.setStyleSheet(
f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}"
f"QCheckBox::indicator::unchecked{{image: url(:/images/star_icon_empty_128.png)}}"
f"QCheckBox::indicator::checked{{image: url(:/images/star_icon_filled_128.png)}}"
# f'QCheckBox{{background-color:yellow;}}'
)
self.favorite_badge.setMinimumSize(check_size, check_size)
self.favorite_badge.setMaximumSize(check_size, check_size)
self.favorite_badge.stateChanged.connect(
lambda x=self.favorite_badge.isChecked(): self.on_favorite_check(bool(x))
)
# self.fav_badge.setContentsMargins(0,0,0,0)
# tr_layout.addWidget(self.fav_badge)
# root_layout.addWidget(self.fav_badge, 0, 2)
self.cb_layout.addWidget(self.favorite_badge)
self.favorite_badge.setHidden(True)
# Archive Badge --------------------------------------------------------
self.archived_badge = QCheckBox()
self.archived_badge.setObjectName("archiveBadge")
self.archived_badge.setToolTip("Archive")
self.archived_badge.setStyleSheet(
f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}"
f"QCheckBox::indicator::unchecked{{image: url(:/images/box_icon_empty_128.png)}}"
f"QCheckBox::indicator::checked{{image: url(:/images/box_icon_filled_128.png)}}"
# f'QCheckBox{{background-color:red;}}'
)
self.archived_badge.setMinimumSize(check_size, check_size)
self.archived_badge.setMaximumSize(check_size, check_size)
# self.archived_badge.clicked.connect(lambda x: self.assign_archived(x))
self.archived_badge.stateChanged.connect(
lambda x=self.archived_badge.isChecked(): self.on_archived_check(bool(x))
)
# tr_layout.addWidget(self.archive_badge)
self.cb_layout.addWidget(self.archived_badge)
self.archived_badge.setHidden(True)
# root_layout.addWidget(self.archive_badge, 0, 2)
# self.dumpObjectTree()
self.set_mode(mode)
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)
# self.ext_badge.setHidden(True)
# 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)
# Count Badge depends on file extension (video length, word count)
self.item_type_badge.setHidden(True)
self.count_badge.setStyleSheet(ItemThumb.small_text_style)
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)
self.ext_badge.setHidden(True)
self.count_badge.setStyleSheet(ItemThumb.med_text_style)
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)
self.ext_badge.setHidden(True)
self.count_badge.setHidden(False)
self.item_type_badge.setHidden(False)
self.mode = mode
# logging.info(f'Set Mode To: {self.mode}')
# def update_(self, thumb: QPixmap, size:QSize, ext:str, badges:list[QPixmap]) -> None:
# """Updates the ItemThumb's visuals."""
# if thumb:
# pass
def set_extension(self, ext: str) -> None:
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()[1:])
if ext in VIDEO_TYPES + AUDIO_TYPES:
self.count_badge.setHidden(False)
else:
if self.mode == ItemType.ENTRY:
self.ext_badge.setHidden(True)
self.count_badge.setHidden(True)
def set_count(self, count: str) -> None:
if count:
self.count_badge.setHidden(False)
self.count_badge.setText(count)
else:
if self.mode == ItemType.ENTRY:
self.ext_badge.setHidden(True)
self.count_badge.setHidden(True)
def update_thumb(self, timestamp: float, image: QPixmap = None):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating Thumbnail for element {id(element)}: {id(image) if image else None}')
if timestamp > ItemThumb.update_cutoff:
self.thumb_button.setIcon(image if image else QPixmap())
# element.repaint()
def update_size(self, timestamp: float, size: QSize):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating size for element {id(element)}: {size.__str__()}')
if timestamp > ItemThumb.update_cutoff:
if self.thumb_button.iconSize != size:
self.thumb_button.setIconSize(size)
self.thumb_button.setMinimumSize(size)
self.thumb_button.setMaximumSize(size)
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:
self.thumb_button.clicked.disconnect()
except RuntimeError:
pass
if clickable:
self.thumb_button.clicked.connect(clickable)
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))
def set_item_id(self, id: int):
"""
also sets the filepath for the file opener
"""
self.item_id = id
if id == -1:
return
entry = self.lib.get_entry(self.item_id)
filepath = self.lib.library_dir / entry.path / entry.filename
self.opener.set_filepath(filepath)
def assign_favorite(self, value: bool):
# Switching mode to None to bypass mode-specific operations when the
# checkbox's state changes.
mode = self.mode
self.mode = None
self.isFavorite = value
self.favorite_badge.setChecked(value)
if not self.thumb_button.underMouse():
self.favorite_badge.setHidden(not self.isFavorite)
self.mode = mode
def assign_archived(self, value: bool):
# Switching mode to None to bypass mode-specific operations when the
# checkbox's state changes.
mode = self.mode
self.mode = None
self.isArchived = value
self.archived_badge.setChecked(value)
if not self.thumb_button.underMouse():
self.archived_badge.setHidden(not self.isArchived)
self.mode = mode
def show_check_badges(self, show: bool):
if self.mode != ItemType.TAG_GROUP:
self.favorite_badge.setHidden(
True if (not show and not self.isFavorite) else False
)
self.archived_badge.setHidden(
True if (not show and not self.isArchived) else False
)
def enterEvent(self, event: QEnterEvent) -> None:
self.show_check_badges(True)
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
self.show_check_badges(False)
return super().leaveEvent(event)
def on_archived_check(self, toggle_value: bool):
if self.mode == ItemType.ENTRY:
self.isArchived = toggle_value
self.toggle_item_tag(toggle_value, TAG_ARCHIVED)
def on_favorite_check(self, toggle_value: bool):
if self.mode == ItemType.ENTRY:
self.isFavorite = toggle_value
self.toggle_item_tag(toggle_value, TAG_FAVORITE)
def toggle_item_tag(self, toggle_value: bool, tag_id: int):
def toggle_tag(entry: Entry):
if toggle_value:
self.favorite_badge.setHidden(False)
entry.add_tag(
self.panel.driver.lib,
tag_id,
field_id=DEFAULT_META_TAG_FIELD,
field_index=-1,
)
else:
entry.remove_tag(self.panel.driver.lib, tag_id)
# Is the badge a part of the selection?
if (ItemType.ENTRY, self.item_id) in self.panel.driver.selected:
# Yes, add chosen tag to all selected.
for _, item_id in self.panel.driver.selected:
entry = self.lib.get_entry(item_id)
toggle_tag(entry)
else:
# No, add tag to the entry this badge is on.
entry = self.lib.get_entry(self.item_id)
toggle_tag(entry)
if self.panel.isOpen:
self.panel.update_widgets()
self.panel.driver.update_badges()

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

@@ -0,0 +1,113 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import logging
from types import FunctionType
from typing import Callable
from PySide6.QtCore import Signal, Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
class PanelModal(QWidget):
saved = Signal()
# TODO: Separate callbacks from the buttons you want, and just generally
# figure out what you want from this.
def __init__(
self,
widget: "PanelWidget",
title: str,
window_title: str,
done_callback: Callable = None,
# cancel_callback:FunctionType=None,
save_callback: Callable = None,
has_save: bool = False,
):
# [Done]
# - OR -
# [Cancel] [Save]
super().__init__()
self.widget = widget
self.setWindowTitle(window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 6)
self.title_widget = QLabel()
self.title_widget.setObjectName("fieldTitle")
self.title_widget.setWordWrap(True)
self.title_widget.setStyleSheet(
"font-weight:bold;" "font-size:14px;" "padding-top: 6px"
)
self.title_widget.setText(title)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)
# self.cancel_button = QPushButton()
# self.cancel_button.setText('Cancel')
if not (save_callback or has_save):
self.done_button = QPushButton()
self.done_button.setText("Done")
self.done_button.setAutoDefault(True)
self.done_button.clicked.connect(self.hide)
if done_callback:
self.done_button.clicked.connect(done_callback)
self.button_layout.addWidget(self.done_button)
if save_callback or has_save:
self.cancel_button = QPushButton()
self.cancel_button.setText("Cancel")
self.cancel_button.clicked.connect(self.hide)
self.cancel_button.clicked.connect(widget.reset)
# self.cancel_button.clicked.connect(cancel_callback)
self.button_layout.addWidget(self.cancel_button)
self.save_button = QPushButton()
self.save_button.setText("Save")
self.save_button.setAutoDefault(True)
self.save_button.clicked.connect(self.hide)
self.save_button.clicked.connect(self.saved.emit)
if done_callback:
self.save_button.clicked.connect(done_callback)
if save_callback:
self.save_button.clicked.connect(
lambda: save_callback(widget.get_content())
)
self.button_layout.addWidget(self.save_button)
# trigger save button actions when pressing enter in the widget
self.widget.add_callback(lambda: self.save_button.click())
widget.done.connect(lambda: save_callback(widget.get_content()))
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(widget)
self.root_layout.setStretch(1, 2)
self.root_layout.addWidget(self.button_container)
class PanelWidget(QWidget):
"""
Used for widgets that go in a modal panel, ex. for editing or searching.
"""
done = Signal()
def __init__(self):
super().__init__()
def get_content(self) -> str:
pass
def reset(self):
pass
def add_callback(self, callback: Callable, event: str = "returnPressed"):
logging.warning(f"add_callback not implemented for {self.__class__.__name__}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QProgressDialog
class ProgressWidget(QWidget):
"""Prebuilt thread-safe progress bar widget."""
def __init__(
self,
window_title: str,
label_text: str,
cancel_button_text: Optional[str],
minimum: int,
maximum: int,
):
super().__init__()
self.root = QVBoxLayout(self)
self.pb = QProgressDialog(
labelText=label_text,
minimum=minimum,
cancelButtonText=cancel_button_text,
maximum=maximum,
)
self.root.addWidget(self.pb)
self.setFixedSize(432, 112)
self.setWindowFlags(
self.pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint
)
self.setWindowTitle(window_title)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
def update_label(self, text: str):
self.pb.setLabelText(text)
def update_progress(self, value: int):
self.pb.setValue(value)

View File

@@ -0,0 +1,250 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import math
import os
from types import FunctionType
from pathlib import Path
from PIL import Image
from PySide6.QtCore import Signal, Qt, QEvent
from PySide6.QtGui import QEnterEvent, QAction
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
class TagWidget(QWidget):
edit_icon_128: Image.Image = Image.open(
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()
on_click = Signal()
on_edit = Signal()
def __init__(
self,
library: Library,
tag: Tag,
has_edit: bool,
has_remove: bool,
on_remove_callback: FunctionType = None,
on_click_callback: FunctionType = None,
on_edit_callback: FunctionType = None,
) -> None:
super().__init__()
self.lib = library
self.tag = tag
self.has_edit: bool = has_edit
self.has_remove: bool = has_remove
# self.bg_label = QLabel()
# self.setStyleSheet('background-color:blue;')
# if on_click_callback:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.base_layout = QVBoxLayout(self)
self.base_layout.setObjectName("baseLayout")
self.base_layout.setContentsMargins(0, 0, 0, 0)
self.bg_button = QPushButton(self)
self.bg_button.setFlat(True)
self.bg_button.setText(tag.display_name(self.lib).replace("&", "&&"))
if has_edit:
edit_action = QAction("Edit", self)
edit_action.triggered.connect(on_edit_callback)
edit_action.triggered.connect(self.on_edit.emit)
self.bg_button.addAction(edit_action)
# if on_click_callback:
self.bg_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
# if has_remove:
# remove_action = QAction('Remove', self)
# # remove_action.triggered.connect(on_remove_callback)
# remove_action.triggered.connect(self.on_remove.emit())
# self.bg_button.addAction(remove_action)
search_for_tag_action = QAction("Search for Tag", self)
# search_for_tag_action.triggered.connect(on_click_callback)
search_for_tag_action.triggered.connect(self.on_click.emit)
self.bg_button.addAction(search_for_tag_action)
add_to_search_action = QAction("Add to Search", self)
self.bg_button.addAction(add_to_search_action)
self.inner_layout = QHBoxLayout()
self.inner_layout.setObjectName("innerLayout")
self.inner_layout.setContentsMargins(2, 2, 2, 2)
# self.inner_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
# self.inner_container = QWidget()
# self.inner_container.setLayout(self.inner_layout)
# self.base_layout.addWidget(self.inner_container)
self.bg_button.setLayout(self.inner_layout)
self.bg_button.setMinimumSize(math.ceil(22 * 1.5), 22)
# self.bg_button.setStyleSheet(
# f'QPushButton {{'
# f'border: 2px solid #8f8f91;'
# f'border-radius: 6px;'
# f'background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,stop: 0 {ColorType.PRIMARY}, stop: 1 {ColorType.BORDER});'
# f'min-width: 80px;}}')
self.bg_button.setStyleSheet(
# f'background: {get_tag_color(ColorType.PRIMARY, tag.color)};'
f"QPushButton{{"
f"background: {get_tag_color(ColorType.PRIMARY, tag.color)};"
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
f"font-weight: 600;"
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
f"border-radius: 6px;"
f"border-style:solid;"
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
# f'border-top:2px solid {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};'
# f'border-bottom:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
# f'border-left:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
# f'border-right:2px solid {get_tag_color(ColorType.BORDER, tag.color)};'
# f'padding-top: 0.5px;'
f"padding-right: 4px;"
f"padding-bottom: 1px;"
f"padding-left: 4px;"
f"font-size: 13px"
f"}}"
f"QPushButton::hover{{"
# f'background: {get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};'
# f'background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_tag_color(ColorType.PRIMARY, tag.color)}, stop:1.0 {get_tag_color(ColorType.BORDER, tag.color)});'
# f"border-color:{get_tag_color(ColorType.PRIMARY, tag.color)};"
# f"color: {get_tag_color(ColorType.TEXT, tag.color)};"
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, tag.color)};"
f"}}"
)
# self.renderer = ThumbRenderer()
# self.renderer.updated.connect(lambda ts, i, s, ext: (self.update_thumb(ts, image=i),
# self.update_size(
# ts, size=s),
# self.set_extension(ext)))
# self.bg_button.setLayout(self.base_layout)
self.base_layout.addWidget(self.bg_button)
# self.setMinimumSize(self.bg_button.size())
# logging.info(tag.color)
if has_remove:
self.remove_button = QPushButton(self)
self.remove_button.setFlat(True)
self.remove_button.setText("")
self.remove_button.setHidden(True)
self.remove_button.setStyleSheet(
f"color: {get_tag_color(ColorType.PRIMARY, tag.color)};"
f"background: {get_tag_color(ColorType.TEXT, tag.color)};"
# f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# f"border-color: {get_tag_color(ColorType.BORDER, tag.color)};"
f"font-weight: 800;"
# f"border-color:{'black' if color not in [
# 'black', 'gray', 'dark gray',
# 'cool gray', 'warm gray', 'blue',
# 'purple', 'violet'] else 'white'};"
f"border-radius: 4px;"
# f'border-style:solid;'
f"border-width:0;"
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
f"padding-bottom: 4px;"
# f'padding-left: 4px;'
f"font-size: 14px"
)
self.remove_button.setMinimumSize(19, 19)
self.remove_button.setMaximumSize(19, 19)
# self.remove_button.clicked.connect(on_remove_callback)
self.remove_button.clicked.connect(self.on_remove.emit)
# NOTE: No more edit button! Just make it a right-click option.
# self.edit_button = QPushButton(self)
# self.edit_button.setFlat(True)
# self.edit_button.setText('Edit')
# self.edit_button.setIcon(QPixmap.fromImage(ImageQt.ImageQt(self.edit_icon_128)))
# self.edit_button.setIconSize(QSize(14,14))
# self.edit_button.setHidden(True)
# self.edit_button.setStyleSheet(f'color: {color};'
# f"background: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# # f"color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# f"border-color: {'black' if color not in ['black', 'gray', 'dark gray', 'cool gray', 'warm gray', 'blue', 'purple', 'violet'] else 'white'};"
# f'font-weight: 600;'
# # f"border-color:{'black' if color not in [
# # 'black', 'gray', 'dark gray',
# # 'cool gray', 'warm gray', 'blue',
# # 'purple', 'violet'] else 'white'};"
# # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}'
# # f'QPushButton{{border-image: url(:/images/edit_icon_128.png);}}'
# f'border-radius: 4px;'
# # f'border-style:solid;'
# # f'border-width:1px;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
# f'padding-bottom: 3px;'
# f'padding-left: 4px;'
# f'font-size: 14px')
# self.edit_button.setMinimumSize(18,18)
# # self.edit_button.setMaximumSize(18,18)
# self.inner_layout.addWidget(self.edit_button)
if has_remove:
self.inner_layout.addWidget(self.remove_button)
self.inner_layout.addStretch(1)
# NOTE: Do this if you don't want the tag to stretch, like in a search.
# self.bg_button.setMaximumWidth(self.bg_button.sizeHint().width())
# self.set_click(on_click_callback)
self.bg_button.clicked.connect(self.on_click.emit)
# self.setMinimumSize(50,20)
# def set_name(self, name:str):
# self.bg_label.setText(str)
# def on_remove(self):
# if self.item and self.item[0] == ItemType.ENTRY:
# if self.field_index >= 0:
# self.lib.get_entry(self.item[1]).remove_tag(self.tag.id, self.field_index)
# else:
# self.lib.get_entry(self.item[1]).remove_tag(self.tag.id)
# def set_click(self, callback):
# try:
# self.bg_button.clicked.disconnect()
# except RuntimeError:
# pass
# if callback:
# self.bg_button.clicked.connect(callback)
# def set_click(self, function):
# try:
# self.bg.clicked.disconnect()
# except RuntimeError:
# pass
# # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath))
# # self.bg.clicked.connect(function)
def enterEvent(self, event: QEnterEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(False)
# self.edit_button.setHidden(False)
self.update()
return super().enterEvent(event)
def leaveEvent(self, event: QEvent) -> None:
if self.has_remove:
self.remove_button.setHidden(True)
# self.edit_button.setHidden(True)
self.update()
return super().leaveEvent(event)

Some files were not shown because too many files have changed in this diff Show More