Compare commits
53 Commits
v9.2.0
...
Alpha-v9.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58be4cdb4b | ||
|
|
08761d5f8a | ||
|
|
6a680ad3d1 | ||
|
|
b5ec3598e1 | ||
|
|
926dfffebe | ||
|
|
461906c349 | ||
|
|
2dc5197fbd | ||
|
|
11f0c7f9b8 | ||
|
|
fb445e6ab0 | ||
|
|
6e96a0ff61 | ||
|
|
c75aff4db3 | ||
|
|
84a4b2f0cf | ||
|
|
10b90dcc74 | ||
|
|
2d89df620e | ||
|
|
0646508c24 | ||
|
|
0137ed5be8 | ||
|
|
779a251c09 | ||
|
|
868b553670 | ||
|
|
9f630fe315 | ||
|
|
6798ffd0a7 | ||
|
|
2e8678414b | ||
|
|
e1cd46d010 | ||
|
|
9879697c95 | ||
|
|
57e27bb51f | ||
|
|
66ec0913b6 | ||
|
|
6357fea8db | ||
|
|
491ebb6714 | ||
|
|
385b4117db | ||
|
|
be3992f655 | ||
|
|
18becd62a3 | ||
|
|
699ecd367c | ||
|
|
9d7609a8e5 | ||
|
|
e94c4871d7 | ||
|
|
02bf15e080 | ||
|
|
5f60ec1702 | ||
|
|
cdf2581f84 | ||
|
|
af8b4e3872 | ||
|
|
ac9dd5879e | ||
|
|
badcd72bea | ||
|
|
8733c8d301 | ||
|
|
4726f1fc63 | ||
|
|
1461f2ee70 | ||
|
|
1bfc24b70f | ||
|
|
c09f50c568 | ||
|
|
66aecf2030 | ||
|
|
dc188264f9 | ||
|
|
6e56f13eda | ||
|
|
c9ea25b940 | ||
|
|
e814d09c60 | ||
|
|
4b1119ecba | ||
|
|
ad850cba94 | ||
|
|
b6848bb81f | ||
|
|
fb7c73d96b |
5
.github/workflows/apprun.yaml
vendored
@@ -32,11 +32,12 @@ jobs:
|
||||
libxcb-render-util0 \
|
||||
libxcb-xinerama0 \
|
||||
libopengl0 \
|
||||
libxcb-cursor0
|
||||
libxcb-cursor0 \
|
||||
libpulse0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install -Ur requirements.txt
|
||||
|
||||
- name: Run TagStudio app and check exit code
|
||||
run: |
|
||||
|
||||
39
.github/workflows/mypy.yaml
vendored
Normal 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
|
||||
8
.github/workflows/release.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- run: pip install -Ur requirements.txt pyinstaller
|
||||
- run: pyinstaller tagstudio.spec -- ${{ matrix.build-flag }}
|
||||
- run: tar czfC dist/tagstudio_linux_x86_64${{ matrix.suffix }}.tar.gz dist tagstudio
|
||||
@@ -34,20 +35,22 @@ jobs:
|
||||
macos:
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ['11', '14']
|
||||
os-version: ['12', '14']
|
||||
include:
|
||||
- os-version: '11'
|
||||
- os-version: '12'
|
||||
arch: x86_64
|
||||
- os-version: '14'
|
||||
arch: aarch64
|
||||
runs-on: macos-${{ matrix.os-version }}
|
||||
env:
|
||||
# even though we run on 12, target towards compatibility
|
||||
MACOSX_DEPLOYMENT_TARGET: '11.0'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- run: pip install -Ur requirements.txt pyinstaller
|
||||
- run: pyinstaller tagstudio.spec
|
||||
- run: tar czfC dist/tagstudio_macos_${{ matrix.arch }}.tar.gz dist TagStudio.app
|
||||
@@ -75,6 +78,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- run: pip install -Ur requirements.txt pyinstaller
|
||||
- run: PyInstaller tagstudio.spec -- ${{ matrix.build-flag }}
|
||||
- run: Compress-Archive -Path dist/TagStudio${{ matrix.file-end }} -DestinationPath dist/tagstudio_windows_x86_64${{ matrix.suffix }}.zip
|
||||
|
||||
18
README.md
@@ -56,7 +56,8 @@ TagStudio is a photo & file organization application with an underlying system t
|
||||
|
||||
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
|
||||
|
||||
Once downloaded, launch the corresponding TagStudio executable to start the program. Once open, go to **"File -> Create/Open Library"** to create your first library from a directory!
|
||||
> [!NOTE]
|
||||
> 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
|
||||
|
||||
@@ -66,7 +67,7 @@ Optional arguments to pass to the program.
|
||||
> Path to a TagStudio Library folder to open on start.
|
||||
|
||||
> `--config-file <path>` / `-c <path>`
|
||||
> Path to a TagStudio Library folder to open on start.
|
||||
> Path to the TagStudio config file to load.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -181,8 +182,12 @@ _Skip these steps if launching from the .sh script on Linux/macOS._
|
||||
- Windows w/Command Prompt: `.venv\Scripts\activate.bat`
|
||||
- Linux/macOS: `source .venv/bin/activate`
|
||||
|
||||
3. Install the required packages:
|
||||
`pip install -r requirements.txt`
|
||||
3. Install the required packages:
|
||||
|
||||
- required to run the app: `pip install -r requirements.txt`
|
||||
- required to develop: `pip install -r requirements-dev.txt`
|
||||
|
||||
To run all the tests use `python -m pytest tests/` from the `tagstudio` folder.
|
||||
|
||||
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._
|
||||
|
||||
@@ -207,10 +212,13 @@ _Learn more about setting up a virtual environment [here](https://docs.python.or
|
||||
|
||||
### What State Is the Project Currently In?
|
||||
|
||||
As of writing (Alpha v9.2.0) the project is in a useable state, however it lacks proper testing and quality of life features.
|
||||
As of writing (Alpha v9.2.1) the project is in a useable state, however it lacks proper testing and quality of life features.
|
||||
|
||||
### What Features Are You Planning on Adding?
|
||||
|
||||
> [!NOTE]
|
||||
> **_See [Planned Features](/doc/planned_features.md) documentation._**
|
||||
|
||||
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
|
||||
|
||||
BIN
doc/assets/db_schema.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
doc/assets/tag_override_ex-1.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
doc/assets/tag_override_ex-2.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -1,189 +0,0 @@
|
||||
# TagStudio Documentation (Alpha v9.1.0)
|
||||
|
||||
## _A User-Focused Document Management System_
|
||||
|
||||
> [!WARNING]
|
||||
> This documentation is still a work in progress, and is intended to aide with deconstructing and understanding of the core mechanics of TagStudio and how it operates.
|
||||
|
||||
## Contents
|
||||
- [Library](#library)
|
||||
- [Fields](#fields)
|
||||
- [Entries](#entries)
|
||||
- [Tags](#tags)
|
||||
- [Retrieving Entries](#retrieving-entries-based-on-tag-cluster)
|
||||
- [Missing File Resolution](#missing-file-resolution)
|
||||
|
||||
## Library
|
||||
|
||||
The Library is how TagStudio represents your chosen directory. In this Library or Vault system, all files within this directory are represented by Entries, which then contain metadata Fields. All TagStudio data for a Library is stored within a `.TagStudio` folder at the root of the Library's directory. Internal Library objects include:
|
||||
|
||||
- Fields (v9+)
|
||||
- Text Line (Title, Author, Artist, URL)
|
||||
- Text Box (Description, Notes)
|
||||
- Tag Box (Tags, Content Tags, Meta Tags)
|
||||
- Datetime (Date Created, Date Modified, Date Taken) [WIP]
|
||||
- Collation (Collation) [WIP]
|
||||
- `name: str`: Collation Name
|
||||
- `page: int`: Page #
|
||||
- Checkbox (Archive, Favorite) [WIP]
|
||||
- Drop Down (Group of Tags to select one from) [WIP]
|
||||
- Entries (v1+)
|
||||
- Tags (v7+)
|
||||
- Macros (v9/10+)
|
||||
|
||||
## Fields
|
||||
|
||||
Fields are the building blocks of metadata stored in Entires. Fields have several base types for representing different types of information, including:
|
||||
|
||||
- `text_line`
|
||||
- A string of text, displayed as a single line.
|
||||
- Useful for Titles, Authors, URLs, etc.
|
||||
- `text_box`
|
||||
- A long string of text displayed as a box of text.
|
||||
- Useful for descriptions, notes, etc.
|
||||
- `datetime` [WIP]
|
||||
- A date and time value.
|
||||
- `tag_box`
|
||||
- A box of tags added by the user.
|
||||
- Multiple tag boxes can be used to separate classifications of tags, ex. 'Content Tags' and 'Meta Tags'.
|
||||
- `checkbox` [WIP]
|
||||
- A two-state checkbox.
|
||||
- Can be associated with a tag for quick organization.
|
||||
- `collation` [WIP]
|
||||
- A collation is a collection of files that are intended to be displayed and retrieved together. Examples may include pages of a book or document that are spread out across several individual files. If you're intention is to associate files across multiple 'collations', use Tags instead!
|
||||
|
||||
## Entries
|
||||
|
||||
Entries are the representations of your files within the Library. They consist of a reference to the file on your drive, as well as the metadata associated with it.
|
||||
|
||||
### Entry Object Structure (v9):
|
||||
|
||||
- `id`:
|
||||
- ID for the Entry.
|
||||
- Int, Unique, Required
|
||||
- Used for internal processing
|
||||
- `filename`:
|
||||
- The filename with extension of the referenced media file.
|
||||
- String, Required
|
||||
- `path`:
|
||||
- The folder path in which the media file is located in.
|
||||
- String, Required, OS Agnostic
|
||||
- `fields`:
|
||||
- A list of Field ID/Value dicts.
|
||||
- List of dicts, Optional
|
||||
|
||||
NOTE: _Entries currently have several unused optional fields intended for later features._
|
||||
|
||||
## Tags
|
||||
|
||||
**Tags** are small data objects that represent an attribute of something. A person, place, thing, concept, you name it! Tags in TagStudio allow for more sophisticated Entry organization and searching thanks to their ability to contain alternate names and spellings via `aliases`, relational organization thanks to inherent `subtags`, and more! Tags can be as simple or as powerful as you want to make them, and TagStudio aims to provide as much power to you as possible.
|
||||
|
||||
### Tag Object Structure (v9):
|
||||
|
||||
- `id`:
|
||||
- ID for the Tag.
|
||||
- Int, Unique, Required
|
||||
- Used for internal processing
|
||||
- `name`:
|
||||
- The normal name of the Tag, with no shortening or specification.
|
||||
- String, Required
|
||||
- Doesn't have to be unique
|
||||
- Each word analyzed individually
|
||||
- Used for display, searching, and storing
|
||||
- `shorthand`:
|
||||
- The shorthand name for the Tag.
|
||||
- String, Optional
|
||||
- Doesn't have to be unique
|
||||
- Entire string analyzed as-is
|
||||
- Used for display and searching
|
||||
- `aliases`:
|
||||
- Alternate names for the Tag.
|
||||
- List of Strings, Optional
|
||||
- Recommended to be unique to this Tag
|
||||
- Entire string analyzed as-is
|
||||
- Used for searching
|
||||
- `subtags`:
|
||||
- Other Tags that make up properties of this Tag.
|
||||
- List of Strings, Optional
|
||||
- Used for display (first subtag only) and searching.
|
||||
- `color`:
|
||||
- A hex code value for customizing the Tag's display color
|
||||
- String, Optional
|
||||
- Used for display
|
||||
|
||||
### Tag Examples:
|
||||
|
||||
#### League of Legends
|
||||
|
||||
- `name`: "League of Legends"
|
||||
- `shorthand`: "LoL"
|
||||
- `aliases`: ["League"]
|
||||
- `subtags`: ["Game", "Fantasy"]
|
||||
|
||||
#### Arcane
|
||||
|
||||
- `name`: "Arcane"
|
||||
- `shorthand`: ""
|
||||
- `aliases`: []
|
||||
- `subtags`: ["League of Legends", "Cartoon"]
|
||||
|
||||
#### Jinx (LoL)
|
||||
|
||||
- `name`: "Jinx Piltover"
|
||||
- `shorthand`: "Jinx"
|
||||
- `aliases`: ["Jinxy", "Jinxy Poo"]
|
||||
- `subtags`: ["League of Legends", "Arcane", "Character"]
|
||||
|
||||
#### Zander (Arcane)
|
||||
|
||||
- `name`: "Zander Zanderson"
|
||||
- `shorthand`: "Zander"
|
||||
- `aliases`: []
|
||||
- `subtags`: ["Arcane", "Character"]
|
||||
|
||||
#### Mr. Legend (LoL)
|
||||
|
||||
- `name`: "Mr. Legend"
|
||||
- `shorthand`: ""
|
||||
- `aliases`: []
|
||||
- `subtags`: ["League of Legends", "Character"]
|
||||
|
||||
### Query "League of Legends" returns results for:
|
||||
|
||||
- League of Legends [because of "League of Legend"'s name]
|
||||
- Arcane [because of "Arcane"'s subtag]
|
||||
- Jinx (LoL) [because of "Jinx Piltover"'s subtag]
|
||||
- Mr. Legend (LoL) [because of "Mr. Legned (LoL)'s subtag"]
|
||||
- Zander (Arcane) [because of "Zander Zanderson"'s subtag ("Arcane")'s subtag]
|
||||
|
||||
### Query "LoL" returns results for:
|
||||
|
||||
- League of Legends [because of "League of Legend"'s shorthand]
|
||||
- LoL [because of "League of Legend"'s shorthand]
|
||||
- Arcane [because of "Arcane"'s subtag]
|
||||
- Jinx (LoL) [because of "Jinx Piltover"'s subtag]
|
||||
- Mr. Legend (LoL) [because of "Mr. Legned (LoL)'s subtag"]
|
||||
- Zander (Arcane) [because of "Zander Zanderson"'s subtag ("Arcane")'s subtag]
|
||||
|
||||
### Query "Arcane" returns results for:
|
||||
|
||||
- Arcane [because of "Arcane"'s name]
|
||||
- Jinx (LoL) [because of "Jinx Piltover"'s subtag "Arcane"]
|
||||
- Zander (Arcane) [because of "Zander Zanderson"'s subtag]
|
||||
|
||||
## Retrieving Entries based on Tag Cluster
|
||||
|
||||
By default when querying Entries, each Entry's `tags` list (stored in the form of Tag `id`s) is compared against the Tag `id`s in a given Tag cluster (list of Tag `id`s) or appended clusters in the case of multi-term queries. The type of comparison depends on the type of query and whether or not it is an inclusive or exclusive query, or a combination of both. This default searching behavior is done in _O(n)_ time, but can be sped up in the future by building indexes on certain search terms. These indexes can be stored on disk and loaded back into memory in future sessions. These indexes will also need to be updated as new Tags and Entries are added or edited.
|
||||
|
||||
## Missing File Resolution
|
||||
|
||||
1. Refresh missing file list (`refresh missing`) (Automatically run if library has few entries)
|
||||
2. Fix missing files screen (`fix missing`)
|
||||
|
||||
### Fix Missing Files Screen
|
||||
|
||||
0. **Match Search** (Determines if entries can be fixed) Scans for filename in library directory
|
||||
1. **Quick Fixes** (one match found, no existing entry)
|
||||
2. **Match Selection** (multiple matches found)
|
||||
3. **Merge Conflict Resolution** (match has existing entry)
|
||||
Any remaining missing files can be listed, but they probably really are missing at this point. You can update the path and filename to point to new files if you know where they should actually be pointing to.
|
||||
24
doc/index.md
Normal file
@@ -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
@@ -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.
|
||||
3
doc/library/entry_groups.md
Normal 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
@@ -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
@@ -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
@@ -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".
|
||||
3
doc/library/tag_categories.md
Normal 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.
|
||||
16
doc/library/tag_overrides.md
Normal 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>
|
||||
43
doc/updates/db_migration.md
Normal 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
|
||||
59
doc/updates/planned_features.md
Normal 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
@@ -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.
|
||||
@@ -1,2 +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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
ruff==0.4.2
|
||||
pre-commit==3.7.0
|
||||
pytest==8.2.0
|
||||
Pyinstaller==6.6.0
|
||||
mypy==1.10.0
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
humanfriendly==10.0
|
||||
opencv_python>=4.8.0.74,<=4.9.0.80
|
||||
Pillow==10.3.0
|
||||
pillow_avif_plugin>=1.3.1,<=1.4.3
|
||||
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
|
||||
|
||||
1
tagstudio/resources/pause.svg
Normal 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 |
1
tagstudio/resources/play.svg
Normal 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 |
BIN
tagstudio/resources/qt/images/tagstudio_logo_text_mono.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
1
tagstudio/resources/volume_muted.svg
Normal 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 |
1
tagstudio/resources/volume_unmuted.svg
Normal 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 |
@@ -1,3 +1,4 @@
|
||||
# type: ignore
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
@@ -6,14 +7,17 @@
|
||||
|
||||
import datetime
|
||||
import math
|
||||
from multiprocessing import Value
|
||||
|
||||
# from multiprocessing import Value
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# import subprocess
|
||||
import sys
|
||||
import time
|
||||
from PIL import Image, ImageOps, ImageChops, UnidentifiedImageError
|
||||
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
import pillow_avif
|
||||
|
||||
# import pillow_avif
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
import cv2
|
||||
@@ -84,21 +88,19 @@ class CliDriver:
|
||||
self.is_dupe_file_count_init: bool = False
|
||||
|
||||
self.external_preview_size: tuple[int, int] = (960, 960)
|
||||
epd_path = os.path.normpath(
|
||||
f"{Path(__file__).parents[2]}/resources/cli/images/external_preview.png"
|
||||
epd_path = (
|
||||
Path(__file__).parents[2] / "resources/cli/images/external_preview.png"
|
||||
)
|
||||
self.external_preview_default: Image = (
|
||||
Image.open(epd_path)
|
||||
if os.path.exists(epd_path)
|
||||
if epd_path.exists()
|
||||
else Image.new(mode="RGB", size=(self.external_preview_size))
|
||||
)
|
||||
self.external_preview_default.thumbnail(self.external_preview_size)
|
||||
epb_path = os.path.normpath(
|
||||
f"{Path(__file__).parents[2]}/resources/cli/images/no_preview.png"
|
||||
)
|
||||
epb_path = Path(__file__).parents[3] / "resources/cli/images/no_preview.png"
|
||||
self.external_preview_broken: Image = (
|
||||
Image.open(epb_path)
|
||||
if os.path.exists(epb_path)
|
||||
if epb_path.exists()
|
||||
else Image.new(mode="RGB", size=(self.external_preview_size))
|
||||
)
|
||||
self.external_preview_broken.thumbnail(self.external_preview_size)
|
||||
@@ -357,45 +359,44 @@ class CliDriver:
|
||||
def init_external_preview(self) -> None:
|
||||
"""Initialized the external preview image file."""
|
||||
if self.lib and self.lib.library_dir:
|
||||
external_preview_path: str = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
|
||||
external_preview_path: Path = (
|
||||
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
|
||||
)
|
||||
if not os.path.isfile(external_preview_path):
|
||||
if not external_preview_path.is_file():
|
||||
temp = self.external_preview_default
|
||||
temp.save(external_preview_path)
|
||||
|
||||
open_file(external_preview_path)
|
||||
|
||||
def set_external_preview_default(self) -> None:
|
||||
"""Sets the external preview to its default image."""
|
||||
if self.lib and self.lib.library_dir:
|
||||
external_preview_path: str = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
|
||||
external_preview_path: Path = (
|
||||
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
|
||||
)
|
||||
if os.path.isfile(external_preview_path):
|
||||
if external_preview_path.is_file():
|
||||
temp = self.external_preview_default
|
||||
temp.save(external_preview_path)
|
||||
|
||||
def set_external_preview_broken(self) -> None:
|
||||
"""Sets the external preview image file to the 'broken' placeholder."""
|
||||
if self.lib and self.lib.library_dir:
|
||||
external_preview_path: str = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
|
||||
external_preview_path: Path = (
|
||||
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
|
||||
)
|
||||
if os.path.isfile(external_preview_path):
|
||||
if external_preview_path.is_file():
|
||||
temp = self.external_preview_broken
|
||||
temp.save(external_preview_path)
|
||||
|
||||
def close_external_preview(self) -> None:
|
||||
"""Destroys and closes the external preview image file."""
|
||||
if self.lib and self.lib.library_dir:
|
||||
external_preview_path: str = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
|
||||
external_preview_path: Path = (
|
||||
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
|
||||
)
|
||||
if os.path.isfile(external_preview_path):
|
||||
if external_preview_path.is_file():
|
||||
os.remove(external_preview_path)
|
||||
|
||||
def scr_create_library(self, path=""):
|
||||
def scr_create_library(self, path=None):
|
||||
"""Screen for creating a new TagStudio library."""
|
||||
|
||||
subtitle = "Create Library"
|
||||
@@ -408,7 +409,10 @@ class CliDriver:
|
||||
if not path:
|
||||
print("Enter Library Folder Path: \n> ", end="")
|
||||
path = input()
|
||||
if os.path.exists(path):
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if path.exists():
|
||||
print("")
|
||||
print(
|
||||
f'{INFO} Are you sure you want to create a new Library at "{path}"? (Y/N)\n> ',
|
||||
@@ -484,9 +488,7 @@ class CliDriver:
|
||||
"""Saves a backup copy of the Library file to disk. Returns True if successful."""
|
||||
if self.lib and self.lib.library_dir:
|
||||
filename = self.lib.save_library_backup_to_disk()
|
||||
location = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/backups/{filename}"
|
||||
)
|
||||
location = self.lib.library_dir / TS_FOLDER_NAME / "backups" / filename
|
||||
if display_message:
|
||||
print(f'{INFO} Backup of Library saved at "{location}".')
|
||||
return True
|
||||
@@ -613,13 +615,11 @@ class CliDriver:
|
||||
"""
|
||||
entry = None if index < 0 else self.lib.entries[index]
|
||||
if entry:
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
external_preview_path: str = ""
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
external_preview_path: Path = None
|
||||
if self.args.external_preview:
|
||||
external_preview_path = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/external_preview.jpg"
|
||||
external_preview_path = (
|
||||
self.lib.library_dir / TS_FOLDER_NAME / "external_preview.jpg"
|
||||
)
|
||||
# thumb_width = min(
|
||||
# os.get_terminal_size()[0]//2,
|
||||
@@ -670,14 +670,9 @@ class CliDriver:
|
||||
final_frame = Image.fromarray(frame)
|
||||
w, h = final_frame.size
|
||||
final_frame.save(
|
||||
os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg"
|
||||
),
|
||||
quality=50,
|
||||
)
|
||||
final_img_path = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg"
|
||||
self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg", quality=50
|
||||
)
|
||||
final_img_path = self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg"
|
||||
# NOTE: Temporary way to hack a non-terminal preview.
|
||||
if self.args.external_preview and entry:
|
||||
final_frame.thumbnail(self.external_preview_size)
|
||||
@@ -740,7 +735,7 @@ class CliDriver:
|
||||
print(image.replace("\n", ("\n" + " " * spacing)))
|
||||
|
||||
if file_type in VIDEO_TYPES:
|
||||
os.remove(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/temp.jpg")
|
||||
os.remove(self.lib.library_dir / TS_FOLDER_NAME / "temp.jpg")
|
||||
except:
|
||||
if not self.args.external_preview or not entry:
|
||||
print(
|
||||
@@ -897,7 +892,7 @@ class CliDriver:
|
||||
"""Runs a specific Macro on an Entry given a Macro name."""
|
||||
# entry: Entry = self.lib.get_entry_from_index(entry_id)
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
path = os.path.normpath(f"{self.lib.library_dir}/{entry.path}/{entry.filename}")
|
||||
path = self.lib.library_dir / entry.path / entry.filename
|
||||
source = path.split(os.sep)[1].lower()
|
||||
if name == "sidecar":
|
||||
self.lib.add_generic_data_to_entry(
|
||||
@@ -1050,8 +1045,11 @@ class CliDriver:
|
||||
time.sleep(5)
|
||||
|
||||
collage = Image.new("RGB", (img_size, img_size))
|
||||
filename = os.path.normpath(
|
||||
f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png'
|
||||
filename = (
|
||||
elf.lib.library_dir
|
||||
/ TS_FOLDER_NAME
|
||||
/ COLLAGE_FOLDER_NAME
|
||||
/ f'collage_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.png'
|
||||
)
|
||||
|
||||
i = 0
|
||||
@@ -1061,10 +1059,7 @@ class CliDriver:
|
||||
if i < len(self.lib.entries) and run:
|
||||
# entry: Entry = self.lib.get_entry_from_index(i)
|
||||
entry = self.lib.entries[i]
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
color: str = ""
|
||||
|
||||
if data_tint_mode or data_only_mode:
|
||||
@@ -1109,16 +1104,17 @@ class CliDriver:
|
||||
collage.paste(pic, (y * thumb_size, x * thumb_size))
|
||||
if not data_only_mode:
|
||||
print(
|
||||
f"\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}"
|
||||
f"\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}{RESET}"
|
||||
)
|
||||
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
|
||||
# sys.stdout.flush()
|
||||
if file_type in IMAGE_TYPES:
|
||||
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
try:
|
||||
with Image.open(
|
||||
os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
self.lib.library_dir
|
||||
/ entry.path
|
||||
/ entry.filename
|
||||
) as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail((thumb_size, thumb_size))
|
||||
@@ -1142,7 +1138,7 @@ class CliDriver:
|
||||
f"[ERROR] One of the images was too big ({e})"
|
||||
)
|
||||
|
||||
elif file_type in VIDEO_TYPES:
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
@@ -1164,9 +1160,7 @@ class CliDriver:
|
||||
)
|
||||
collage.paste(pic, (y * thumb_size, x * thumb_size))
|
||||
except UnidentifiedImageError:
|
||||
print(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
print(f"\n{ERROR} Couldn't read {entry.path / entry.filename}")
|
||||
except KeyboardInterrupt:
|
||||
# self.quit(save=False, backup=True)
|
||||
run = False
|
||||
@@ -1174,7 +1168,7 @@ class CliDriver:
|
||||
print(f"{INFO} Collage operation cancelled.")
|
||||
clear_scr = False
|
||||
except:
|
||||
print(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
|
||||
print(f"{ERROR} {entry.path / entry.filename}")
|
||||
traceback.print_exc()
|
||||
print("Continuing...")
|
||||
i = i + 1
|
||||
@@ -1451,7 +1445,7 @@ class CliDriver:
|
||||
f"{WHITE_FG}Enter the filename for your DupeGuru results file:\n> {RESET}",
|
||||
end="",
|
||||
)
|
||||
dg_results_file = os.path.normpath(input())
|
||||
dg_results_file = Path(input())
|
||||
print(
|
||||
f"{INFO} Checking for duplicate files in Library '{self.lib.library_dir}'..."
|
||||
)
|
||||
@@ -1473,7 +1467,7 @@ class CliDriver:
|
||||
) > 1:
|
||||
if com[1].lower() == "entries":
|
||||
for i, e in enumerate(self.lib.entries, start=0):
|
||||
title = f"[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path}{os.path.sep}{self.lib.entries[i].filename}"
|
||||
title = f"[{i+1}/{len(self.lib.entries)}] {self.lib.entries[i].path / os.path.sep / self.lib.entries[i].filename}"
|
||||
print(
|
||||
self.format_subtitle(
|
||||
title,
|
||||
@@ -1522,12 +1516,11 @@ class CliDriver:
|
||||
for dupe in self.lib.dupe_entries:
|
||||
print(
|
||||
self.lib.entries[dupe[0]].path
|
||||
+ os.path.sep
|
||||
+ self.lib.entries[dupe[0]].filename
|
||||
/ self.lib.entries[dupe[0]].filename
|
||||
)
|
||||
for d in dupe[1]:
|
||||
print(
|
||||
f"\t-> {(self.lib.entries[d].path + os.path.sep + self.lib.entries[d].filename)}"
|
||||
f"\t-> {(self.lib.entries[d].path / self.lib.entries[d].filename)}"
|
||||
)
|
||||
time.sleep(0.1)
|
||||
print("Press Enter to Continue...")
|
||||
@@ -1867,20 +1860,18 @@ class CliDriver:
|
||||
# entry = self.lib.get_entry_from_index(
|
||||
# self.filtered_entries[index])
|
||||
entry = self.lib.get_entry(self.filtered_entries[index][1])
|
||||
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
|
||||
filename = self.lib.library_dir / entry.path / entry.filename
|
||||
# if self.lib.is_legacy_library:
|
||||
# title += ' (Legacy Format)'
|
||||
h1 = f"[{index + 1}/{len(self.filtered_entries)}] {filename}"
|
||||
|
||||
# print(self.format_subtitle(subtitle))
|
||||
print(
|
||||
self.format_h1(
|
||||
h1, self.get_file_color(os.path.splitext(filename)[1])
|
||||
)
|
||||
self.format_h1(h1, self.get_file_color(filename.suffix.lower()))
|
||||
)
|
||||
print("")
|
||||
|
||||
if not os.path.isfile(filename):
|
||||
if not filename.is_file():
|
||||
print(
|
||||
f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}"
|
||||
)
|
||||
@@ -2523,7 +2514,7 @@ class CliDriver:
|
||||
|
||||
while True:
|
||||
entry = self.lib.get_entry_from_index(index)
|
||||
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
|
||||
filename = self.lib.library_dir / entry.path / entry.filename
|
||||
|
||||
if refresh:
|
||||
if clear_scr:
|
||||
@@ -2538,7 +2529,7 @@ class CliDriver:
|
||||
|
||||
for i, match in enumerate(self.lib.missing_matches[filename]):
|
||||
print(self.format_h1(f"[{i+1}] {match}"), end="\n\n")
|
||||
fn = f'{os.path.normpath(self.lib.library_dir + "/" + match + "/" + entry.filename)}'
|
||||
fn = self.lib.library_dir / match / entry.filename
|
||||
self.print_thumbnail(
|
||||
index=-1,
|
||||
filepath=fn,
|
||||
@@ -2581,9 +2572,7 @@ class CliDriver:
|
||||
# Open =============================================================
|
||||
elif com[0].lower() == "open" or com[0].lower() == "o":
|
||||
for match in self.lib.missing_matches[filename]:
|
||||
fn = os.path.normpath(
|
||||
self.lib.library_dir + "/" + match + "/" + entry.filename
|
||||
)
|
||||
fn = self.lib.library_dir / match / entry.filename
|
||||
open_file(fn)
|
||||
refresh = False
|
||||
# clear()
|
||||
@@ -2626,9 +2615,7 @@ class CliDriver:
|
||||
while True:
|
||||
dupe = self.lib.dupe_files[index]
|
||||
|
||||
if os.path.exists(os.path.normpath(f"{dupe[0]}")) and os.path.exists(
|
||||
os.path.normpath(f"{dupe[1]}")
|
||||
):
|
||||
if dupe[0].exists() and dupe[1].exists():
|
||||
# entry = self.lib.get_entry_from_index(index_1)
|
||||
entry_1_index = self.lib.get_entry_id_from_filepath(dupe[0])
|
||||
entry_2_index = self.lib.get_entry_id_from_filepath(dupe[1])
|
||||
@@ -2775,7 +2762,7 @@ class CliDriver:
|
||||
title = f"{self.base_title} - Library '{self.lib.library_dir}'"
|
||||
|
||||
entry = self.lib.entries[entry_index]
|
||||
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
|
||||
filename = self.lib.library_dir / entry.path / entry.filename
|
||||
field_name = self.lib.get_field_attr(entry.fields[field_index], "name")
|
||||
subtitle = f'Editing "{field_name}" Field'
|
||||
h1 = f"{filename}"
|
||||
@@ -2792,7 +2779,7 @@ class CliDriver:
|
||||
)
|
||||
print("")
|
||||
|
||||
if not os.path.isfile(filename):
|
||||
if not filename.is_file():
|
||||
print(
|
||||
f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}"
|
||||
)
|
||||
@@ -3044,7 +3031,7 @@ class CliDriver:
|
||||
title = f"{self.base_title} - Library '{self.lib.library_dir}'"
|
||||
|
||||
entry = self.lib.entries[entry_index]
|
||||
filename = f'{os.path.normpath(self.lib.library_dir + "/" + entry.path + "/" + entry.filename)}'
|
||||
filename = self.lib.library_dir / entry.path / entry.filename
|
||||
field_name = self.lib.get_field_attr(entry.fields[field_index], "name")
|
||||
subtitle = f'Editing "{field_name}" Field'
|
||||
h1 = f"{filename}"
|
||||
@@ -3057,7 +3044,7 @@ class CliDriver:
|
||||
print(self.format_h1(h1, self.get_file_color(os.path.splitext(filename)[1])))
|
||||
print("")
|
||||
|
||||
if not os.path.isfile(filename):
|
||||
if not filename.is_file():
|
||||
print(
|
||||
f"{RED_BG}{BRIGHT_WHITE_FG}[File Missing]{RESET}{BRIGHT_RED_FG} (Run 'fix missing' to resolve){RESET}"
|
||||
)
|
||||
|
||||
165
tagstudio/src/core/constants.py
Normal file
@@ -0,0 +1,165 @@
|
||||
VERSION: str = "9.3.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
TS_FOLDER_NAME: str = ".TagStudio"
|
||||
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",
|
||||
]
|
||||
@@ -8,9 +8,19 @@ class SettingItems(str, enum.Enum):
|
||||
LAST_LIBRARY = "last_library"
|
||||
LIBS_LIST = "libs_list"
|
||||
WINDOW_SHOW_LIBS = "window_show_libs"
|
||||
AUTOPLAY = "autoplay_videos"
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
COLOR_BG = "#65000000"
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
COLOR_DISABLED = "#65F39CAA"
|
||||
COLOR_DISABLED_BG = "#65440D12"
|
||||
|
||||
|
||||
class SearchMode(int, enum.Enum):
|
||||
"""Operational modes for item searching."""
|
||||
|
||||
AND = 0
|
||||
OR = 1
|
||||
|
||||
@@ -19,7 +19,7 @@ class FieldTemplate:
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import TypedDict
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
|
||||
class JsonLibary(TypedDict("", {"ts-version": str})):
|
||||
@@ -8,6 +9,9 @@ class JsonLibary(TypedDict("", {"ts-version": str})):
|
||||
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):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ColorType(Enum):
|
||||
class ColorType(int, Enum):
|
||||
PRIMARY = 0
|
||||
TEXT = 1
|
||||
BORDER = 2
|
||||
@@ -278,7 +278,7 @@ _TAG_COLORS = {
|
||||
}
|
||||
|
||||
|
||||
def get_tag_color(type: ColorType, color: str):
|
||||
def get_tag_color(type, color):
|
||||
color = color.lower()
|
||||
try:
|
||||
if type == ColorType.TEXT:
|
||||
|
||||
@@ -6,145 +6,10 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.library import Entry, Library
|
||||
|
||||
VERSION: str = "9.2.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"
|
||||
|
||||
# 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",
|
||||
]
|
||||
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"]
|
||||
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",
|
||||
]
|
||||
from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS
|
||||
|
||||
|
||||
class TagStudioCore:
|
||||
@@ -156,7 +21,7 @@ class TagStudioCore:
|
||||
def __init__(self):
|
||||
self.lib: Library = Library()
|
||||
|
||||
def get_gdl_sidecar(self, filepath: str, source: str = "") -> dict:
|
||||
def get_gdl_sidecar(self, filepath: str | Path, source: str = "") -> dict:
|
||||
"""
|
||||
Attempts to open and dump a Gallery-DL Sidecar sidecar file for
|
||||
the filepath.\n Returns a formatted object with notable values or an
|
||||
@@ -164,16 +29,19 @@ class TagStudioCore:
|
||||
"""
|
||||
json_dump = {}
|
||||
info = {}
|
||||
_filepath: Path = Path(filepath)
|
||||
_filepath = _filepath.parent / (_filepath.stem + ".json")
|
||||
|
||||
# NOTE: This fixes an unknown (recent?) bug in Gallery-DL where Instagram sidecar
|
||||
# files may be downloaded with indices starting at 1 rather than 0, unlike the posts.
|
||||
# This may only occur with sidecar files that are downloaded separate from posts.
|
||||
if source == "instagram":
|
||||
if not os.path.isfile(os.path.normpath(filepath + ".json")):
|
||||
filepath = filepath[:-16] + "1" + filepath[-15:]
|
||||
if not _filepath.is_file():
|
||||
newstem = _filepath.stem[:-16] + "1" + _filepath.stem[-15:]
|
||||
_filepath = _filepath.parent / (newstem + ".json")
|
||||
|
||||
try:
|
||||
with open(os.path.normpath(filepath + ".json"), "r", encoding="utf8") as f:
|
||||
with open(_filepath, "r", encoding="utf8") as f:
|
||||
json_dump = json.load(f)
|
||||
|
||||
if json_dump:
|
||||
@@ -237,19 +105,17 @@ class TagStudioCore:
|
||||
def match_conditions(self, entry_id: int) -> None:
|
||||
"""Matches defined conditions against a file to add Entry data."""
|
||||
|
||||
cond_file = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{TS_FOLDER_NAME}/conditions.json"
|
||||
)
|
||||
cond_file = self.lib.library_dir / TS_FOLDER_NAME / "conditions.json"
|
||||
# TODO: Make this stored somewhere better instead of temporarily in this JSON file.
|
||||
entry: Entry = self.lib.get_entry(entry_id)
|
||||
try:
|
||||
if os.path.isfile(cond_file):
|
||||
if cond_file.is_file():
|
||||
with open(cond_file, "r", encoding="utf8") as f:
|
||||
json_dump = json.load(f)
|
||||
for c in json_dump["conditions"]:
|
||||
match: bool = False
|
||||
for path_c in c["path_conditions"]:
|
||||
if os.path.normpath(path_c) in entry.path:
|
||||
if str(Path(path_c).resolve()) in str(entry.path):
|
||||
match = True
|
||||
break
|
||||
if match:
|
||||
@@ -300,7 +166,7 @@ class TagStudioCore:
|
||||
# input()
|
||||
pass
|
||||
|
||||
def build_url(self, entry_id: int, source: str) -> str:
|
||||
def build_url(self, entry_id: int, source: str):
|
||||
"""Tries to rebuild a source URL given a specific filename structure."""
|
||||
|
||||
source = source.lower().replace("-", " ").replace("_", " ")
|
||||
@@ -316,7 +182,7 @@ class TagStudioCore:
|
||||
"""
|
||||
try:
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
stubs = entry.filename.rsplit("_", 3)
|
||||
stubs = str(entry.filename).rsplit("_", 3)
|
||||
# print(stubs)
|
||||
# source, author = os.path.split(entry.path)
|
||||
url = f"www.twitter.com/{stubs[0]}/status/{stubs[-3]}/photo/{stubs[-2]}"
|
||||
@@ -331,7 +197,7 @@ class TagStudioCore:
|
||||
"""
|
||||
try:
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
stubs = entry.filename.rsplit("_", 2)
|
||||
stubs = str(entry.filename).rsplit("_", 2)
|
||||
# stubs[0] = stubs[0].replace(f"{author}_", '', 1)
|
||||
# print(stubs)
|
||||
# NOTE: Both Instagram usernames AND their ID can have underscores in them,
|
||||
|
||||
27
tagstudio/src/core/utils/encoding.py
Normal 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"]
|
||||
59
tagstudio/src/qt/helpers/color_overlay.py
Normal 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
|
||||
@@ -8,6 +8,7 @@ import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
@@ -19,7 +20,7 @@ INFO = f"[INFO]"
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
def open_file(path: str, file_manager: bool = False):
|
||||
def open_file(path: str | Path, file_manager: bool = False):
|
||||
"""Open a file in the default application or file explorer.
|
||||
|
||||
Args:
|
||||
@@ -27,13 +28,14 @@ def open_file(path: str, file_manager: bool = False):
|
||||
file_manager (bool, optional): Whether to open the file in the file manager (e.g. Finder on macOS).
|
||||
Defaults to False.
|
||||
"""
|
||||
logging.info(f"Opening file: {path}")
|
||||
if not os.path.exists(path):
|
||||
logging.error(f"File not found: {path}")
|
||||
_path = str(path)
|
||||
logging.info(f"Opening file: {_path}")
|
||||
if not os.path.exists(_path):
|
||||
logging.error(f"File not found: {_path}")
|
||||
return
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
normpath = os.path.normpath(path)
|
||||
normpath = os.path.normpath(_path)
|
||||
if file_manager:
|
||||
command_name = "explorer"
|
||||
command_args = '/select,"' + normpath + '"'
|
||||
@@ -59,7 +61,7 @@ def open_file(path: str, file_manager: bool = False):
|
||||
else:
|
||||
if sys.platform == "darwin":
|
||||
command_name = "open"
|
||||
command_args = [path]
|
||||
command_args = [_path]
|
||||
if file_manager:
|
||||
# will reveal in Finder
|
||||
command_args.append("-R")
|
||||
@@ -73,12 +75,12 @@ def open_file(path: str, file_manager: bool = False):
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
f"array:string:file://{path}",
|
||||
f"array:string:file://{_path}",
|
||||
"string:",
|
||||
]
|
||||
else:
|
||||
command_name = "xdg-open"
|
||||
command_args = [path]
|
||||
command_args = [_path]
|
||||
command = shutil.which(command_name)
|
||||
if command is not None:
|
||||
subprocess.Popen([command] + command_args, close_fds=True)
|
||||
@@ -89,21 +91,21 @@ def open_file(path: str, file_manager: bool = False):
|
||||
|
||||
|
||||
class FileOpenerHelper:
|
||||
def __init__(self, filepath: str):
|
||||
def __init__(self, filepath: str | Path):
|
||||
"""Initialize the FileOpenerHelper.
|
||||
|
||||
Args:
|
||||
filepath (str): The path to the file to open.
|
||||
"""
|
||||
self.filepath = filepath
|
||||
self.filepath = str(filepath)
|
||||
|
||||
def set_filepath(self, filepath: str):
|
||||
def set_filepath(self, filepath: str | Path):
|
||||
"""Set the filepath to open.
|
||||
|
||||
Args:
|
||||
filepath (str): The path to the file to open.
|
||||
"""
|
||||
self.filepath = filepath
|
||||
self.filepath = str(filepath)
|
||||
|
||||
def open_file(self):
|
||||
"""Open the file in the default application."""
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
from types import FunctionType
|
||||
|
||||
from PySide6.QtCore import Signal, QObject
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class FunctionIterator(QObject):
|
||||
@@ -13,7 +12,7 @@ class FunctionIterator(QObject):
|
||||
|
||||
value = Signal(object)
|
||||
|
||||
def __init__(self, function: FunctionType):
|
||||
def __init__(self, function: Callable):
|
||||
super().__init__()
|
||||
self.iterable = function
|
||||
|
||||
|
||||
65
tagstudio/src/qt/helpers/gradient.py
Normal 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
|
||||
@@ -12,215 +12,227 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,
|
||||
QSize, Qt)
|
||||
from PySide6.QtGui import (QFont, QAction)
|
||||
|
||||
import logging
|
||||
import typing
|
||||
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt)
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
|
||||
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
|
||||
QPushButton, QScrollArea, QSizePolicy,
|
||||
QStatusBar, QWidget, QSplitter)
|
||||
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
|
||||
QPushButton, QScrollArea, QSizePolicy,
|
||||
QStatusBar, QWidget, QSplitter, QCheckBox,
|
||||
QSpacerItem)
|
||||
from src.qt.pagination import Pagination
|
||||
from src.qt.widgets.landing import LandingWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class Ui_MainWindow(QMainWindow):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setupUi(self)
|
||||
|
||||
def __init__(self, driver: "QtDriver", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.driver: "QtDriver" = driver
|
||||
self.setupUi(self)
|
||||
|
||||
# self.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
|
||||
# self.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False)
|
||||
# # self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
# NOTE: These are old attempts to allow for a translucent/acrylic
|
||||
# window effect. This may be attempted again in the future.
|
||||
# self.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
|
||||
# self.setWindowFlag(Qt.WindowType.WindowTransparentForInput, False)
|
||||
# # self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
# self.windowFX = WindowEffect()
|
||||
# self.windowFX.setAcrylicEffect(self.winId(), isEnableShadow=False)
|
||||
# self.windowFX = WindowEffect()
|
||||
# self.windowFX.setAcrylicEffect(self.winId(), isEnableShadow=False)
|
||||
|
||||
# # self.setStyleSheet(
|
||||
# # 'background:#EE000000;'
|
||||
# # )
|
||||
|
||||
# # self.setStyleSheet(
|
||||
# # 'background:#EE000000;'
|
||||
# # )
|
||||
|
||||
|
||||
def setupUi(self, MainWindow):
|
||||
if not MainWindow.objectName():
|
||||
MainWindow.setObjectName(u"MainWindow")
|
||||
MainWindow.resize(1300, 720)
|
||||
|
||||
self.centralwidget = QWidget(MainWindow)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
def setupUi(self, MainWindow):
|
||||
if not MainWindow.objectName():
|
||||
MainWindow.setObjectName(u"MainWindow")
|
||||
MainWindow.resize(1300, 720)
|
||||
|
||||
self.centralwidget = QWidget(MainWindow)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
|
||||
# ComboBox goup for search type and thumbnail size
|
||||
self.horizontalLayout_3 = QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||
|
||||
# left side spacer
|
||||
spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||
self.horizontalLayout_3.addItem(spacerItem)
|
||||
|
||||
# Search type selector
|
||||
self.comboBox_2 = QComboBox(self.centralwidget)
|
||||
self.comboBox_2.setMinimumSize(QSize(165, 0))
|
||||
self.comboBox_2.setObjectName("comboBox_2")
|
||||
self.comboBox_2.addItem("")
|
||||
self.comboBox_2.addItem("")
|
||||
self.horizontalLayout_3.addWidget(self.comboBox_2)
|
||||
|
||||
# Thumbnail Size placeholder
|
||||
self.comboBox = QComboBox(self.centralwidget)
|
||||
self.comboBox.setObjectName(u"comboBox")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(
|
||||
self.comboBox.sizePolicy().hasHeightForWidth())
|
||||
self.comboBox.setSizePolicy(sizePolicy)
|
||||
self.comboBox.setMinimumWidth(128)
|
||||
self.comboBox.setMaximumWidth(128)
|
||||
self.horizontalLayout_3.addWidget(self.comboBox)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
|
||||
|
||||
self.splitter = QSplitter()
|
||||
self.splitter.setObjectName(u"splitter")
|
||||
self.splitter.setHandleWidth(12)
|
||||
self.splitter = QSplitter()
|
||||
self.splitter.setObjectName(u"splitter")
|
||||
self.splitter.setHandleWidth(12)
|
||||
|
||||
self.frame_container = QWidget()
|
||||
self.frame_layout = QVBoxLayout(self.frame_container)
|
||||
self.frame_layout.setSpacing(0)
|
||||
self.frame_container = QWidget()
|
||||
self.frame_layout = QVBoxLayout(self.frame_container)
|
||||
self.frame_layout.setSpacing(0)
|
||||
|
||||
self.scrollArea = QScrollArea()
|
||||
self.scrollArea.setObjectName(u"scrollArea")
|
||||
self.scrollArea.setFocusPolicy(Qt.WheelFocus)
|
||||
self.scrollArea.setFrameShape(QFrame.NoFrame)
|
||||
self.scrollArea.setFrameShadow(QFrame.Plain)
|
||||
self.scrollArea.setWidgetResizable(True)
|
||||
self.scrollAreaWidgetContents = QWidget()
|
||||
self.scrollAreaWidgetContents.setObjectName(
|
||||
u"scrollAreaWidgetContents")
|
||||
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 590))
|
||||
self.gridLayout_2 = QGridLayout(self.scrollAreaWidgetContents)
|
||||
self.gridLayout_2.setSpacing(8)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(0, 0, 0, 8)
|
||||
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
|
||||
self.frame_layout.addWidget(self.scrollArea)
|
||||
self.scrollArea = QScrollArea()
|
||||
self.scrollArea.setObjectName(u"scrollArea")
|
||||
self.scrollArea.setFocusPolicy(Qt.WheelFocus)
|
||||
self.scrollArea.setFrameShape(QFrame.NoFrame)
|
||||
self.scrollArea.setFrameShadow(QFrame.Plain)
|
||||
self.scrollArea.setWidgetResizable(True)
|
||||
self.scrollAreaWidgetContents = QWidget()
|
||||
self.scrollAreaWidgetContents.setObjectName(
|
||||
u"scrollAreaWidgetContents")
|
||||
self.scrollAreaWidgetContents.setGeometry(QRect(0, 0, 1260, 590))
|
||||
self.gridLayout_2 = QGridLayout(self.scrollAreaWidgetContents)
|
||||
self.gridLayout_2.setSpacing(8)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(0, 0, 0, 8)
|
||||
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
|
||||
self.frame_layout.addWidget(self.scrollArea)
|
||||
|
||||
self.landing_widget: LandingWidget = LandingWidget(self.driver, self.devicePixelRatio())
|
||||
self.frame_layout.addWidget(self.landing_widget)
|
||||
|
||||
# self.page_bar_controls = QWidget()
|
||||
# self.page_bar_controls.setStyleSheet('background:blue;')
|
||||
# self.page_bar_controls.setMinimumHeight(32)
|
||||
self.pagination = Pagination()
|
||||
self.frame_layout.addWidget(self.pagination)
|
||||
|
||||
self.pagination = Pagination()
|
||||
self.frame_layout.addWidget(self.pagination)
|
||||
self.horizontalLayout.addWidget(self.splitter)
|
||||
self.splitter.addWidget(self.frame_container)
|
||||
self.splitter.setStretchFactor(0, 1)
|
||||
|
||||
# self.frame_layout.addWidget(self.page_bar_controls)
|
||||
# self.frame_layout.addWidget(self.page_bar_controls)
|
||||
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 1)
|
||||
|
||||
self.horizontalLayout.addWidget(self.splitter)
|
||||
self.splitter.addWidget(self.frame_container)
|
||||
self.splitter.setStretchFactor(0, 1)
|
||||
self.horizontalLayout_2 = QHBoxLayout()
|
||||
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
|
||||
self.horizontalLayout_2.setSizeConstraint(QLayout.SetMinimumSize)
|
||||
self.backButton = QPushButton(self.centralwidget)
|
||||
self.backButton.setObjectName(u"backButton")
|
||||
self.backButton.setMinimumSize(QSize(0, 32))
|
||||
self.backButton.setMaximumSize(QSize(32, 16777215))
|
||||
font = QFont()
|
||||
font.setPointSize(14)
|
||||
font.setBold(True)
|
||||
self.backButton.setFont(font)
|
||||
|
||||
self.gridLayout.addLayout(self.horizontalLayout, 10, 0, 1, 1)
|
||||
self.horizontalLayout_2.addWidget(self.backButton)
|
||||
|
||||
self.horizontalLayout_2 = QHBoxLayout()
|
||||
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
|
||||
self.horizontalLayout_2.setSizeConstraint(QLayout.SetMinimumSize)
|
||||
self.backButton = QPushButton(self.centralwidget)
|
||||
self.backButton.setObjectName(u"backButton")
|
||||
self.backButton.setMinimumSize(QSize(0, 32))
|
||||
self.backButton.setMaximumSize(QSize(32, 16777215))
|
||||
font = QFont()
|
||||
font.setPointSize(14)
|
||||
font.setBold(True)
|
||||
self.backButton.setFont(font)
|
||||
self.forwardButton = QPushButton(self.centralwidget)
|
||||
self.forwardButton.setObjectName(u"forwardButton")
|
||||
self.forwardButton.setMinimumSize(QSize(0, 32))
|
||||
self.forwardButton.setMaximumSize(QSize(32, 16777215))
|
||||
font1 = QFont()
|
||||
font1.setPointSize(14)
|
||||
font1.setBold(True)
|
||||
font1.setKerning(True)
|
||||
self.forwardButton.setFont(font1)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.backButton)
|
||||
self.horizontalLayout_2.addWidget(self.forwardButton)
|
||||
|
||||
self.forwardButton = QPushButton(self.centralwidget)
|
||||
self.forwardButton.setObjectName(u"forwardButton")
|
||||
self.forwardButton.setMinimumSize(QSize(0, 32))
|
||||
self.forwardButton.setMaximumSize(QSize(32, 16777215))
|
||||
font1 = QFont()
|
||||
font1.setPointSize(14)
|
||||
font1.setBold(True)
|
||||
font1.setKerning(True)
|
||||
self.forwardButton.setFont(font1)
|
||||
self.searchField = QLineEdit(self.centralwidget)
|
||||
self.searchField.setObjectName(u"searchField")
|
||||
self.searchField.setMinimumSize(QSize(0, 32))
|
||||
font2 = QFont()
|
||||
font2.setPointSize(11)
|
||||
font2.setBold(False)
|
||||
self.searchField.setFont(font2)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.forwardButton)
|
||||
self.horizontalLayout_2.addWidget(self.searchField)
|
||||
|
||||
self.searchField = QLineEdit(self.centralwidget)
|
||||
self.searchField.setObjectName(u"searchField")
|
||||
self.searchField.setMinimumSize(QSize(0, 32))
|
||||
font2 = QFont()
|
||||
font2.setPointSize(11)
|
||||
font2.setBold(False)
|
||||
self.searchField.setFont(font2)
|
||||
self.searchButton = QPushButton(self.centralwidget)
|
||||
self.searchButton.setObjectName(u"searchButton")
|
||||
self.searchButton.setMinimumSize(QSize(0, 32))
|
||||
self.searchButton.setFont(font2)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.searchField)
|
||||
self.horizontalLayout_2.addWidget(self.searchButton)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
|
||||
self.gridLayout_2.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.searchButton = QPushButton(self.centralwidget)
|
||||
self.searchButton.setObjectName(u"searchButton")
|
||||
self.searchButton.setMinimumSize(QSize(0, 32))
|
||||
self.searchButton.setFont(font2)
|
||||
MainWindow.setCentralWidget(self.centralwidget)
|
||||
self.statusbar = QStatusBar(MainWindow)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(0)
|
||||
sizePolicy1.setHeightForWidth(
|
||||
self.statusbar.sizePolicy().hasHeightForWidth())
|
||||
self.statusbar.setSizePolicy(sizePolicy1)
|
||||
MainWindow.setStatusBar(self.statusbar)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.searchButton)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_2, 3, 0, 1, 1)
|
||||
self.retranslateUi(MainWindow)
|
||||
|
||||
self.comboBox = QComboBox(self.centralwidget)
|
||||
self.comboBox.setObjectName(u"comboBox")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(
|
||||
self.comboBox.sizePolicy().hasHeightForWidth())
|
||||
self.comboBox.setSizePolicy(sizePolicy)
|
||||
self.comboBox.setMinimumWidth(128)
|
||||
self.comboBox.setMaximumWidth(128)
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
# setupUi
|
||||
|
||||
self.gridLayout.addWidget(self.comboBox, 4, 0, 1, 1, Qt.AlignRight)
|
||||
def retranslateUi(self, MainWindow):
|
||||
MainWindow.setWindowTitle(QCoreApplication.translate(
|
||||
"MainWindow", u"MainWindow", None))
|
||||
# Navigation buttons
|
||||
self.backButton.setText(
|
||||
QCoreApplication.translate("MainWindow", u"<", None))
|
||||
self.forwardButton.setText(
|
||||
QCoreApplication.translate("MainWindow", u">", None))
|
||||
|
||||
# Search field
|
||||
self.searchField.setPlaceholderText(
|
||||
QCoreApplication.translate("MainWindow", u"Search Entries", None))
|
||||
self.searchButton.setText(
|
||||
QCoreApplication.translate("MainWindow", u"Search", None))
|
||||
|
||||
# Search type selector
|
||||
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)"))
|
||||
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)"))
|
||||
self.comboBox.setCurrentText("")
|
||||
|
||||
# Thumbnail size selector
|
||||
self.comboBox.setPlaceholderText(
|
||||
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
|
||||
# retranslateUi
|
||||
|
||||
self.gridLayout_2.setContentsMargins(6, 6, 6, 6)
|
||||
def moveEvent(self, event) -> None:
|
||||
# time.sleep(0.02) # sleep for 20ms
|
||||
pass
|
||||
|
||||
MainWindow.setCentralWidget(self.centralwidget)
|
||||
# self.menubar = QMenuBar(MainWindow)
|
||||
# self.menubar.setObjectName(u"menubar")
|
||||
# self.menubar.setGeometry(QRect(0, 0, 1280, 22))
|
||||
# MainWindow.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(MainWindow)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(0)
|
||||
sizePolicy1.setHeightForWidth(
|
||||
self.statusbar.sizePolicy().hasHeightForWidth())
|
||||
self.statusbar.setSizePolicy(sizePolicy1)
|
||||
MainWindow.setStatusBar(self.statusbar)
|
||||
def resizeEvent(self, event) -> None:
|
||||
# time.sleep(0.02) # sleep for 20ms
|
||||
pass
|
||||
|
||||
# menu_bar = self.menuBar()
|
||||
# self.setMenuBar(menu_bar)
|
||||
# self.gridLayout.addWidget(menu_bar, 4, 0, 1, 1, Qt.AlignRight)
|
||||
# self.frame_layout.addWidget(menu_bar)
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, MainWindow):
|
||||
MainWindow.setWindowTitle(QCoreApplication.translate(
|
||||
"MainWindow", u"MainWindow", None))
|
||||
self.backButton.setText(
|
||||
QCoreApplication.translate("MainWindow", u"<", None))
|
||||
self.forwardButton.setText(
|
||||
QCoreApplication.translate("MainWindow", u">", None))
|
||||
self.searchField.setPlaceholderText(
|
||||
QCoreApplication.translate("MainWindow", u"Search Entries", None))
|
||||
self.searchButton.setText(
|
||||
QCoreApplication.translate("MainWindow", u"Search", None))
|
||||
self.comboBox.setCurrentText("")
|
||||
self.comboBox.setPlaceholderText(
|
||||
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
|
||||
# retranslateUi
|
||||
|
||||
def moveEvent(self, event) -> None:
|
||||
# time.sleep(0.02) # sleep for 20ms
|
||||
pass
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
# time.sleep(0.02) # sleep for 20ms
|
||||
pass
|
||||
|
||||
# def _createMenuBar(self, main_window):
|
||||
# menu_bar = QMenuBar(main_window)
|
||||
# file_menu = QMenu('&File', main_window)
|
||||
# edit_menu = QMenu('&Edit', main_window)
|
||||
# tools_menu = QMenu('&Tools', main_window)
|
||||
# macros_menu = QMenu('&Macros', main_window)
|
||||
# help_menu = QMenu('&Help', main_window)
|
||||
|
||||
# file_menu.addAction(QAction('&New Library', main_window))
|
||||
# file_menu.addAction(QAction('&Open Library', main_window))
|
||||
# file_menu.addAction(QAction('&Save Library', main_window))
|
||||
# file_menu.addAction(QAction('&Close Library', main_window))
|
||||
|
||||
# file_menu.addAction(QAction('&Refresh Directories', main_window))
|
||||
# file_menu.addAction(QAction('&Add New Files to Library', main_window))
|
||||
|
||||
# menu_bar.addMenu(file_menu)
|
||||
# menu_bar.addMenu(edit_menu)
|
||||
# menu_bar.addMenu(tools_menu)
|
||||
# menu_bar.addMenu(macros_menu)
|
||||
# menu_bar.addMenu(help_menu)
|
||||
|
||||
# main_window.setMenuBar(menu_bar)
|
||||
def toggle_landing_page(self, enabled: bool):
|
||||
if enabled:
|
||||
self.scrollArea.setHidden(True)
|
||||
self.landing_widget.setHidden(False)
|
||||
self.landing_widget.animate_logo_in()
|
||||
else:
|
||||
self.landing_widget.setHidden(True)
|
||||
self.landing_widget.set_status_label("")
|
||||
self.scrollArea.setHidden(False)
|
||||
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.core.ts_core import TAG_COLORS
|
||||
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
|
||||
@@ -96,7 +96,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.subtags_layout.setSpacing(0)
|
||||
self.subtags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.subtags_title = QLabel()
|
||||
self.subtags_title.setText("Subtags")
|
||||
self.subtags_title.setText("Parent Tags")
|
||||
self.subtags_layout.addWidget(self.subtags_title)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
@@ -118,7 +118,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.subtags_add_button.setText("+")
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
|
||||
self.add_tag_modal = PanelModal(tsp, "Add Subtags", "Add Subtags")
|
||||
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
|
||||
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
self.subtags_layout.addWidget(self.subtags_add_button)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.setWindowTitle(f"Delete Unlinked Entries")
|
||||
self.setWindowTitle("Delete Unlinked Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(500, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -78,23 +78,9 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
||||
|
||||
self.model.clear()
|
||||
for i in self.lib.missing_files:
|
||||
self.model.appendRow(QStandardItem(i))
|
||||
self.model.appendRow(QStandardItem(str(i)))
|
||||
|
||||
def delete_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Deleting Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.lib.ref(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
|
||||
iterator = FunctionIterator(self.lib.remove_missing_files)
|
||||
|
||||
pw = ProgressWidget(
|
||||
@@ -119,23 +105,3 @@ class DeleteUnlinkedEntriesModal(QWidget):
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
|
||||
|
||||
# def delete_entries_runnable(self):
|
||||
# deleted = []
|
||||
# for i, missing in enumerate(self.lib.missing_files):
|
||||
# # pb.setValue(i)
|
||||
# # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries')
|
||||
# try:
|
||||
# id = self.lib.get_entry_id_from_filepath(missing)
|
||||
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
|
||||
# self.lib.remove_entry(id)
|
||||
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
|
||||
# deleted.append(missing)
|
||||
# except KeyError:
|
||||
# logging.info(
|
||||
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
|
||||
# yield i
|
||||
# for d in deleted:
|
||||
# self.lib.missing_files.remove(d)
|
||||
# # self.driver.filter_items('')
|
||||
# # self.done.emit()
|
||||
|
||||
@@ -4,51 +4,110 @@
|
||||
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem
|
||||
from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QWidget,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QStyledItemDelegate,
|
||||
QLineEdit,
|
||||
QComboBox,
|
||||
QLabel,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
|
||||
|
||||
class FileExtensionItemDelegate(QStyledItemDelegate):
|
||||
def setModelData(self, editor, model, index):
|
||||
if isinstance(editor, QLineEdit):
|
||||
if editor.text() and not editor.text().startswith("."):
|
||||
editor.setText(f".{editor.text()}")
|
||||
super().setModelData(editor, model, index)
|
||||
|
||||
|
||||
class FileExtensionModal(PanelWidget):
|
||||
done = Signal()
|
||||
|
||||
def __init__(self, library: "Library"):
|
||||
super().__init__()
|
||||
# Initialize Modal =====================================================
|
||||
self.lib = library
|
||||
self.setWindowTitle(f"File Extensions")
|
||||
self.setWindowTitle("File Extensions")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(200, 400)
|
||||
self.setMinimumSize(240, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.table = QTableWidget(len(self.lib.ignored_extensions), 1)
|
||||
# Create Table Widget --------------------------------------------------
|
||||
self.table = QTableWidget(len(self.lib.ext_list), 1)
|
||||
self.table.horizontalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.setItemDelegate(FileExtensionItemDelegate())
|
||||
|
||||
# Create "Add Button" Widget -------------------------------------------
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setText("&Add Extension")
|
||||
self.add_button.clicked.connect(self.add_item)
|
||||
self.add_button.setDefault(True)
|
||||
self.add_button.setMinimumWidth(100)
|
||||
|
||||
# Create Mode Widgets --------------------------------------------------
|
||||
self.mode_widget = QWidget()
|
||||
self.mode_layout = QHBoxLayout(self.mode_widget)
|
||||
self.mode_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.mode_layout.setSpacing(12)
|
||||
self.mode_label = QLabel()
|
||||
self.mode_label.setText("List Mode:")
|
||||
self.mode_combobox = QComboBox()
|
||||
self.mode_combobox.setEditable(False)
|
||||
self.mode_combobox.addItem("Exclude")
|
||||
self.mode_combobox.addItem("Include")
|
||||
self.mode_combobox.setCurrentIndex(0 if self.lib.is_exclude_list else 1)
|
||||
self.mode_combobox.currentIndexChanged.connect(
|
||||
lambda i: self.update_list_mode(i)
|
||||
)
|
||||
self.mode_layout.addWidget(self.mode_label)
|
||||
self.mode_layout.addWidget(self.mode_combobox)
|
||||
self.mode_layout.setStretch(1, 1)
|
||||
|
||||
# Add Widgets To Layout ------------------------------------------------
|
||||
self.root_layout.addWidget(self.mode_widget)
|
||||
self.root_layout.addWidget(self.table)
|
||||
self.root_layout.addWidget(
|
||||
self.add_button, alignment=Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
|
||||
# Finalize Modal -------------------------------------------------------
|
||||
self.refresh_list()
|
||||
|
||||
def update_list_mode(self, mode: int):
|
||||
"""
|
||||
Update the mode of the extension list: "Exclude" or "Include".
|
||||
|
||||
Args:
|
||||
mode (int): The list mode, given by the index of the mode inside
|
||||
the mode combobox. 0 for "Exclude", 1 for "Include".
|
||||
"""
|
||||
if mode == 0:
|
||||
self.lib.is_exclude_list = True
|
||||
elif mode == 1:
|
||||
self.lib.is_exclude_list = False
|
||||
|
||||
def refresh_list(self):
|
||||
for i, ext in enumerate(self.lib.ignored_extensions):
|
||||
for i, ext in enumerate(self.lib.ext_list):
|
||||
self.table.setItem(i, 0, QTableWidgetItem(ext))
|
||||
|
||||
def add_item(self):
|
||||
self.table.insertRow(self.table.rowCount())
|
||||
|
||||
def save(self):
|
||||
self.lib.ignored_extensions.clear()
|
||||
self.lib.ext_list.clear()
|
||||
for i in range(self.table.rowCount()):
|
||||
ext = self.table.item(i, 0)
|
||||
if ext and ext.text():
|
||||
self.lib.ignored_extensions.append(ext.text())
|
||||
self.lib.ext_list.append(ext.text())
|
||||
|
||||
@@ -137,9 +137,7 @@ class FixDupeFilesModal(QWidget):
|
||||
self.set_dupe_count(self.count)
|
||||
|
||||
def select_file(self):
|
||||
qfd = QFileDialog(
|
||||
self, "Open DupeGuru Results File", os.path.normpath(self.lib.library_dir)
|
||||
)
|
||||
qfd = QFileDialog(self, "Open DupeGuru Results File", str(self.lib.library_dir))
|
||||
qfd.setFileMode(QFileDialog.FileMode.ExistingFile)
|
||||
qfd.setNameFilter("DupeGuru Files (*.dupeguru)")
|
||||
if qfd.exec_():
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QThread, Qt, QThreadPool
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
|
||||
from src.core.library import Library
|
||||
@@ -14,6 +14,7 @@ from src.qt.helpers.function_iterator import FunctionIterator
|
||||
from src.qt.helpers.custom_runnable import CustomRunnable
|
||||
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
|
||||
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
|
||||
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
|
||||
from src.qt.widgets.progress import ProgressWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
@@ -21,65 +22,79 @@ if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class FixUnlinkedEntriesModal(QWidget):
|
||||
# done = Signal(int)
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.driver = driver
|
||||
self.count = -1
|
||||
self.setWindowTitle(f"Fix Unlinked Entries")
|
||||
self.missing_count = -1
|
||||
self.dupe_count = -1
|
||||
self.setWindowTitle("Fix Unlinked Entries")
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(400, 300)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
|
||||
self.desc_widget = QLabel()
|
||||
self.desc_widget.setObjectName("descriptionLabel")
|
||||
self.desc_widget.setWordWrap(True)
|
||||
self.desc_widget.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
"text-align:left;"
|
||||
# 'font-weight:bold;'
|
||||
# 'font-size:14px;'
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
self.unlinked_desc_widget = QLabel()
|
||||
self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel")
|
||||
self.unlinked_desc_widget.setWordWrap(True)
|
||||
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
|
||||
self.unlinked_desc_widget.setText(
|
||||
"""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired."""
|
||||
)
|
||||
self.desc_widget.setText("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
|
||||
Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""")
|
||||
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.missing_count = QLabel()
|
||||
self.missing_count.setObjectName("missingCountLabel")
|
||||
self.missing_count.setStyleSheet(
|
||||
# 'background:blue;'
|
||||
# 'text-align:center;'
|
||||
"font-weight:bold;"
|
||||
"font-size:14px;"
|
||||
# 'padding-top: 6px'
|
||||
""
|
||||
self.dupe_desc_widget = QLabel()
|
||||
self.dupe_desc_widget.setObjectName("dupeDescriptionLabel")
|
||||
self.dupe_desc_widget.setWordWrap(True)
|
||||
self.dupe_desc_widget.setStyleSheet("text-align:left;")
|
||||
self.dupe_desc_widget.setText(
|
||||
"""Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with "duplicate files", which are duplicates of your files themselves outside of TagStudio."""
|
||||
)
|
||||
self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# self.missing_count.setText('Missing Files: N/A')
|
||||
|
||||
self.refresh_button = QPushButton()
|
||||
self.refresh_button.setText("&Refresh")
|
||||
self.refresh_button.clicked.connect(lambda: self.refresh_missing_files())
|
||||
self.missing_count_label = QLabel()
|
||||
self.missing_count_label.setObjectName("missingCountLabel")
|
||||
self.missing_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
|
||||
self.missing_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.dupe_count_label = QLabel()
|
||||
self.dupe_count_label.setObjectName("dupeCountLabel")
|
||||
self.dupe_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
|
||||
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.refresh_unlinked_button = QPushButton()
|
||||
self.refresh_unlinked_button.setText("&Refresh All")
|
||||
self.refresh_unlinked_button.clicked.connect(
|
||||
lambda: self.refresh_missing_files()
|
||||
)
|
||||
|
||||
self.merge_class = MergeDuplicateEntries(self.lib, self.driver)
|
||||
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
|
||||
|
||||
self.search_button = QPushButton()
|
||||
self.search_button.setText("&Search && Relink")
|
||||
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
|
||||
self.relink_class.done.connect(lambda: self.refresh_missing_files())
|
||||
self.relink_class.done.connect(lambda: self.driver.update_thumbs())
|
||||
self.relink_class.done.connect(
|
||||
lambda: self.refresh_and_repair_dupe_entries(self.merge_class)
|
||||
)
|
||||
self.search_button.clicked.connect(lambda: self.relink_class.repair_entries())
|
||||
|
||||
self.refresh_dupe_button = QPushButton()
|
||||
self.refresh_dupe_button.setText("Refresh Duplicate Entries")
|
||||
self.refresh_dupe_button.clicked.connect(lambda: self.refresh_dupe_entries())
|
||||
|
||||
self.merge_dupe_button = QPushButton()
|
||||
self.merge_dupe_button.setText("&Merge Duplicate Entries")
|
||||
self.merge_class.done.connect(lambda: self.set_dupe_count(-1))
|
||||
self.merge_class.done.connect(lambda: self.set_missing_count(-1))
|
||||
self.merge_class.done.connect(lambda: self.driver.filter_items())
|
||||
self.merge_dupe_button.clicked.connect(lambda: self.merge_class.merge_entries())
|
||||
|
||||
self.manual_button = QPushButton()
|
||||
self.manual_button.setText("&Manual Relink")
|
||||
|
||||
@@ -92,14 +107,6 @@ class FixUnlinkedEntriesModal(QWidget):
|
||||
self.delete_button.setText("De&lete Unlinked Entries")
|
||||
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
|
||||
|
||||
# self.combo_box = QComboBox()
|
||||
# self.combo_box.setEditable(False)
|
||||
# # self.combo_box.setMaxVisibleItems(5)
|
||||
# self.combo_box.setStyleSheet('combobox-popup:0;')
|
||||
# self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
# for df in self.lib.default_fields:
|
||||
# self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
|
||||
|
||||
self.button_container = QWidget()
|
||||
self.button_layout = QHBoxLayout(self.button_container)
|
||||
self.button_layout.setContentsMargins(6, 6, 6, 6)
|
||||
@@ -107,50 +114,39 @@ class FixUnlinkedEntriesModal(QWidget):
|
||||
|
||||
self.done_button = QPushButton()
|
||||
self.done_button.setText("&Done")
|
||||
# self.save_button.setAutoDefault(True)
|
||||
self.done_button.setDefault(True)
|
||||
self.done_button.clicked.connect(self.hide)
|
||||
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
|
||||
self.button_layout.addWidget(self.done_button)
|
||||
|
||||
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
|
||||
|
||||
# self.done.connect(lambda x: callback(x))
|
||||
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
self.root_layout.addWidget(self.missing_count)
|
||||
self.root_layout.addWidget(self.refresh_button)
|
||||
self.root_layout.addWidget(self.missing_count_label)
|
||||
self.root_layout.addWidget(self.unlinked_desc_widget)
|
||||
self.root_layout.addWidget(self.refresh_unlinked_button)
|
||||
self.root_layout.addWidget(self.search_button)
|
||||
self.manual_button.setHidden(True)
|
||||
self.root_layout.addWidget(self.manual_button)
|
||||
self.root_layout.addWidget(self.delete_button)
|
||||
# self.root_layout.setStretch(1,2)
|
||||
self.root_layout.addStretch(1)
|
||||
self.root_layout.addWidget(self.dupe_count_label)
|
||||
self.root_layout.addWidget(self.dupe_desc_widget)
|
||||
self.root_layout.addWidget(self.refresh_dupe_button)
|
||||
self.root_layout.addWidget(self.merge_dupe_button)
|
||||
self.root_layout.addStretch(2)
|
||||
self.root_layout.addWidget(self.button_container)
|
||||
|
||||
self.set_missing_count(self.count)
|
||||
self.set_missing_count(self.missing_count)
|
||||
self.set_dupe_count(self.dupe_count)
|
||||
|
||||
def refresh_missing_files(self):
|
||||
logging.info(f"Start RMF: {QThread.currentThread()}")
|
||||
# pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Scanning Library')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
iterator = FunctionIterator(self.lib.refresh_missing_files)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text=f"Scanning Library for Unlinked Entries...",
|
||||
label_text="Scanning Library for Unlinked Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
# rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
@@ -159,30 +155,76 @@ class FixUnlinkedEntriesModal(QWidget):
|
||||
pw.deleteLater(),
|
||||
self.set_missing_count(len(self.lib.missing_files)),
|
||||
self.delete_modal.refresh_list(),
|
||||
self.refresh_dupe_entries(),
|
||||
)
|
||||
)
|
||||
|
||||
# r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
|
||||
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
# pass
|
||||
def refresh_dupe_entries(self):
|
||||
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text="Scanning Library for Duplicate Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.set_dupe_count(len(self.lib.dupe_entries)),
|
||||
)
|
||||
)
|
||||
|
||||
# def update_scan_value(self, pb:QProgressDialog, value=int):
|
||||
# # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
|
||||
# pb.setValue(value)
|
||||
def refresh_and_repair_dupe_entries(self, merge_class: MergeDuplicateEntries):
|
||||
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
|
||||
pw = ProgressWidget(
|
||||
window_title="Scanning Library",
|
||||
label_text="Scanning Library for Duplicate Entries...",
|
||||
cancel_button_text=None,
|
||||
minimum=0,
|
||||
maximum=len(self.lib.entries),
|
||||
)
|
||||
pw.show()
|
||||
iterator.value.connect(lambda v: pw.update_progress(v + 1))
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
QThreadPool.globalInstance().start(r)
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(), # type: ignore
|
||||
pw.deleteLater(), # type: ignore
|
||||
self.set_dupe_count(len(self.lib.dupe_entries)),
|
||||
merge_class.merge_entries(),
|
||||
)
|
||||
)
|
||||
|
||||
def set_missing_count(self, count: int):
|
||||
self.count = count
|
||||
if self.count < 0:
|
||||
self.missing_count = count
|
||||
if self.missing_count < 0:
|
||||
self.search_button.setDisabled(True)
|
||||
self.delete_button.setDisabled(True)
|
||||
self.missing_count.setText(f"Unlinked Entries: N/A")
|
||||
elif self.count == 0:
|
||||
self.missing_count_label.setText("Unlinked Entries: N/A")
|
||||
elif self.missing_count == 0:
|
||||
self.search_button.setDisabled(True)
|
||||
self.delete_button.setDisabled(True)
|
||||
self.missing_count.setText(f"Unlinked Entries: {count}")
|
||||
self.missing_count_label.setText(f"Unlinked Entries: {count}")
|
||||
else:
|
||||
self.search_button.setDisabled(False)
|
||||
self.delete_button.setDisabled(False)
|
||||
self.missing_count.setText(f"Unlinked Entries: {count}")
|
||||
self.missing_count_label.setText(f"Unlinked Entries: {count}")
|
||||
|
||||
def set_dupe_count(self, count: int):
|
||||
self.dupe_count = count
|
||||
if self.dupe_count < 0:
|
||||
self.dupe_count_label.setText("Duplicate Entries: N/A")
|
||||
self.merge_dupe_button.setDisabled(True)
|
||||
elif self.dupe_count == 0:
|
||||
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
|
||||
self.merge_dupe_button.setDisabled(True)
|
||||
else:
|
||||
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
|
||||
self.merge_dupe_button.setDisabled(False)
|
||||
|
||||
@@ -36,18 +36,18 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
def folders_to_tags(library: Library):
|
||||
logging.info("Converting folders to Tags")
|
||||
tree = dict(dirs={})
|
||||
tree: dict = dict(dirs={})
|
||||
|
||||
def add_tag_to_tree(list: list[Tag]):
|
||||
def add_tag_to_tree(items: list[Tag]):
|
||||
branch = tree
|
||||
for tag in list:
|
||||
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(list: list[str]) -> Tag:
|
||||
branch = tree
|
||||
for folder in list:
|
||||
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,
|
||||
@@ -67,7 +67,7 @@ def folders_to_tags(library: Library):
|
||||
add_tag_to_tree(reversed_tag)
|
||||
|
||||
for entry in library.entries:
|
||||
folders = entry.path.split("\\")
|
||||
folders = list(entry.path.parts)
|
||||
if len(folders) == 1 and folders[0] == "":
|
||||
continue
|
||||
tag = add_folders_to_tree(folders)
|
||||
@@ -97,18 +97,18 @@ def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]:
|
||||
|
||||
|
||||
def generate_preview_data(library: Library):
|
||||
tree = dict(dirs={}, files=[])
|
||||
tree: dict = dict(dirs={}, files=[])
|
||||
|
||||
def add_tag_to_tree(list: list[Tag]):
|
||||
branch = tree
|
||||
for tag in list:
|
||||
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(list: list[str]) -> Tag:
|
||||
branch = tree
|
||||
for folder in list:
|
||||
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=[])
|
||||
@@ -120,7 +120,7 @@ def generate_preview_data(library: Library):
|
||||
add_tag_to_tree(reversed_tag)
|
||||
|
||||
for entry in library.entries:
|
||||
folders = entry.path.split("\\")
|
||||
folders = list(entry.path.parts)
|
||||
if len(folders) == 1 and folders[0] == "":
|
||||
continue
|
||||
branch = add_folders_to_tree(folders)
|
||||
@@ -220,7 +220,7 @@ class FoldersToTagsModal(QWidget):
|
||||
self.apply_button.setMinimumWidth(100)
|
||||
self.apply_button.clicked.connect(self.on_apply)
|
||||
|
||||
self.showEvent = self.on_open
|
||||
self.showEvent = self.on_open # type: ignore
|
||||
|
||||
self.root_layout.addWidget(self.title_widget)
|
||||
self.root_layout.addWidget(self.desc_widget)
|
||||
|
||||
46
tagstudio/src/qt/modals/merge_dupe_entries.py
Normal 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)
|
||||
@@ -125,7 +125,7 @@ class MirrorEntriesModal(QWidget):
|
||||
)
|
||||
|
||||
def mirror_entries_runnable(self):
|
||||
mirrored = []
|
||||
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')
|
||||
|
||||
@@ -26,20 +26,6 @@ class RelinkUnlinkedEntries(QObject):
|
||||
self.fixed = 0
|
||||
|
||||
def repair_entries(self):
|
||||
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
|
||||
# # pb.setMaximum(len(self.lib.missing_files))
|
||||
# pb.setFixedSize(432, 112)
|
||||
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
# pb.setWindowTitle('Relinking Entries')
|
||||
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
# pb.show()
|
||||
|
||||
# r = CustomRunnable(lambda: self.repair_entries_runnable(pb))
|
||||
# r.done.connect(lambda: self.done.emit())
|
||||
# # r.done.connect(lambda: self.model.clear())
|
||||
# QThreadPool.globalInstance().start(r)
|
||||
# # r.run()
|
||||
|
||||
iterator = FunctionIterator(self.lib.fix_missing_files)
|
||||
|
||||
pw = ProgressWidget(
|
||||
@@ -49,6 +35,7 @@ class RelinkUnlinkedEntries(QObject):
|
||||
minimum=0,
|
||||
maximum=len(self.lib.missing_files),
|
||||
)
|
||||
|
||||
pw.show()
|
||||
|
||||
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
|
||||
@@ -60,7 +47,6 @@ class RelinkUnlinkedEntries(QObject):
|
||||
),
|
||||
)
|
||||
)
|
||||
# iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1]))
|
||||
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(
|
||||
@@ -73,27 +59,3 @@ class RelinkUnlinkedEntries(QObject):
|
||||
|
||||
def reset_fixed(self):
|
||||
self.fixed = 0
|
||||
|
||||
# def repair_entries_runnable(self, pb: QProgressDialog):
|
||||
# fixed = 0
|
||||
# for i in self.lib.fix_missing_files():
|
||||
# if i[1]:
|
||||
# fixed += 1
|
||||
# pb.setValue(i[0])
|
||||
# pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked')
|
||||
|
||||
# for i, missing in enumerate(self.lib.missing_files):
|
||||
# pb.setValue(i)
|
||||
# pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries')
|
||||
# self.lib.fix_missing_files()
|
||||
# try:
|
||||
# id = self.lib.get_entry_id_from_filepath(missing)
|
||||
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
|
||||
# self.lib.remove_entry(id)
|
||||
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
|
||||
# deleted.append(missing)
|
||||
# except KeyError:
|
||||
# logging.info(
|
||||
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
|
||||
# for d in deleted:
|
||||
# self.lib.missing_files.remove(d)
|
||||
|
||||
@@ -38,7 +38,7 @@ class TagSearchPanel(PanelWidget):
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
self.first_tag_id = None
|
||||
self.tag_limit = 30
|
||||
self.tag_limit = 100
|
||||
# self.selected_tag: int = 0
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -107,9 +107,7 @@ class TagSearchPanel(PanelWidget):
|
||||
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
found_tags = self.lib.search_tags(query, include_cluster=True)[
|
||||
: self.tag_limit - 1
|
||||
]
|
||||
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
|
||||
self.first_tag_id = found_tags[0] if found_tags else None
|
||||
|
||||
for tag_id in found_tags:
|
||||
|
||||
@@ -145,7 +145,6 @@ class Pagination(QWidget, QObject):
|
||||
self.end_buffer_layout.itemAt(i).widget().setHidden(True)
|
||||
|
||||
end_page = page_count - 1
|
||||
|
||||
if page_count <= 1:
|
||||
# Hide everything if there are only one or less pages.
|
||||
# [-------------- HIDDEN --------------]
|
||||
@@ -178,6 +177,7 @@ class Pagination(QWidget, QObject):
|
||||
# self.start_buffer_layout.setContentsMargins(3,0,3,0)
|
||||
self._assign_click(self.prev_button, index - 1)
|
||||
self.prev_button.setDisabled(False)
|
||||
|
||||
if index == end_page:
|
||||
self.next_button.setDisabled(True)
|
||||
# self.end_buffer_layout.setContentsMargins(0,0,0,0)
|
||||
@@ -292,9 +292,9 @@ class Pagination(QWidget, QObject):
|
||||
).widget().setHidden(False)
|
||||
self.start_buffer_layout.itemAt(
|
||||
i - start_offset
|
||||
).widget().setText(str(i + 1))
|
||||
).widget().setText(str(i + 1)) # type: ignore
|
||||
self._assign_click(
|
||||
self.start_buffer_layout.itemAt(i - start_offset).widget(),
|
||||
self.start_buffer_layout.itemAt(i - start_offset).widget(), # type: ignore
|
||||
i,
|
||||
)
|
||||
sbc += 1
|
||||
@@ -319,11 +319,12 @@ class Pagination(QWidget, QObject):
|
||||
self.end_buffer_layout.itemAt(
|
||||
i - end_offset
|
||||
).widget().setHidden(False)
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(
|
||||
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(), i
|
||||
self.end_buffer_layout.itemAt(i - end_offset).widget(), # type: ignore
|
||||
i,
|
||||
)
|
||||
else:
|
||||
# if self.start_buffer_layout.itemAt(i-1):
|
||||
|
||||
@@ -13,15 +13,23 @@ import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
import webbrowser
|
||||
from datetime import datetime as dt
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QObject, QThread, Signal, Qt, QThreadPool, QTimer, QSettings
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QThread,
|
||||
Signal,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QTimer,
|
||||
QSettings,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QGuiApplication,
|
||||
QPixmap,
|
||||
@@ -43,29 +51,14 @@ from PySide6.QtWidgets import (
|
||||
QSplashScreen,
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QComboBox,
|
||||
)
|
||||
from humanfriendly import format_timespan
|
||||
|
||||
from src.core.enums import SettingItems
|
||||
from src.core.enums import SettingItems, SearchMode
|
||||
from src.core.library import ItemType
|
||||
from src.core.ts_core import (
|
||||
PLAINTEXT_TYPES,
|
||||
TagStudioCore,
|
||||
TAG_COLORS,
|
||||
DATE_FIELDS,
|
||||
TEXT_FIELDS,
|
||||
BOX_FIELDS,
|
||||
ALL_FILE_TYPES,
|
||||
SHORTCUT_TYPES,
|
||||
PROGRAM_TYPES,
|
||||
ARCHIVE_TYPES,
|
||||
PRESENTATION_TYPES,
|
||||
SPREADSHEET_TYPES,
|
||||
DOC_TYPES,
|
||||
AUDIO_TYPES,
|
||||
VIDEO_TYPES,
|
||||
IMAGE_TYPES,
|
||||
LIBRARY_FILENAME,
|
||||
from src.core.ts_core import TagStudioCore
|
||||
from src.core.constants import (
|
||||
COLLAGE_FOLDER_NAME,
|
||||
BACKUP_FOLDER_NAME,
|
||||
TS_FOLDER_NAME,
|
||||
@@ -117,7 +110,7 @@ class NavigationState:
|
||||
scrollbar_pos: int,
|
||||
page_index: int,
|
||||
page_count: int,
|
||||
search_text: str = None,
|
||||
search_text: str | None = None,
|
||||
thumb_size=None,
|
||||
spacing=None,
|
||||
) -> None:
|
||||
@@ -165,17 +158,24 @@ class QtDriver(QObject):
|
||||
|
||||
SIGTERM = Signal()
|
||||
|
||||
def __init__(self, core, args):
|
||||
preview_panel: PreviewPanel
|
||||
|
||||
def __init__(self, core: TagStudioCore, args):
|
||||
super().__init__()
|
||||
self.core: TagStudioCore = core
|
||||
self.lib = self.core.lib
|
||||
self.args = args
|
||||
self.frame_dict: dict = {}
|
||||
self.nav_frames: list[NavigationState] = []
|
||||
self.cur_frame_idx: int = -1
|
||||
|
||||
self.search_mode = SearchMode.AND
|
||||
|
||||
# self.main_window = None
|
||||
# self.main_window = Ui_MainWindow()
|
||||
|
||||
self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
|
||||
self.base_title: str = f"TagStudio {VERSION}{self.branch}"
|
||||
self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}"
|
||||
# self.title_text: str = self.base_title
|
||||
# self.buffer = {}
|
||||
self.thumb_job_queue: Queue = Queue()
|
||||
@@ -193,10 +193,13 @@ class QtDriver(QObject):
|
||||
f"[QT DRIVER] Config File does not exist creating {str(path)}"
|
||||
)
|
||||
logging.info(f"[QT DRIVER] Using Config File {str(path)}")
|
||||
self.settings = QSettings(str(path), QSettings.IniFormat)
|
||||
self.settings = QSettings(str(path), QSettings.Format.IniFormat)
|
||||
else:
|
||||
self.settings = QSettings(
|
||||
QSettings.IniFormat, QSettings.UserScope, "TagStudio", "TagStudio"
|
||||
QSettings.Format.IniFormat,
|
||||
QSettings.Scope.UserScope,
|
||||
"TagStudio",
|
||||
"TagStudio",
|
||||
)
|
||||
logging.info(
|
||||
f"[QT DRIVER] Config File not specified, defaulting to {self.settings.fileName()}"
|
||||
@@ -219,7 +222,7 @@ class QtDriver(QObject):
|
||||
None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly
|
||||
)
|
||||
if dir not in (None, ""):
|
||||
self.open_library(dir)
|
||||
self.open_library(Path(dir))
|
||||
|
||||
def signal_handler(self, sig, frame):
|
||||
if sig in (SIGINT, SIGTERM, SIGQUIT):
|
||||
@@ -230,7 +233,7 @@ class QtDriver(QObject):
|
||||
signal(SIGTERM, self.signal_handler)
|
||||
signal(SIGQUIT, self.signal_handler)
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
"""Launches the main Qt window."""
|
||||
|
||||
loader = QUiLoader()
|
||||
@@ -244,8 +247,8 @@ class QtDriver(QObject):
|
||||
# pal.setColor(QPalette.ColorGroup.Normal,
|
||||
# QPalette.ColorRole.Window, QColor('#110F1B'))
|
||||
# app.setPalette(pal)
|
||||
home_path = os.path.normpath(f"{Path(__file__).parent}/ui/home.ui")
|
||||
icon_path = os.path.normpath(f"{Path(__file__).parents[2]}/resources/icon.png")
|
||||
home_path = Path(__file__).parent / "ui/home.ui"
|
||||
icon_path = Path(__file__).parents[2] / "resources/icon.png"
|
||||
|
||||
# Handle OS signals
|
||||
self.setup_signals()
|
||||
@@ -255,9 +258,9 @@ class QtDriver(QObject):
|
||||
timer.timeout.connect(lambda: None)
|
||||
|
||||
# self.main_window = loader.load(home_path)
|
||||
self.main_window = Ui_MainWindow()
|
||||
self.main_window = Ui_MainWindow(self)
|
||||
self.main_window.setWindowTitle(self.base_title)
|
||||
self.main_window.mousePressEvent = self.mouse_navigation
|
||||
self.main_window.mousePressEvent = self.mouse_navigation # type: ignore
|
||||
# self.main_window.setStyleSheet(
|
||||
# f'QScrollBar::{{background:red;}}'
|
||||
# )
|
||||
@@ -273,17 +276,17 @@ class QtDriver(QObject):
|
||||
|
||||
splash_pixmap = QPixmap(":/images/splash.png")
|
||||
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
|
||||
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint)
|
||||
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
|
||||
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.splash.show()
|
||||
|
||||
if os.name == "nt":
|
||||
appid = "cyanvoxel.tagstudio.9"
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore
|
||||
|
||||
if sys.platform != "darwin":
|
||||
icon = QIcon()
|
||||
icon.addFile(icon_path)
|
||||
icon.addFile(str(icon_path))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
menu_bar = QMenuBar(self.main_window)
|
||||
@@ -379,7 +382,26 @@ class QtDriver(QObject):
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
manage_file_extensions_action = QAction("Ignored File Extensions", menu_bar)
|
||||
select_all_action = QAction("Select All", menu_bar)
|
||||
select_all_action.triggered.connect(self.select_all_action_callback)
|
||||
select_all_action.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier(QtCore.Qt.KeyboardModifier.ControlModifier),
|
||||
QtCore.Qt.Key.Key_A,
|
||||
)
|
||||
)
|
||||
select_all_action.setToolTip("Ctrl+A")
|
||||
edit_menu.addAction(select_all_action)
|
||||
|
||||
clear_select_action = QAction("Clear Selection", menu_bar)
|
||||
clear_select_action.triggered.connect(self.clear_select_action_callback)
|
||||
clear_select_action.setShortcut(QtCore.Qt.Key.Key_Escape)
|
||||
clear_select_action.setToolTip("Esc")
|
||||
edit_menu.addAction(clear_select_action)
|
||||
|
||||
edit_menu.addSeparator()
|
||||
|
||||
manage_file_extensions_action = QAction("Manage File Extensions", menu_bar)
|
||||
manage_file_extensions_action.triggered.connect(
|
||||
lambda: self.show_file_extension_modal()
|
||||
)
|
||||
@@ -392,7 +414,7 @@ class QtDriver(QObject):
|
||||
check_action = QAction("Open library on start", self)
|
||||
check_action.setCheckable(True)
|
||||
check_action.setChecked(
|
||||
self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool)
|
||||
self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool) # type: ignore
|
||||
)
|
||||
check_action.triggered.connect(
|
||||
lambda checked: self.settings.setValue(
|
||||
@@ -447,15 +469,14 @@ class QtDriver(QObject):
|
||||
self.sort_fields_action.setToolTip("Alt+S")
|
||||
macros_menu.addAction(self.sort_fields_action)
|
||||
|
||||
folders_to_tags_action = QAction("Create Tags From Folders", menu_bar)
|
||||
show_libs_list_action = QAction("Show Recent Libraries", menu_bar)
|
||||
show_libs_list_action.setCheckable(True)
|
||||
show_libs_list_action.setChecked(
|
||||
self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool)
|
||||
self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool) # type: ignore
|
||||
)
|
||||
show_libs_list_action.triggered.connect(
|
||||
lambda checked: (
|
||||
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked),
|
||||
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), # type: ignore
|
||||
self.toggle_libs_list(checked),
|
||||
)
|
||||
)
|
||||
@@ -472,7 +493,6 @@ class QtDriver(QObject):
|
||||
lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio")
|
||||
)
|
||||
help_menu.addAction(self.repo_action)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
|
||||
menu_bar.addMenu(file_menu)
|
||||
@@ -487,9 +507,7 @@ class QtDriver(QObject):
|
||||
l.addWidget(self.preview_panel)
|
||||
|
||||
QFontDatabase.addApplicationFont(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf"
|
||||
)
|
||||
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
|
||||
)
|
||||
|
||||
self.thumb_size = 128
|
||||
@@ -506,13 +524,21 @@ class QtDriver(QObject):
|
||||
elif self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool):
|
||||
lib = self.settings.value(SettingItems.LAST_LIBRARY)
|
||||
|
||||
# TODO: Remove this check if the library is no longer saved with files
|
||||
if lib and not (Path(lib) / TS_FOLDER_NAME).exists():
|
||||
logging.error(
|
||||
f"[QT DRIVER] {TS_FOLDER_NAME} folder in {lib} does not exist."
|
||||
)
|
||||
self.settings.setValue(SettingItems.LAST_LIBRARY, "")
|
||||
lib = None
|
||||
|
||||
if lib:
|
||||
self.splash.showMessage(
|
||||
f'Opening Library "{lib}"...',
|
||||
int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter),
|
||||
QColor("#9782ff"),
|
||||
)
|
||||
self.open_library(lib)
|
||||
self.open_library(Path(lib))
|
||||
|
||||
if self.args.ci:
|
||||
# gracefully terminate the app in CI environment
|
||||
@@ -523,6 +549,7 @@ class QtDriver(QObject):
|
||||
self.shutdown()
|
||||
|
||||
def init_library_window(self):
|
||||
# self._init_landing_page() # Taken care of inside the widget now
|
||||
self._init_thumb_grid()
|
||||
|
||||
# TODO: Put this into its own method that copies the font file(s) into memory
|
||||
@@ -539,12 +566,25 @@ class QtDriver(QObject):
|
||||
search_field.returnPressed.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
search_type_selector: QComboBox = self.main_window.comboBox_2
|
||||
search_type_selector.currentIndexChanged.connect(
|
||||
lambda: self.set_search_type(
|
||||
SearchMode(search_type_selector.currentIndex())
|
||||
)
|
||||
)
|
||||
|
||||
back_button: QPushButton = self.main_window.backButton
|
||||
back_button.clicked.connect(self.nav_back)
|
||||
forward_button: QPushButton = self.main_window.forwardButton
|
||||
forward_button.clicked.connect(self.nav_forward)
|
||||
|
||||
# NOTE: Putting this early will result in a white non-responsive
|
||||
# window until everything is loaded. Consider adding a splash screen
|
||||
# or implementing some clever loading tricks.
|
||||
self.main_window.show()
|
||||
self.main_window.activateWindow()
|
||||
self.main_window.toggle_landing_page(True)
|
||||
|
||||
self.frame_dict = {}
|
||||
self.main_window.pagination.index.connect(
|
||||
lambda i: (
|
||||
@@ -557,20 +597,15 @@ class QtDriver(QObject):
|
||||
)
|
||||
)
|
||||
|
||||
self.nav_frames: list[NavigationState] = []
|
||||
self.cur_frame_idx: int = -1
|
||||
self.cur_query: str = ""
|
||||
self.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
self.cur_query = ""
|
||||
self.filter_items()
|
||||
# self.update_thumbs()
|
||||
|
||||
# self.render_times: list = []
|
||||
# self.main_window.setWindowFlag(Qt.FramelessWindowHint)
|
||||
|
||||
# NOTE: Putting this early will result in a white non-responsive
|
||||
# window until everything is loaded. Consider adding a splash screen
|
||||
# or implementing some clever loading tricks.
|
||||
self.main_window.show()
|
||||
self.main_window.activateWindow()
|
||||
# self.main_window.raise_()
|
||||
self.splash.finish(self.main_window)
|
||||
self.preview_panel.update_widgets()
|
||||
@@ -650,12 +685,13 @@ class QtDriver(QObject):
|
||||
title_text = f"{self.base_title}"
|
||||
self.main_window.setWindowTitle(title_text)
|
||||
|
||||
self.nav_frames: list[NavigationState] = []
|
||||
self.cur_frame_idx: int = -1
|
||||
self.cur_query: str = ""
|
||||
self.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
self.cur_query = ""
|
||||
self.selected.clear()
|
||||
self.preview_panel.update_widgets()
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(True)
|
||||
|
||||
end_time = time.time()
|
||||
self.main_window.statusbar.showMessage(
|
||||
@@ -669,7 +705,7 @@ class QtDriver(QObject):
|
||||
fn = self.lib.save_library_backup_to_disk()
|
||||
end_time = time.time()
|
||||
self.main_window.statusbar.showMessage(
|
||||
f'Library Backup Saved at: "{os.path.normpath(os.path.normpath(f"{self.lib.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}/{fn}"))}" ({format_timespan(end_time - start_time)})'
|
||||
f'Library Backup Saved at: "{ self.lib.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / fn}" ({format_timespan(end_time - start_time)})'
|
||||
)
|
||||
|
||||
def add_tag_action_callback(self):
|
||||
@@ -684,6 +720,23 @@ class QtDriver(QObject):
|
||||
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
|
||||
self.modal.show()
|
||||
|
||||
def select_all_action_callback(self):
|
||||
for item in self.item_thumbs:
|
||||
if item.mode and (item.mode, item.item_id) not in self.selected:
|
||||
self.selected.append((item.mode, item.item_id))
|
||||
item.thumb_button.set_selected(True)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def clear_select_action_callback(self):
|
||||
self.selected.clear()
|
||||
for item in self.item_thumbs:
|
||||
item.thumb_button.set_selected(False)
|
||||
|
||||
self.set_macro_menu_viability()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
def show_tag_database(self):
|
||||
self.modal = PanelModal(
|
||||
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
|
||||
@@ -691,10 +744,12 @@ class QtDriver(QObject):
|
||||
self.modal.show()
|
||||
|
||||
def show_file_extension_modal(self):
|
||||
# self.modal = FileExtensionModal(self.lib)
|
||||
panel = FileExtensionModal(self.lib)
|
||||
self.modal = PanelModal(
|
||||
panel, "Ignored File Extensions", "Ignored File Extensions", has_save=True
|
||||
panel,
|
||||
"File Extensions",
|
||||
"File Extensions",
|
||||
has_save=True,
|
||||
)
|
||||
self.modal.saved.connect(lambda: (panel.save(), self.filter_items("")))
|
||||
self.modal.show()
|
||||
@@ -839,8 +894,8 @@ class QtDriver(QObject):
|
||||
def run_macro(self, name: str, entry_id: int):
|
||||
"""Runs a specific Macro on an Entry given a Macro name."""
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
path = os.path.normpath(f"{self.lib.library_dir}/{entry.path}/{entry.filename}")
|
||||
source = path.split(os.sep)[1].lower()
|
||||
path = self.lib.library_dir / entry.path / entry.filename
|
||||
source = entry.path.parts[0]
|
||||
if name == "sidecar":
|
||||
self.lib.add_generic_data_to_entry(
|
||||
self.core.get_gdl_sidecar(path, source), entry_id
|
||||
@@ -1016,8 +1071,10 @@ class QtDriver(QObject):
|
||||
self.update_thumbs()
|
||||
# logging.info(f'Refresh: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}')
|
||||
|
||||
@typing.no_type_check
|
||||
def purge_item_from_navigation(self, type: ItemType, id: int):
|
||||
# logging.info(self.nav_frames)
|
||||
# TODO - types here are ambiguous
|
||||
for i, frame in enumerate(self.nav_frames, start=0):
|
||||
while (type, id) in frame.contents:
|
||||
logging.info(f"Removing {id} from nav stack frame {i}")
|
||||
@@ -1061,7 +1118,7 @@ class QtDriver(QObject):
|
||||
sa.setWidgetResizable(True)
|
||||
sa.setWidget(self.flow_container)
|
||||
|
||||
def select_item(self, type: int, id: int, append: bool, bridge: bool):
|
||||
def select_item(self, type: ItemType, id: int, append: bool, bridge: bool):
|
||||
"""Selects one or more items in the Thumbnail Grid."""
|
||||
if append:
|
||||
# self.selected.append((thumb_index, page_index))
|
||||
@@ -1164,7 +1221,7 @@ class QtDriver(QObject):
|
||||
self.thumb_job_queue.put(
|
||||
(
|
||||
item_thumb.renderer.render,
|
||||
(sys.float_info.max, "", base_size, ratio, True),
|
||||
(sys.float_info.max, "", base_size, ratio, True, True),
|
||||
)
|
||||
)
|
||||
# # Restore Selected Borders
|
||||
@@ -1190,9 +1247,7 @@ class QtDriver(QObject):
|
||||
entry = self.lib.get_entry(
|
||||
self.nav_frames[self.cur_frame_idx].contents[i][1]
|
||||
)
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
|
||||
item_thumb.set_item_id(entry.id)
|
||||
item_thumb.assign_archived(entry.has_tag(self.lib, 0))
|
||||
@@ -1237,9 +1292,7 @@ class QtDriver(QObject):
|
||||
else collation.e_ids_and_pages[0][0]
|
||||
)
|
||||
cover_e = self.lib.get_entry(cover_id)
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{cover_e.path}/{cover_e.filename}"
|
||||
)
|
||||
filepath = self.lib.library_dir / cover_e.path / cover_e.filename
|
||||
item_thumb.set_count(str(len(collation.e_ids_and_pages)))
|
||||
item_thumb.update_clickable(
|
||||
clickable=(
|
||||
@@ -1262,7 +1315,7 @@ class QtDriver(QObject):
|
||||
self.thumb_job_queue.put(
|
||||
(
|
||||
item_thumb.renderer.render,
|
||||
(time.time(), filepath, base_size, ratio, False),
|
||||
(time.time(), filepath, base_size, ratio, False, True),
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -1284,14 +1337,14 @@ class QtDriver(QObject):
|
||||
self.nav_forward([(ItemType.ENTRY, x[0]) for x in collation_entries])
|
||||
# self.update_thumbs()
|
||||
|
||||
def get_frame_contents(self, index=0, query: str = None):
|
||||
def get_frame_contents(self, index=0, query: str = ""):
|
||||
return (
|
||||
[] if not self.frame_dict[query] else self.frame_dict[query][index],
|
||||
index,
|
||||
len(self.frame_dict[query]),
|
||||
)
|
||||
|
||||
def filter_items(self, query=""):
|
||||
def filter_items(self, query: str = ""):
|
||||
if self.lib:
|
||||
# logging.info('Filtering...')
|
||||
self.main_window.statusbar.showMessage(
|
||||
@@ -1302,8 +1355,8 @@ class QtDriver(QObject):
|
||||
|
||||
# self.filtered_items = self.lib.search_library(query)
|
||||
# 73601 Entries at 500 size should be 246
|
||||
all_items = self.lib.search_library(query)
|
||||
frames = []
|
||||
all_items = self.lib.search_library(query, search_mode=self.search_mode)
|
||||
frames: list[list[tuple[ItemType, int]]] = []
|
||||
frame_count = math.ceil(len(all_items) / self.max_results)
|
||||
for i in range(0, frame_count):
|
||||
frames.append(
|
||||
@@ -1343,13 +1396,18 @@ class QtDriver(QObject):
|
||||
|
||||
# self.update_thumbs()
|
||||
|
||||
def set_search_type(self, mode=SearchMode.AND):
|
||||
self.search_mode = mode
|
||||
self.filter_items(self.main_window.searchField.text())
|
||||
|
||||
def remove_recent_library(self, item_key: str):
|
||||
self.settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
self.settings.remove(item_key)
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
|
||||
def update_libs_list(self, path: str | Path):
|
||||
@typing.no_type_check
|
||||
def update_libs_list(self, path: Path):
|
||||
"""add library to list in SettingItems.LIBS_LIST"""
|
||||
ITEMS_LIMIT = 5
|
||||
path = Path(path)
|
||||
@@ -1375,25 +1433,20 @@ class QtDriver(QObject):
|
||||
self.settings.endGroup()
|
||||
self.settings.sync()
|
||||
|
||||
def open_library(self, path):
|
||||
def open_library(self, path: Path):
|
||||
"""Opens a TagStudio library."""
|
||||
open_message: str = f'Opening Library "{str(path)}"...'
|
||||
self.main_window.landing_widget.set_status_label(open_message)
|
||||
self.main_window.statusbar.showMessage(open_message, 3)
|
||||
self.main_window.repaint()
|
||||
|
||||
if self.lib.library_dir:
|
||||
self.save_library()
|
||||
self.lib.clear_internal_vars()
|
||||
|
||||
self.main_window.statusbar.showMessage(f"Opening Library {path}", 3)
|
||||
return_code = self.lib.open_library(path)
|
||||
if return_code == 1:
|
||||
# if self.args.external_preview:
|
||||
# self.init_external_preview()
|
||||
|
||||
# if len(self.lib.entries) <= 1000:
|
||||
# print(f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...')
|
||||
# self.lib.refresh_missing_files()
|
||||
# title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\''
|
||||
# self.main_window.setWindowTitle(title_text)
|
||||
self.update_libs_list(path)
|
||||
|
||||
pass
|
||||
else:
|
||||
logging.info(
|
||||
f"{ERROR} No existing TagStudio library found at '{path}'. Creating one."
|
||||
@@ -1401,15 +1454,17 @@ class QtDriver(QObject):
|
||||
print(f"Library Creation Return Code: {self.lib.create_library(path)}")
|
||||
self.add_new_files_callback()
|
||||
|
||||
self.update_libs_list(path)
|
||||
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
|
||||
self.main_window.setWindowTitle(title_text)
|
||||
|
||||
self.nav_frames: list[NavigationState] = []
|
||||
self.cur_frame_idx: int = -1
|
||||
self.cur_query: str = ""
|
||||
self.nav_frames = []
|
||||
self.cur_frame_idx = -1
|
||||
self.cur_query = ""
|
||||
self.selected.clear()
|
||||
self.preview_panel.update_widgets()
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(False)
|
||||
|
||||
def create_collage(self) -> None:
|
||||
"""Generates and saves an image collage based on Library Entries."""
|
||||
@@ -1444,7 +1499,7 @@ class QtDriver(QObject):
|
||||
# ('Stretch to Fill','Stretches the media file to fill the entire collage square.'),
|
||||
# ('Keep Aspect Ratio','Keeps the original media file\'s aspect ratio, filling the rest of the square with black bars.')
|
||||
# ], prompt='', required=True)
|
||||
keep_aspect = 0
|
||||
keep_aspect = False
|
||||
|
||||
if mode in [1, 2, 3]:
|
||||
# TODO: Choose data visualization options here.
|
||||
@@ -1536,8 +1591,11 @@ class QtDriver(QObject):
|
||||
self.completed += 1
|
||||
# logging.info(f'threshold:{len(self.lib.entries}, completed:{self.completed}')
|
||||
if self.completed == len(self.lib.entries):
|
||||
filename = os.path.normpath(
|
||||
f'{self.lib.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}/collage_{dt.utcnow().strftime("%F_%T").replace(":", "")}.png'
|
||||
filename = (
|
||||
self.lib.library_dir
|
||||
/ TS_FOLDER_NAME
|
||||
/ COLLAGE_FOLDER_NAME
|
||||
/ f'collage_{dt.utcnow().strftime("%F_%T").replace(":", "")}.png'
|
||||
)
|
||||
self.collage.save(filename)
|
||||
self.collage = None
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
18
tagstudio/src/qt/widgets/clickable_label.py
Normal 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()
|
||||
@@ -24,7 +24,7 @@ from PySide6.QtCore import (
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.ts_core import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
@@ -52,9 +52,7 @@ class CollageIconRenderer(QObject):
|
||||
keep_aspect,
|
||||
):
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
color: str = ""
|
||||
|
||||
@@ -86,21 +84,19 @@ class CollageIconRenderer(QObject):
|
||||
color = "#e22c3c" # Red
|
||||
|
||||
if data_only_mode:
|
||||
pic: Image = Image.new("RGB", size, color)
|
||||
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(file_type)}{entry.path}{os.sep}{entry.filename}\033[0m"
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
|
||||
)
|
||||
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
|
||||
# sys.stdout.flush()
|
||||
if file_type in IMAGE_TYPES:
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
try:
|
||||
with Image.open(
|
||||
os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
str(self.lib.library_dir / entry.path / entry.filename)
|
||||
) as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
@@ -115,8 +111,8 @@ class CollageIconRenderer(QObject):
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif file_type in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
@@ -145,8 +141,9 @@ class CollageIconRenderer(QObject):
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
with Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[2]}/resources/qt/images/thumb_broken_512.png"
|
||||
str(
|
||||
Path(__file__).parents[2]
|
||||
/ "resources/qt/images/thumb_broken_512.png"
|
||||
)
|
||||
) as pic:
|
||||
pic.thumbnail(size)
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType
|
||||
from types import FunctionType, MethodType
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, cast, Callable, Any
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
@@ -18,23 +18,17 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushBu
|
||||
class FieldContainer(QWidget):
|
||||
# TODO: reference a resources folder rather than path.parents[3]?
|
||||
clipboard_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/clipboard_icon_128.png"
|
||||
)
|
||||
str(Path(__file__).parents[3] / "resources/qt/images/clipboard_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
clipboard_icon_128.load()
|
||||
|
||||
edit_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/edit_icon_128.png"
|
||||
)
|
||||
str(Path(__file__).parents[3] / "resources/qt/images/edit_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
edit_icon_128.load()
|
||||
|
||||
trash_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/trash_icon_128.png"
|
||||
)
|
||||
str(Path(__file__).parents[3] / "resources/qt/images/trash_icon_128.png")
|
||||
).resize((math.floor(24 * 1.25), math.floor(24 * 1.25)))
|
||||
trash_icon_128.load()
|
||||
|
||||
@@ -48,7 +42,7 @@ class FieldContainer(QWidget):
|
||||
# self.editable:bool = editable
|
||||
self.copy_callback: FunctionType = None
|
||||
self.edit_callback: FunctionType = None
|
||||
self.remove_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;')
|
||||
|
||||
@@ -129,7 +123,7 @@ class FieldContainer(QWidget):
|
||||
|
||||
# self.set_inner_widget(mode)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[FunctionType]):
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
try:
|
||||
self.copy_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
@@ -138,7 +132,7 @@ class FieldContainer(QWidget):
|
||||
self.copy_callback = callback
|
||||
self.copy_button.clicked.connect(callback)
|
||||
|
||||
def set_edit_callback(self, callback: Optional[FunctionType]):
|
||||
def set_edit_callback(self, callback: Optional[MethodType]):
|
||||
try:
|
||||
self.edit_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
@@ -147,7 +141,7 @@ class FieldContainer(QWidget):
|
||||
self.edit_callback = callback
|
||||
self.edit_button.clicked.connect(callback)
|
||||
|
||||
def set_remove_callback(self, callback: Optional[FunctionType]):
|
||||
def set_remove_callback(self, callback: Optional[Callable]):
|
||||
try:
|
||||
self.remove_button.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
@@ -168,7 +162,7 @@ class FieldContainer(QWidget):
|
||||
|
||||
def get_inner_widget(self) -> Optional["FieldWidget"]:
|
||||
if self.field_layout.itemAt(0):
|
||||
return self.field_layout.itemAt(0).widget()
|
||||
return cast(FieldWidget, self.field_layout.itemAt(0).widget())
|
||||
return None
|
||||
|
||||
def set_title(self, title: str):
|
||||
|
||||
@@ -23,8 +23,9 @@ from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
)
|
||||
|
||||
|
||||
from src.core.library import ItemType, Library, Entry
|
||||
from src.core.ts_core import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
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
|
||||
@@ -52,16 +53,12 @@ class ItemThumb(FlowWidget):
|
||||
update_cutoff: float = time.time()
|
||||
|
||||
collation_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/collation_icon_128.png"
|
||||
)
|
||||
str(Path(__file__).parents[3] / "resources/qt/images/collation_icon_128.png")
|
||||
)
|
||||
collation_icon_128.load()
|
||||
|
||||
tag_group_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/tag_group_icon_128.png"
|
||||
)
|
||||
str(Path(__file__).parents[3] / "resources/qt/images/tag_group_icon_128.png")
|
||||
)
|
||||
tag_group_icon_128.load()
|
||||
|
||||
@@ -181,7 +178,7 @@ class ItemThumb(FlowWidget):
|
||||
lambda ts, i, s, ext: (
|
||||
self.update_thumb(ts, image=i),
|
||||
self.update_size(ts, size=s),
|
||||
self.set_extension(ext),
|
||||
self.set_extension(ext), # type: ignore
|
||||
)
|
||||
)
|
||||
self.thumb_button.setFlat(True)
|
||||
@@ -353,9 +350,11 @@ class ItemThumb(FlowWidget):
|
||||
# pass
|
||||
|
||||
def set_extension(self, ext: str) -> None:
|
||||
if ext and ext not in IMAGE_TYPES or ext in ["gif", "apng"]:
|
||||
if ext and ext.startswith(".") is False:
|
||||
ext = "." + ext
|
||||
if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]:
|
||||
self.ext_badge.setHidden(False)
|
||||
self.ext_badge.setText(ext.upper())
|
||||
self.ext_badge.setText(ext.upper()[1:])
|
||||
if ext in VIDEO_TYPES + AUDIO_TYPES:
|
||||
self.count_badge.setHidden(False)
|
||||
else:
|
||||
@@ -388,7 +387,7 @@ class ItemThumb(FlowWidget):
|
||||
self.thumb_button.setMinimumSize(size)
|
||||
self.thumb_button.setMaximumSize(size)
|
||||
|
||||
def update_clickable(self, clickable: FunctionType = None):
|
||||
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:
|
||||
@@ -413,9 +412,7 @@ class ItemThumb(FlowWidget):
|
||||
if id == -1:
|
||||
return
|
||||
entry = self.lib.get_entry(self.item_id)
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{entry.path}/{entry.filename}"
|
||||
)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
self.opener.set_filepath(filepath)
|
||||
|
||||
def assign_favorite(self, value: bool):
|
||||
|
||||
181
tagstudio/src/qt/widgets/landing.py
Normal 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)
|
||||
@@ -19,9 +19,9 @@ class PanelModal(QWidget):
|
||||
widget: "PanelWidget",
|
||||
title: str,
|
||||
window_title: str,
|
||||
done_callback: FunctionType = None,
|
||||
done_callback: Callable = None,
|
||||
# cancel_callback:FunctionType=None,
|
||||
save_callback: FunctionType = None,
|
||||
save_callback: Callable = None,
|
||||
has_save: bool = False,
|
||||
):
|
||||
# [Done]
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import typing
|
||||
from types import FunctionType
|
||||
from datetime import datetime as dt
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
@@ -30,7 +30,7 @@ from humanfriendly import format_size
|
||||
|
||||
from src.core.enums import SettingItems, Theme
|
||||
from src.core.library import Entry, ItemType, Library
|
||||
from src.core.ts_core import VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME
|
||||
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
|
||||
from src.qt.modals.add_field import AddFieldModal
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -40,16 +40,16 @@ from src.qt.widgets.text import TextWidget
|
||||
from src.qt.widgets.panel import PanelModal
|
||||
from src.qt.widgets.text_box_edit import EditTextBox
|
||||
from src.qt.widgets.text_line_edit import EditTextLine
|
||||
from src.qt.widgets.item_thumb import ItemThumb
|
||||
from src.qt.widgets.video_player import VideoPlayer
|
||||
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
@@ -67,8 +67,8 @@ class PreviewPanel(QWidget):
|
||||
self.isOpen: bool = False
|
||||
# self.filepath = None
|
||||
# self.item = None # DEPRECATED, USE self.selected
|
||||
self.common_fields = []
|
||||
self.mixed_fields = []
|
||||
self.common_fields: list = []
|
||||
self.mixed_fields: list = []
|
||||
self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items
|
||||
self.tag_callback = None
|
||||
self.containers: list[QWidget] = []
|
||||
@@ -90,10 +90,13 @@ class PreviewPanel(QWidget):
|
||||
|
||||
self.preview_img.addAction(self.open_file_action)
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
|
||||
self.tr = ThumbRenderer()
|
||||
self.tr.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
|
||||
self.tr.updated_ratio.connect(
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.hide()
|
||||
self.thumb_renderer = ThumbRenderer()
|
||||
self.thumb_renderer.updated.connect(
|
||||
lambda ts, i, s: (self.preview_img.setIcon(i))
|
||||
)
|
||||
self.thumb_renderer.updated_ratio.connect(
|
||||
lambda ratio: (
|
||||
self.set_image_ratio(ratio),
|
||||
self.update_image_size(
|
||||
@@ -108,7 +111,9 @@ class PreviewPanel(QWidget):
|
||||
|
||||
image_layout.addWidget(self.preview_img)
|
||||
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
image_layout.addWidget(self.preview_vid)
|
||||
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
|
||||
self.image_container.setMinimumSize(*self.img_button_size)
|
||||
self.file_label = FileOpenerLabel("Filename")
|
||||
self.file_label.setWordWrap(True)
|
||||
self.file_label.setTextInteractionFlags(
|
||||
@@ -174,14 +179,17 @@ class PreviewPanel(QWidget):
|
||||
info_layout.addWidget(scroll_area)
|
||||
|
||||
# keep list of rendered libraries to avoid needless re-rendering
|
||||
self.render_libs = set()
|
||||
self.render_libs: set = set()
|
||||
self.libs_layout = QVBoxLayout()
|
||||
self.fill_libs_widget(self.libs_layout)
|
||||
|
||||
self.libs_flow_container: QWidget = QWidget()
|
||||
self.libs_flow_container.setObjectName("librariesList")
|
||||
self.libs_flow_container.setLayout(self.libs_layout)
|
||||
self.libs_flow_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
self.libs_flow_container.setSizePolicy(
|
||||
QSizePolicy.Preferred, # type: ignore
|
||||
QSizePolicy.Maximum, # type: ignore
|
||||
)
|
||||
|
||||
# set initial visibility based on settings
|
||||
if not self.driver.settings.value(
|
||||
@@ -231,7 +239,7 @@ class PreviewPanel(QWidget):
|
||||
settings.beginGroup(SettingItems.LIBS_LIST)
|
||||
lib_items: dict[str, tuple[str, str]] = {}
|
||||
for item_tstamp in settings.allKeys():
|
||||
val = settings.value(item_tstamp)
|
||||
val: str = settings.value(item_tstamp) # type: ignore
|
||||
cut_val = val
|
||||
if len(val) > 45:
|
||||
cut_val = f"{val[0:10]} ... {val[-10:]}"
|
||||
@@ -259,13 +267,13 @@ class PreviewPanel(QWidget):
|
||||
if child.widget() is not None:
|
||||
child.widget().deleteLater()
|
||||
elif child.layout() is not None:
|
||||
clear_layout(child.layout())
|
||||
clear_layout(child.layout()) # type: ignore
|
||||
|
||||
# remove any potential previous items
|
||||
clear_layout(layout)
|
||||
|
||||
label = QLabel("Recent Libraries")
|
||||
label.setAlignment(Qt.AlignCenter)
|
||||
label.setAlignment(Qt.AlignCenter) # type: ignore
|
||||
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.addWidget(label)
|
||||
@@ -290,6 +298,7 @@ class PreviewPanel(QWidget):
|
||||
"}"
|
||||
f"QPushButton::hover{{background-color:{Theme.COLOR_HOVER.value};}}"
|
||||
f"QPushButton::pressed{{background-color:{Theme.COLOR_PRESSED.value};}}"
|
||||
f"QPushButton::disabled{{background-color:{Theme.COLOR_DISABLED_BG.value};}}"
|
||||
)
|
||||
)
|
||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
@@ -298,8 +307,13 @@ class PreviewPanel(QWidget):
|
||||
button = QPushButton(text=cut_val)
|
||||
button.setObjectName(f"path{item_key}")
|
||||
|
||||
lib = Path(full_val)
|
||||
if not lib.exists() or not (lib / TS_FOLDER_NAME).exists():
|
||||
button.setDisabled(True)
|
||||
button.setToolTip("Location is missing")
|
||||
|
||||
def open_library_button_clicked(path):
|
||||
return lambda: self.driver.open_library(path)
|
||||
return lambda: self.driver.open_library(Path(path))
|
||||
|
||||
button.clicked.connect(open_library_button_clicked(full_val))
|
||||
set_button_style(button)
|
||||
@@ -346,8 +360,8 @@ class PreviewPanel(QWidget):
|
||||
# logging.info(f'')
|
||||
# self.preview_img.setMinimumSize(64,64)
|
||||
|
||||
adj_width = size[0]
|
||||
adj_height = size[1]
|
||||
adj_width: float = size[0]
|
||||
adj_height: float = size[1]
|
||||
# Landscape
|
||||
if self.image_ratio > 1:
|
||||
# logging.info('Landscape')
|
||||
@@ -369,16 +383,19 @@ class PreviewPanel(QWidget):
|
||||
|
||||
# self.preview_img.setMinimumSize(s)
|
||||
# self.preview_img.setMaximumSize(s_max)
|
||||
adj_size = QSize(adj_width, adj_height)
|
||||
self.img_button_size = (adj_width, adj_height)
|
||||
adj_size = QSize(int(adj_width), int(adj_height))
|
||||
self.img_button_size = (int(adj_width), int(adj_height))
|
||||
self.preview_img.setMaximumSize(adj_size)
|
||||
self.preview_img.setIconSize(adj_size)
|
||||
self.preview_vid.resizeVideo(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
# self.preview_img.setMinimumSize(adj_size)
|
||||
|
||||
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
|
||||
# if type(self.item) == Entry:
|
||||
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
|
||||
# self.tr.render_big(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio())
|
||||
# self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True)
|
||||
|
||||
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
|
||||
# logging.info(f' Max Button Size: {size}')
|
||||
@@ -430,7 +447,7 @@ class PreviewPanel(QWidget):
|
||||
# 0 Selected Items
|
||||
if not self.driver.selected:
|
||||
if self.selected or not self.initialized:
|
||||
self.file_label.setText(f"No Items Selected")
|
||||
self.file_label.setText("No Items Selected")
|
||||
self.file_label.setFilePath("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
@@ -441,14 +458,23 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
ratio: float = self.devicePixelRatio()
|
||||
self.tr.render_big(time.time(), "", (512, 512), ratio, True)
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
"",
|
||||
(512, 512),
|
||||
ratio,
|
||||
True,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
try:
|
||||
self.preview_img.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
for i, c in enumerate(self.containers):
|
||||
c.setHidden(True)
|
||||
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.selected = list(self.driver.selected)
|
||||
self.add_field_button.setHidden(True)
|
||||
|
||||
@@ -456,17 +482,24 @@ class PreviewPanel(QWidget):
|
||||
elif len(self.driver.selected) == 1:
|
||||
# 1 Selected Entry
|
||||
if self.driver.selected[0][0] == ItemType.ENTRY:
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
|
||||
# If a new selection is made, update the thumbnail and filepath.
|
||||
if not self.selected or self.selected != self.driver.selected:
|
||||
filepath = os.path.normpath(
|
||||
f"{self.lib.library_dir}/{item.path}/{item.filename}"
|
||||
)
|
||||
filepath = self.lib.library_dir / item.path / item.filename
|
||||
self.file_label.setFilePath(filepath)
|
||||
window_title = filepath
|
||||
window_title = str(filepath)
|
||||
ratio: float = self.devicePixelRatio()
|
||||
self.tr.render_big(time.time(), filepath, (512, 512), ratio)
|
||||
self.file_label.setText("\u200b".join(filepath))
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
ratio,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
self.file_label.setText("\u200b".join(str(filepath)))
|
||||
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.preview_img.setContextMenuPolicy(
|
||||
@@ -481,53 +514,74 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
|
||||
# TODO: Do this somewhere else, this is just here temporarily.
|
||||
extension = os.path.splitext(filepath)[1][1:].lower()
|
||||
try:
|
||||
image = None
|
||||
if extension in IMAGE_TYPES:
|
||||
image = Image.open(filepath)
|
||||
if image.mode == "RGBA":
|
||||
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
|
||||
new_bg.paste(image, mask=image.getchannel(3))
|
||||
image = new_bg
|
||||
if image.mode != "RGB":
|
||||
image = image.convert(mode="RGB")
|
||||
elif extension in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
if filepath.suffix.lower() in IMAGE_TYPES:
|
||||
image = Image.open(str(filepath))
|
||||
elif filepath.suffix.lower() in RAW_IMAGE_TYPES:
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
image = Image.new(
|
||||
"L", (rgb.shape[1], rgb.shape[0]), color="black"
|
||||
)
|
||||
except (
|
||||
rawpy._rawpy.LibRawIOError,
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
):
|
||||
pass
|
||||
elif filepath.suffix.lower() in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
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)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
|
||||
# Stats for specific file types are displayed here.
|
||||
if extension in (IMAGE_TYPES + VIDEO_TYPES):
|
||||
if image and filepath.suffix.lower() in (
|
||||
IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES
|
||||
):
|
||||
self.dimensions_label.setText(
|
||||
f"{extension.upper()} • {format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px"
|
||||
f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}\n{image.width} x {image.height} px"
|
||||
)
|
||||
else:
|
||||
self.dimensions_label.setText(f"{extension.upper()}")
|
||||
|
||||
if not image:
|
||||
self.dimensions_label.setText(
|
||||
f"{extension.upper()} • {format_size(os.stat(filepath).st_size)}"
|
||||
f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
raise UnidentifiedImageError
|
||||
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{filepath.suffix.upper()}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
) as e:
|
||||
self.dimensions_label.setText(
|
||||
f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
@@ -562,6 +616,9 @@ class PreviewPanel(QWidget):
|
||||
|
||||
# Multiple Selected Items
|
||||
elif len(self.driver.selected) > 1:
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
if self.selected != self.driver.selected:
|
||||
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
@@ -574,7 +631,14 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
ratio: float = self.devicePixelRatio()
|
||||
self.tr.render_big(time.time(), "", (512, 512), ratio, True)
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
"",
|
||||
(512, 512),
|
||||
ratio,
|
||||
True,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
try:
|
||||
self.preview_img.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
@@ -656,7 +720,7 @@ class PreviewPanel(QWidget):
|
||||
# filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}')
|
||||
# window_title = filepath
|
||||
# ratio: float = self.devicePixelRatio()
|
||||
# self.tr.render_big(time.time(), filepath, (512, 512), ratio)
|
||||
# self.thumb_renderer.render(time.time(), filepath, (512, 512), ratio,update_on_ratio_change=True)
|
||||
# self.file_label.setText("\u200b".join(filepath))
|
||||
|
||||
# # TODO: Deal with this later.
|
||||
@@ -711,7 +775,7 @@ class PreviewPanel(QWidget):
|
||||
self.tags_updated.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
logging.info(f"[UPDATE CONTAINER] Setting tags updated slot")
|
||||
logging.info("[UPDATE CONTAINER] Setting tags updated slot")
|
||||
self.tags_updated.connect(slot)
|
||||
|
||||
# def write_container(self, item:Union[Entry, Collation, Tag], index, field):
|
||||
@@ -794,7 +858,6 @@ class PreviewPanel(QWidget):
|
||||
# container.set_editable(True)
|
||||
container.set_inline(False)
|
||||
# Normalize line endings in any text content.
|
||||
text: str = ""
|
||||
if not mixed:
|
||||
text = self.lib.get_field_attr(field, "content").replace("\r", "\n")
|
||||
else:
|
||||
@@ -834,7 +897,6 @@ class PreviewPanel(QWidget):
|
||||
# container.set_editable(True)
|
||||
container.set_inline(False)
|
||||
# Normalize line endings in any text content.
|
||||
text: str = ""
|
||||
if not mixed:
|
||||
text = self.lib.get_field_attr(field, "content").replace("\r", "\n")
|
||||
else:
|
||||
@@ -875,7 +937,7 @@ class PreviewPanel(QWidget):
|
||||
self.lib.get_field_attr(field, "content")
|
||||
)
|
||||
title = f"{self.lib.get_field_attr(field, 'name')} (Collation)"
|
||||
text: str = f"{collation.title} ({len(collation.e_ids_and_pages)} Items)"
|
||||
text = f"{collation.title} ({len(collation.e_ids_and_pages)} Items)"
|
||||
if len(self.selected) == 1:
|
||||
text += f" - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}"
|
||||
inner_container = TextWidget(title, text)
|
||||
@@ -948,10 +1010,11 @@ class PreviewPanel(QWidget):
|
||||
container.set_remove_callback(
|
||||
lambda: self.remove_message_box(prompt=prompt, callback=callback)
|
||||
)
|
||||
container.edit_button.setHidden(True)
|
||||
container.setHidden(False)
|
||||
self.place_add_field_button()
|
||||
|
||||
def remove_field(self, field: object):
|
||||
def remove_field(self, field: dict):
|
||||
"""Removes a field from all selected Entries, given a field object."""
|
||||
for item_pair in self.selected:
|
||||
if item_pair[0] == ItemType.ENTRY:
|
||||
@@ -973,7 +1036,7 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
pass
|
||||
|
||||
def update_field(self, field: object, content):
|
||||
def update_field(self, field: dict, content):
|
||||
"""Removes a field from all selected Entries, given a field object."""
|
||||
field = dict(field)
|
||||
for item_pair in self.selected:
|
||||
@@ -989,7 +1052,7 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
pass
|
||||
|
||||
def remove_message_box(self, prompt: str, callback: FunctionType) -> int:
|
||||
def remove_message_box(self, prompt: str, callback: typing.Callable) -> None:
|
||||
remove_mb = QMessageBox()
|
||||
remove_mb.setText(prompt)
|
||||
remove_mb.setWindowTitle("Remove Field")
|
||||
|
||||
@@ -24,9 +24,7 @@ INFO = f"[INFO]"
|
||||
|
||||
class TagWidget(QWidget):
|
||||
edit_icon_128: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/edit_icon_128.png"
|
||||
)
|
||||
str(Path(__file__).parents[3] / "resources/qt/images/edit_icon_128.png")
|
||||
).resize((math.floor(14 * 1.25), math.floor(14 * 1.25)))
|
||||
edit_icon_128.load()
|
||||
on_remove = Signal()
|
||||
|
||||
@@ -78,7 +78,7 @@ class TagBoxWidget(FieldWidget):
|
||||
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
|
||||
self.add_modal = PanelModal(tsp, title, "Add Tags")
|
||||
self.add_button.clicked.connect(
|
||||
lambda: (tsp.update_tags(), self.add_modal.show())
|
||||
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore
|
||||
)
|
||||
|
||||
self.set_tags(tags)
|
||||
@@ -137,7 +137,6 @@ class TagBoxWidget(FieldWidget):
|
||||
has_save=True,
|
||||
)
|
||||
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
|
||||
panel: BuildTagPanel = self.edit_modal.widget
|
||||
self.edit_modal.saved.connect(lambda: self.lib.update_tag(btp.build_tag()))
|
||||
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
|
||||
self.edit_modal.show()
|
||||
@@ -149,7 +148,7 @@ class TagBoxWidget(FieldWidget):
|
||||
f"[TAG BOX WIDGET] ADD TAG CALLBACK: T:{tag_id} to E:{self.item.id}"
|
||||
)
|
||||
logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}")
|
||||
id = list(self.field.keys())[0]
|
||||
id: int = list(self.field.keys())[0] # type: ignore
|
||||
for x in self.driver.selected:
|
||||
self.driver.lib.get_entry(x[1]).add_tag(
|
||||
self.driver.lib, tag_id, field_id=id, field_index=-1
|
||||
@@ -170,9 +169,9 @@ class TagBoxWidget(FieldWidget):
|
||||
def edit_tag_callback(self, tag: Tag):
|
||||
self.lib.update_tag(tag)
|
||||
|
||||
def remove_tag(self, tag_id):
|
||||
def remove_tag(self, tag_id: int):
|
||||
logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}")
|
||||
id = list(self.field.keys())[0]
|
||||
id: int = list(self.field.keys())[0] # type: ignore
|
||||
for x in self.driver.selected:
|
||||
index = self.driver.lib.get_field_index_in_entry(
|
||||
self.driver.lib.get_entry(x[1]), id
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath
|
||||
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
|
||||
from PySide6.QtWidgets import QWidget, QPushButton
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class ThumbButton(QPushButton):
|
||||
|
||||
# self.clicked.connect(lambda checked: self.set_selected(True))
|
||||
|
||||
def paintEvent(self, event: QEvent) -> None:
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
if self.hovered or self.selected:
|
||||
painter = QPainter()
|
||||
|
||||
@@ -3,37 +3,43 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
from pillow_heif import register_heif_opener, register_avif_opener
|
||||
from PIL import (
|
||||
Image,
|
||||
ImageChops,
|
||||
UnidentifiedImageError,
|
||||
ImageQt,
|
||||
ImageDraw,
|
||||
ImageFont,
|
||||
ImageEnhance,
|
||||
ImageOps,
|
||||
ImageFile,
|
||||
)
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QObject, Signal, QSize
|
||||
from PySide6.QtGui import QPixmap
|
||||
from src.core.ts_core import PLAINTEXT_TYPES, VIDEO_TYPES, IMAGE_TYPES
|
||||
from src.qt.helpers.gradient import four_corner_gradient_background
|
||||
from src.core.constants import (
|
||||
PLAINTEXT_TYPES,
|
||||
VIDEO_TYPES,
|
||||
IMAGE_TYPES,
|
||||
RAW_IMAGE_TYPES,
|
||||
)
|
||||
from src.core.utils.encoding import detect_char_encoding
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
register_heif_opener()
|
||||
register_avif_opener()
|
||||
|
||||
|
||||
class ThumbRenderer(QObject):
|
||||
@@ -44,121 +50,118 @@ class ThumbRenderer(QObject):
|
||||
# updatedSize = Signal(QSize)
|
||||
|
||||
thumb_mask_512: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_mask_512.png"
|
||||
)
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_mask_512.png"
|
||||
)
|
||||
thumb_mask_512.load()
|
||||
|
||||
thumb_mask_hl_512: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_mask_hl_512.png"
|
||||
)
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_mask_hl_512.png"
|
||||
)
|
||||
thumb_mask_hl_512.load()
|
||||
|
||||
thumb_loading_512: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_loading_512.png"
|
||||
)
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png"
|
||||
)
|
||||
thumb_loading_512.load()
|
||||
|
||||
thumb_broken_512: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_broken_512.png"
|
||||
)
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png"
|
||||
)
|
||||
thumb_broken_512.load()
|
||||
|
||||
thumb_file_default_512: Image.Image = Image.open(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_file_default_512.png"
|
||||
)
|
||||
Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png"
|
||||
)
|
||||
thumb_file_default_512.load()
|
||||
|
||||
# thumb_debug: Image.Image = Image.open(os.path.normpath(
|
||||
# thumb_debug: Image.Image = Image.open(Path(
|
||||
# f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg'))
|
||||
# thumb_debug.load()
|
||||
|
||||
# TODO: Make dynamic font sized given different pixel ratios
|
||||
font_pixel_ratio: float = 1
|
||||
ext_font = ImageFont.truetype(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/fonts/Oxanium-Bold.ttf"
|
||||
),
|
||||
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
|
||||
math.floor(12 * font_pixel_ratio),
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
timestamp: float,
|
||||
filepath,
|
||||
filepath: str | Path,
|
||||
base_size: tuple[int, int],
|
||||
pixelRatio: float,
|
||||
isLoading=False,
|
||||
pixel_ratio: float,
|
||||
is_loading=False,
|
||||
gradient=False,
|
||||
update_on_ratio_change=False,
|
||||
):
|
||||
"""Renders an entry/element thumbnail for the GUI."""
|
||||
adj_size: int = 1
|
||||
image = None
|
||||
pixmap = None
|
||||
final = None
|
||||
extension: str = None
|
||||
broken_thumb = False
|
||||
# adj_font_size = math.floor(12 * pixelRatio)
|
||||
if ThumbRenderer.font_pixel_ratio != pixelRatio:
|
||||
ThumbRenderer.font_pixel_ratio = pixelRatio
|
||||
"""Internal renderer. Renders an entry/element thumbnail for the GUI."""
|
||||
image: Image.Image = None
|
||||
pixmap: QPixmap = None
|
||||
final: Image.Image = None
|
||||
_filepath: Path = Path(filepath)
|
||||
resampling_method = Image.Resampling.BILINEAR
|
||||
if ThumbRenderer.font_pixel_ratio != pixel_ratio:
|
||||
ThumbRenderer.font_pixel_ratio = pixel_ratio
|
||||
ThumbRenderer.ext_font = ImageFont.truetype(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/fonts/Oxanium-Bold.ttf"
|
||||
),
|
||||
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
|
||||
math.floor(12 * ThumbRenderer.font_pixel_ratio),
|
||||
)
|
||||
|
||||
if isLoading or filepath:
|
||||
adj_size = math.ceil(base_size[0] * pixelRatio)
|
||||
|
||||
if isLoading:
|
||||
li: Image.Image = ThumbRenderer.thumb_loading_512.resize(
|
||||
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio)
|
||||
if is_loading:
|
||||
final = ThumbRenderer.thumb_loading_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
qim = ImageQt.ImageQt(li)
|
||||
qim = ImageQt.ImageQt(final)
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
pixmap.setDevicePixelRatio(pixelRatio)
|
||||
elif filepath:
|
||||
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
).getchannel(3)
|
||||
hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
extension = os.path.splitext(filepath)[1][1:].lower()
|
||||
|
||||
pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
if update_on_ratio_change:
|
||||
self.updated_ratio.emit(1)
|
||||
elif _filepath:
|
||||
try:
|
||||
# Images =======================================================
|
||||
if extension in IMAGE_TYPES:
|
||||
if _filepath.suffix.lower() in IMAGE_TYPES:
|
||||
try:
|
||||
image = Image.open(filepath)
|
||||
# image = self.thumb_debug
|
||||
image = Image.open(_filepath)
|
||||
if image.mode != "RGB" and image.mode != "RGBA":
|
||||
image = image.convert(mode="RGBA")
|
||||
if image.mode == "RGBA":
|
||||
# logging.info(image.getchannel(3).tobytes())
|
||||
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
|
||||
new_bg.paste(image, mask=image.getchannel(3))
|
||||
image = new_bg
|
||||
if image.mode != "RGB":
|
||||
image = image.convert(mode="RGB")
|
||||
|
||||
image = ImageOps.exif_transpose(image)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
|
||||
elif _filepath.suffix.lower() in RAW_IMAGE_TYPES:
|
||||
try:
|
||||
with rawpy.imread(str(_filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
image = Image.frombytes(
|
||||
"RGB",
|
||||
(rgb.shape[1], rgb.shape[0]),
|
||||
rgb,
|
||||
decoder_name="raw",
|
||||
)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {_filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
except (
|
||||
rawpy._rawpy.LibRawIOError,
|
||||
rawpy._rawpy.LibRawFileUnsupportedError,
|
||||
) as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {_filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
|
||||
# Videos =======================================================
|
||||
elif extension in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
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),
|
||||
@@ -174,19 +177,33 @@ class ThumbRenderer(QObject):
|
||||
image = Image.fromarray(frame)
|
||||
|
||||
# Plain Text ===================================================
|
||||
elif extension in PLAINTEXT_TYPES:
|
||||
try:
|
||||
text: str = extension
|
||||
with open(filepath, "r", encoding="utf-8") as text_file:
|
||||
text = text_file.read(256)
|
||||
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
|
||||
draw = ImageDraw.Draw(bg)
|
||||
draw.text((16, 16), text, file=(255, 255, 255))
|
||||
image = bg
|
||||
except:
|
||||
logging.info(
|
||||
f"[ThumbRenderer][ERROR]: Coulnd't render thumbnail for {filepath}"
|
||||
)
|
||||
elif _filepath.suffix.lower() in PLAINTEXT_TYPES:
|
||||
encoding = detect_char_encoding(_filepath)
|
||||
with open(_filepath, "r", encoding=encoding) as text_file:
|
||||
text = text_file.read(256)
|
||||
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
|
||||
draw = ImageDraw.Draw(bg)
|
||||
draw.text((16, 16), text, file=(255, 255, 255))
|
||||
image = bg
|
||||
# 3D ===========================================================
|
||||
# elif extension == 'stl':
|
||||
# # Create a new plot
|
||||
# matplotlib.use('agg')
|
||||
# figure = plt.figure()
|
||||
# axes = figure.add_subplot(projection='3d')
|
||||
|
||||
# # Load the STL files and add the vectors to the plot
|
||||
# your_mesh = mesh.Mesh.from_file(_filepath)
|
||||
|
||||
# poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors)
|
||||
# poly_collection.set_color((0,0,1)) # play with color
|
||||
# scale = your_mesh.points.flatten()
|
||||
# axes.auto_scale_xyz(scale, scale, scale)
|
||||
# axes.add_collection3d(poly_collection)
|
||||
# # plt.show()
|
||||
# img_buf = io.BytesIO()
|
||||
# plt.savefig(img_buf, format='png')
|
||||
# image = Image.open(img_buf)
|
||||
# No Rendered Thumbnail ========================================
|
||||
else:
|
||||
image = ThumbRenderer.thumb_file_default_512.resize(
|
||||
@@ -206,310 +223,77 @@ class ThumbRenderer(QObject):
|
||||
new_y = adj_size
|
||||
new_x = math.ceil(adj_size * (orig_x / orig_y))
|
||||
|
||||
# img_ratio = new_x / new_y
|
||||
image = image.resize((new_x, new_y), resample=Image.Resampling.BILINEAR)
|
||||
if update_on_ratio_change:
|
||||
self.updated_ratio.emit(new_x / new_y)
|
||||
|
||||
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)
|
||||
resampling_method = (
|
||||
Image.Resampling.NEAREST
|
||||
if max(image.size[0], image.size[1])
|
||||
< max(base_size[0], base_size[1])
|
||||
else Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
# hl_add = hl.copy()
|
||||
# hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25))
|
||||
# final.paste(hl_add, mask=hl_add.getchannel(3))
|
||||
|
||||
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
|
||||
broken_thumb = True
|
||||
final = ThumbRenderer.thumb_broken_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
qim = ImageQt.ImageQt(final)
|
||||
if image:
|
||||
image.close()
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
pixmap.setDevicePixelRatio(pixelRatio)
|
||||
|
||||
if pixmap:
|
||||
self.updated.emit(timestamp, pixmap, QSize(*base_size), extension)
|
||||
|
||||
else:
|
||||
self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension)
|
||||
|
||||
def render_big(
|
||||
self,
|
||||
timestamp: float,
|
||||
filepath,
|
||||
base_size: tuple[int, int],
|
||||
pixelRatio: float,
|
||||
isLoading=False,
|
||||
):
|
||||
"""Renders a large, non-square entry/element thumbnail for the GUI."""
|
||||
adj_size: int = 1
|
||||
image: Image.Image = None
|
||||
pixmap: QPixmap = None
|
||||
final: Image.Image = None
|
||||
extension: str = None
|
||||
broken_thumb = False
|
||||
img_ratio = 1
|
||||
# adj_font_size = math.floor(12 * pixelRatio)
|
||||
if ThumbRenderer.font_pixel_ratio != pixelRatio:
|
||||
ThumbRenderer.font_pixel_ratio = pixelRatio
|
||||
ThumbRenderer.ext_font = ImageFont.truetype(
|
||||
os.path.normpath(
|
||||
f"{Path(__file__).parents[3]}/resources/qt/fonts/Oxanium-Bold.ttf"
|
||||
),
|
||||
math.floor(12 * ThumbRenderer.font_pixel_ratio),
|
||||
)
|
||||
|
||||
if isLoading or filepath:
|
||||
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixelRatio)
|
||||
|
||||
if isLoading:
|
||||
adj_size = math.ceil((512 * pixelRatio))
|
||||
final: Image.Image = ThumbRenderer.thumb_loading_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
qim = ImageQt.ImageQt(final)
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
pixmap.setDevicePixelRatio(pixelRatio)
|
||||
self.updated_ratio.emit(1)
|
||||
|
||||
elif filepath:
|
||||
# mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
|
||||
# (adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3)
|
||||
# hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
|
||||
# (adj_size, adj_size), resample=Image.Resampling.BILINEAR)
|
||||
|
||||
extension = os.path.splitext(filepath)[1][1:].lower()
|
||||
|
||||
try:
|
||||
# Images =======================================================
|
||||
if extension in IMAGE_TYPES:
|
||||
try:
|
||||
image = Image.open(filepath)
|
||||
# image = self.thumb_debug
|
||||
if image.mode == "RGBA":
|
||||
# logging.info(image.getchannel(3).tobytes())
|
||||
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
|
||||
new_bg.paste(image, mask=image.getchannel(3))
|
||||
image = new_bg
|
||||
if image.mode != "RGB":
|
||||
image = image.convert(mode="RGB")
|
||||
|
||||
image = ImageOps.exif_transpose(image)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
|
||||
# Videos =======================================================
|
||||
elif extension in VIDEO_TYPES:
|
||||
video = cv2.VideoCapture(filepath)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
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)
|
||||
image = Image.fromarray(frame)
|
||||
# Plain Text ===================================================
|
||||
elif extension in PLAINTEXT_TYPES:
|
||||
try:
|
||||
text: str = extension
|
||||
with open(filepath, "r", encoding="utf-8") as text_file:
|
||||
text = text_file.read(256)
|
||||
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
|
||||
draw = ImageDraw.Draw(bg)
|
||||
draw.text((16, 16), text, file=(255, 255, 255))
|
||||
image = bg
|
||||
except:
|
||||
logging.info(
|
||||
f"[ThumbRenderer][ERROR]: Coulnd't render thumbnail for {filepath}"
|
||||
)
|
||||
# No Rendered Thumbnail ========================================
|
||||
else:
|
||||
image = ThumbRenderer.thumb_file_default_512.resize(
|
||||
image = image.resize((new_x, new_y), resample=resampling_method)
|
||||
if gradient:
|
||||
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
).getchannel(3)
|
||||
hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
)
|
||||
|
||||
if not image:
|
||||
raise UnidentifiedImageError
|
||||
|
||||
orig_x, orig_y = image.size
|
||||
if orig_x < adj_size and orig_y < adj_size:
|
||||
new_x, new_y = (adj_size, adj_size)
|
||||
if orig_x > orig_y:
|
||||
new_x = adj_size
|
||||
new_y = math.ceil(adj_size * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = adj_size
|
||||
new_x = math.ceil(adj_size * (orig_x / orig_y))
|
||||
final = four_corner_gradient_background(image, adj_size, mask, hl)
|
||||
else:
|
||||
new_x, new_y = (adj_size, adj_size)
|
||||
if orig_x > orig_y:
|
||||
new_x = adj_size
|
||||
new_y = math.ceil(adj_size * (orig_y / orig_x))
|
||||
elif orig_y > orig_x:
|
||||
new_y = adj_size
|
||||
new_x = math.ceil(adj_size * (orig_x / orig_y))
|
||||
|
||||
self.updated_ratio.emit(new_x / new_y)
|
||||
image = image.resize((new_x, new_y), resample=Image.Resampling.BILINEAR)
|
||||
|
||||
# image = image.resize(
|
||||
# (new_x, new_y), resample=Image.Resampling.BILINEAR)
|
||||
|
||||
# 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(.5))
|
||||
# final.paste(ImageChops.soft_light(final, hl_soft),
|
||||
# mask=hl_soft.getchannel(3))
|
||||
|
||||
# hl_add = hl.copy()
|
||||
# hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25))
|
||||
# final.paste(hl_add, mask=hl_add.getchannel(3))
|
||||
scalar = 4
|
||||
rec: Image.Image = Image.new(
|
||||
"RGB", tuple([d * scalar for d in image.size]), "black"
|
||||
)
|
||||
draw = ImageDraw.Draw(rec)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + rec.size,
|
||||
(base_size[0] // 32) * scalar * pixelRatio,
|
||||
fill="red",
|
||||
)
|
||||
rec = rec.resize(
|
||||
tuple([d // scalar for d in rec.size]),
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
)
|
||||
# final = image
|
||||
final = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
# logging.info(rec.size)
|
||||
# logging.info(image.size)
|
||||
final.paste(image, mask=rec.getchannel(0))
|
||||
|
||||
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
|
||||
broken_thumb = True
|
||||
self.updated_ratio.emit(1)
|
||||
scalar = 4
|
||||
rec: Image.Image = Image.new(
|
||||
"RGB",
|
||||
tuple([d * scalar for d in image.size]), # type: ignore
|
||||
"black",
|
||||
)
|
||||
draw = ImageDraw.Draw(rec)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + rec.size,
|
||||
(base_size[0] // 32) * scalar * pixel_ratio,
|
||||
fill="red",
|
||||
)
|
||||
rec = rec.resize(
|
||||
tuple([d // scalar for d in rec.size]),
|
||||
resample=Image.Resampling.BILINEAR,
|
||||
)
|
||||
final = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
final.paste(image, mask=rec.getchannel(0))
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
cv2.error,
|
||||
DecompressionBombError,
|
||||
UnicodeDecodeError,
|
||||
) as e:
|
||||
if e is not UnicodeDecodeError:
|
||||
logging.info(
|
||||
f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
if update_on_ratio_change:
|
||||
self.updated_ratio.emit(1)
|
||||
final = ThumbRenderer.thumb_broken_512.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
(adj_size, adj_size), resample=resampling_method
|
||||
)
|
||||
|
||||
# if extension in VIDEO_TYPES + ['gif', 'apng'] or broken_thumb:
|
||||
# idk = ImageDraw.Draw(final)
|
||||
# # idk.textlength(file_type)
|
||||
# ext_offset_x = idk.textlength(
|
||||
# text=extension.upper(), font=ThumbRenderer.ext_font) / 2
|
||||
# ext_offset_x = math.floor(ext_offset_x * (1/pixelRatio))
|
||||
# x_margin = math.floor(
|
||||
# (adj_size-((base_size[0]//6)+ext_offset_x) * pixelRatio))
|
||||
# y_margin = math.floor(
|
||||
# (adj_size-((base_size[0]//8)) * pixelRatio))
|
||||
# stroke_width = round(2 * pixelRatio)
|
||||
# fill = 'white' if not broken_thumb else '#E32B41'
|
||||
# idk.text((x_margin, y_margin), extension.upper(
|
||||
# ), fill=fill, font=ThumbRenderer.ext_font, stroke_width=stroke_width, stroke_fill=(0, 0, 0))
|
||||
|
||||
qim = ImageQt.ImageQt(final)
|
||||
if image:
|
||||
image.close()
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
pixmap.setDevicePixelRatio(pixelRatio)
|
||||
pixmap.setDevicePixelRatio(pixel_ratio)
|
||||
|
||||
if pixmap:
|
||||
# logging.info(final.size)
|
||||
# self.updated.emit(pixmap, QSize(*final.size))
|
||||
self.updated.emit(
|
||||
timestamp,
|
||||
pixmap,
|
||||
QSize(
|
||||
math.ceil(adj_size * 1 / pixelRatio),
|
||||
math.ceil(final.size[1] * 1 / pixelRatio),
|
||||
math.ceil(adj_size / pixel_ratio),
|
||||
math.ceil(final.size[1] / pixel_ratio),
|
||||
),
|
||||
extension,
|
||||
_filepath.suffix.lower(),
|
||||
)
|
||||
|
||||
else:
|
||||
self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension)
|
||||
self.updated.emit(
|
||||
timestamp, QPixmap(), QSize(*base_size), _filepath.suffix.lower()
|
||||
)
|
||||
|
||||
373
tagstudio/src/qt/widgets/video_player.py
Normal file
@@ -0,0 +1,373 @@
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
|
||||
# os.environ["QT_MEDIA_BACKEND"] = "ffmpeg"
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt,
|
||||
QSize,
|
||||
QTimer,
|
||||
QVariantAnimation,
|
||||
QUrl,
|
||||
QObject,
|
||||
QEvent,
|
||||
QRectF,
|
||||
)
|
||||
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices
|
||||
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
|
||||
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene
|
||||
from PySide6.QtGui import (
|
||||
QInputMethodEvent,
|
||||
QPen,
|
||||
QColor,
|
||||
QBrush,
|
||||
QResizeEvent,
|
||||
QWheelEvent,
|
||||
QAction,
|
||||
QRegion,
|
||||
QBitmap,
|
||||
)
|
||||
from PySide6.QtSvgWidgets import QSvgWidget
|
||||
from PIL import Image
|
||||
from src.qt.helpers.file_opener import FileOpenerHelper
|
||||
|
||||
from src.core.constants import VIDEO_TYPES, AUDIO_TYPES
|
||||
from PIL import Image, ImageDraw
|
||||
from src.core.enums import SettingItems
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.qt.ts_qt import QtDriver
|
||||
|
||||
|
||||
class VideoPlayer(QGraphicsView):
|
||||
"""A simple video player for the TagStudio application."""
|
||||
|
||||
resolution = QSize(1280, 720)
|
||||
hover_fix_timer = QTimer()
|
||||
video_preview = None
|
||||
play_pause = None
|
||||
mute_button = None
|
||||
content_visible = False
|
||||
filepath = None
|
||||
|
||||
def __init__(self, driver: "QtDriver") -> None:
|
||||
# Set up the base class.
|
||||
super().__init__()
|
||||
self.driver = driver
|
||||
self.animation = QVariantAnimation(self)
|
||||
self.animation.valueChanged.connect(
|
||||
lambda value: self.setTintTransparency(value)
|
||||
)
|
||||
self.hover_fix_timer.timeout.connect(lambda: self.checkIfStillHovered())
|
||||
self.hover_fix_timer.setSingleShot(True)
|
||||
# Set up the video player.
|
||||
self.installEventFilter(self)
|
||||
self.setScene(QGraphicsScene(self))
|
||||
self.player = QMediaPlayer(self)
|
||||
self.player.mediaStatusChanged.connect(
|
||||
lambda: self.checkMediaStatus(self.player.mediaStatus())
|
||||
)
|
||||
self.video_preview = VideoPreview()
|
||||
self.player.setVideoOutput(self.video_preview)
|
||||
self.video_preview.setAcceptHoverEvents(True)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.RightButton)
|
||||
self.video_preview.installEventFilter(self)
|
||||
self.player.setAudioOutput(
|
||||
QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)
|
||||
)
|
||||
self.player.audioOutput().setMuted(True)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scene().addItem(self.video_preview)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
# Set up the video tint.
|
||||
self.video_tint = self.scene().addRect(
|
||||
0,
|
||||
0,
|
||||
self.video_preview.size().width(),
|
||||
self.video_preview.size().height(),
|
||||
QPen(QColor(0, 0, 0, 0)),
|
||||
QBrush(QColor(0, 0, 0, 0)),
|
||||
)
|
||||
# self.video_tint.setParentItem(self.video_preview)
|
||||
# self.album_art = QGraphicsPixmapItem(self.video_preview)
|
||||
# self.scene().addItem(self.album_art)
|
||||
# self.album_art.setPixmap(
|
||||
# QPixmap("./tagstudio/resources/qt/images/thumb_file_default_512.png")
|
||||
# )
|
||||
# self.album_art.setOpacity(0.0)
|
||||
# Set up the buttons.
|
||||
self.play_pause = QSvgWidget("./tagstudio/resources/pause.svg")
|
||||
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.play_pause.setMouseTracking(True)
|
||||
self.play_pause.installEventFilter(self)
|
||||
self.scene().addWidget(self.play_pause)
|
||||
self.play_pause.resize(100, 100)
|
||||
self.play_pause.move(
|
||||
int(self.width() / 2 - self.play_pause.size().width() / 2),
|
||||
int(self.height() / 2 - self.play_pause.size().height() / 2),
|
||||
)
|
||||
self.play_pause.hide()
|
||||
|
||||
self.mute_button = QSvgWidget("./tagstudio/resources/volume_muted.svg")
|
||||
self.mute_button.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.mute_button.setMouseTracking(True)
|
||||
self.mute_button.installEventFilter(self)
|
||||
self.scene().addWidget(self.mute_button)
|
||||
self.mute_button.resize(40, 40)
|
||||
self.mute_button.move(
|
||||
int(self.width() - self.mute_button.size().width() / 2),
|
||||
int(self.height() - self.mute_button.size().height() / 2),
|
||||
)
|
||||
self.mute_button.hide()
|
||||
# self.fullscreen_button = QSvgWidget('./tagstudio/resources/pause.svg', self)
|
||||
# self.fullscreen_button.setMouseTracking(True)
|
||||
# self.fullscreen_button.installEventFilter(self)
|
||||
# self.scene().addWidget(self.fullscreen_button)
|
||||
# self.fullscreen_button.resize(40, 40)
|
||||
# self.fullscreen_button.move(self.fullscreen_button.size().width()/2, self.height() - self.fullscreen_button.size().height()/2)
|
||||
# self.fullscreen_button.hide()
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.opener = FileOpenerHelper(filepath=self.filepath)
|
||||
autoplay_action = QAction("Autoplay", self)
|
||||
autoplay_action.setCheckable(True)
|
||||
self.addAction(autoplay_action)
|
||||
autoplay_action.setChecked(
|
||||
self.driver.settings.value(SettingItems.AUTOPLAY, True, bool) # type: ignore
|
||||
)
|
||||
autoplay_action.triggered.connect(lambda: self.toggleAutoplay())
|
||||
self.autoplay = autoplay_action
|
||||
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
open_explorer_action = QAction("Open file in explorer", self)
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
self.addAction(open_file_action)
|
||||
self.addAction(open_explorer_action)
|
||||
|
||||
def close(self, *args, **kwargs) -> None:
|
||||
self.player.stop()
|
||||
super().close(*args, **kwargs)
|
||||
|
||||
def toggleAutoplay(self) -> None:
|
||||
self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked())
|
||||
self.driver.settings.sync()
|
||||
|
||||
def checkMediaStatus(self, media_status: QMediaPlayer.MediaStatus) -> None:
|
||||
# logging.info(media_status)
|
||||
if media_status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
# Switches current video to with video at filepath. Reason for this is because Pyside6 is dumb and can't handle setting a new source and freezes.
|
||||
# Even if I stop the player before switching, it breaks.
|
||||
# On the plus side, this adds infinite looping for the video preview.
|
||||
self.player.stop()
|
||||
self.player.setSource(QUrl().fromLocalFile(self.filepath))
|
||||
# logging.info(f'Set source to {self.filepath}.')
|
||||
# self.video_preview.setSize(self.resolution)
|
||||
self.player.setPosition(0)
|
||||
# logging.info(f'Set muted to true.')
|
||||
if self.autoplay.isChecked():
|
||||
# logging.info(self.driver.settings.value("autoplay_videos", True, bool))
|
||||
self.player.play()
|
||||
else:
|
||||
# logging.info("Paused")
|
||||
self.player.pause()
|
||||
self.opener.set_filepath(self.filepath)
|
||||
self.keepControlsInPlace()
|
||||
self.updateControls()
|
||||
|
||||
def updateControls(self) -> None:
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
|
||||
else:
|
||||
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
|
||||
|
||||
if self.player.isPlaying():
|
||||
self.play_pause.load("./tagstudio/resources/pause.svg")
|
||||
else:
|
||||
self.play_pause.load("./tagstudio/resources/play.svg")
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
return
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
# This chunk of code is for the video controls.
|
||||
if (
|
||||
obj == self.play_pause
|
||||
and event.type() == QEvent.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton # type: ignore
|
||||
):
|
||||
if self.player.hasVideo():
|
||||
self.pauseToggle()
|
||||
|
||||
if (
|
||||
obj == self.mute_button
|
||||
and event.type() == QEvent.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton # type: ignore
|
||||
):
|
||||
if self.player.hasAudio():
|
||||
self.muteToggle()
|
||||
|
||||
if (
|
||||
obj == self.video_preview
|
||||
and event.type() == QEvent.Type.GraphicsSceneHoverEnter
|
||||
or event.type() == QEvent.Type.HoverEnter
|
||||
):
|
||||
if self.video_preview.isUnderMouse():
|
||||
self.underMouse()
|
||||
self.hover_fix_timer.start(10)
|
||||
elif (
|
||||
obj == self.video_preview
|
||||
and event.type() == QEvent.Type.GraphicsSceneHoverLeave
|
||||
or event.type() == QEvent.Type.HoverLeave
|
||||
):
|
||||
if not self.video_preview.isUnderMouse():
|
||||
self.hover_fix_timer.stop()
|
||||
self.releaseMouse()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def checkIfStillHovered(self) -> None:
|
||||
# Yet again, Pyside6 is dumb. I don't know why, but the HoverLeave event is not triggered sometimes and does not hide the controls.
|
||||
# So, this is a workaround. This is called by a QTimer every 10ms to check if the mouse is still in the video preview.
|
||||
if not self.video_preview.isUnderMouse():
|
||||
self.releaseMouse()
|
||||
else:
|
||||
self.hover_fix_timer.start(10)
|
||||
|
||||
def setTintTransparency(self, value) -> None:
|
||||
self.video_tint.setBrush(QBrush(QColor(0, 0, 0, value)))
|
||||
|
||||
def underMouse(self) -> bool:
|
||||
# logging.info("under mouse")
|
||||
self.animation.setStartValue(self.video_tint.brush().color().alpha())
|
||||
self.animation.setEndValue(100)
|
||||
self.animation.setDuration(500)
|
||||
self.animation.start()
|
||||
self.play_pause.show()
|
||||
self.mute_button.show()
|
||||
# self.fullscreen_button.show()
|
||||
self.keepControlsInPlace()
|
||||
self.updateControls()
|
||||
# rcontent = self.contentsRect()
|
||||
# self.setSceneRect(0, 0, rcontent.width(), rcontent.height())
|
||||
return super().underMouse()
|
||||
|
||||
def releaseMouse(self) -> None:
|
||||
# logging.info("release mouse")
|
||||
self.animation.setStartValue(self.video_tint.brush().color().alpha())
|
||||
self.animation.setEndValue(0)
|
||||
self.animation.setDuration(500)
|
||||
self.animation.start()
|
||||
self.play_pause.hide()
|
||||
self.mute_button.hide()
|
||||
# self.fullscreen_button.hide()
|
||||
return super().releaseMouse()
|
||||
|
||||
def resetControlsToDefault(self) -> None:
|
||||
# Resets the video controls to their default state.
|
||||
self.play_pause.load("./tagstudio/resources/pause.svg")
|
||||
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
|
||||
|
||||
def pauseToggle(self) -> None:
|
||||
if self.player.isPlaying():
|
||||
self.player.pause()
|
||||
self.play_pause.load("./tagstudio/resources/play.svg")
|
||||
else:
|
||||
self.player.play()
|
||||
self.play_pause.load("./tagstudio/resources/pause.svg")
|
||||
|
||||
def muteToggle(self) -> None:
|
||||
if self.player.audioOutput().isMuted():
|
||||
self.player.audioOutput().setMuted(False)
|
||||
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
|
||||
else:
|
||||
self.player.audioOutput().setMuted(True)
|
||||
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
|
||||
|
||||
def play(self, filepath: str, resolution: QSize) -> None:
|
||||
# Sets the filepath and sends the current player position to the very end, so that the new video can be played.
|
||||
# self.player.audioOutput().setMuted(True)
|
||||
logging.info(f"Playing {filepath}")
|
||||
self.resolution = resolution
|
||||
self.filepath = filepath
|
||||
if self.player.isPlaying():
|
||||
self.player.setPosition(self.player.duration())
|
||||
self.player.play()
|
||||
else:
|
||||
self.checkMediaStatus(QMediaPlayer.MediaStatus.EndOfMedia)
|
||||
# logging.info(f"Successfully stopped.")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.filepath = None
|
||||
self.player.stop()
|
||||
|
||||
def resizeVideo(self, new_size: QSize) -> None:
|
||||
# Resizes the video preview to the new size.
|
||||
self.video_preview.setSize(new_size)
|
||||
self.video_tint.setRect(
|
||||
0, 0, self.video_preview.size().width(), self.video_preview.size().height()
|
||||
)
|
||||
|
||||
rcontent = self.contentsRect()
|
||||
self.centerOn(self.video_preview)
|
||||
self.roundCorners()
|
||||
self.setSceneRect(0, 0, rcontent.width(), rcontent.height())
|
||||
self.keepControlsInPlace()
|
||||
|
||||
def roundCorners(self) -> None:
|
||||
width: int = int(max(self.contentsRect().size().width(), 0))
|
||||
height: int = int(max(self.contentsRect().size().height(), 0))
|
||||
mask = Image.new(
|
||||
"RGBA",
|
||||
(
|
||||
width,
|
||||
height,
|
||||
),
|
||||
(0, 0, 0, 255),
|
||||
)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle(
|
||||
(0, 0) + (width, height),
|
||||
radius=12,
|
||||
fill=(0, 0, 0, 0),
|
||||
)
|
||||
final_mask = mask.getchannel("A").toqpixmap()
|
||||
self.setMask(QRegion(QBitmap(final_mask)))
|
||||
|
||||
def keepControlsInPlace(self) -> None:
|
||||
# Keeps the video controls in the places they should be.
|
||||
self.play_pause.move(
|
||||
int(self.width() / 2 - self.play_pause.size().width() / 2),
|
||||
int(self.height() / 2 - self.play_pause.size().height() / 2),
|
||||
)
|
||||
self.mute_button.move(
|
||||
int(self.width() - self.mute_button.size().width() - 10),
|
||||
int(self.height() - self.mute_button.size().height() - 10),
|
||||
)
|
||||
# self.fullscreen_button.move(-self.fullscreen_button.size().width()-10, self.height() - self.fullscreen_button.size().height()-10)
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
# Keeps the video preview in the center of the screen.
|
||||
self.centerOn(self.video_preview)
|
||||
self.resizeVideo(
|
||||
QSize(
|
||||
int(self.video_preview.size().width()),
|
||||
int(self.video_preview.size().height()),
|
||||
)
|
||||
)
|
||||
return
|
||||
# return super().resizeEvent(event)\
|
||||
|
||||
|
||||
class VideoPreview(QGraphicsVideoItem):
|
||||
def boundingRect(self):
|
||||
return QRectF(0, 0, self.size().width(), self.size().height())
|
||||
|
||||
def paint(self, painter, option, widget):
|
||||
# painter.brush().setColor(QColor(0, 0, 0, 255))
|
||||
# You can set any shape you want here. RoundedRect is the standard rectangle with rounded corners
|
||||
# With 2nd and 3rd parameter you can tweak the curve until you get what you expect
|
||||
|
||||
super().paint(painter, option, widget)
|
||||
@@ -5,7 +5,7 @@
|
||||
"""TagStudio launcher."""
|
||||
|
||||
from src.core.ts_core import TagStudioCore
|
||||
from src.cli.ts_cli import CliDriver
|
||||
from src.cli.ts_cli import CliDriver # type: ignore
|
||||
from src.qt.ts_qt import QtDriver
|
||||
import argparse
|
||||
import traceback
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
from src.core.library import Tag
|
||||
|
||||
|
||||
class TestTags:
|
||||
def test_construction(self):
|
||||
tag = Tag(
|
||||
id=1,
|
||||
name="Tag Name",
|
||||
shorthand="TN",
|
||||
aliases=["First A", "Second A"],
|
||||
subtags_ids=[2, 3, 4],
|
||||
color="",
|
||||
)
|
||||
assert tag
|
||||
def test_construction():
|
||||
tag = Tag(
|
||||
id=1,
|
||||
name="Tag Name",
|
||||
shorthand="TN",
|
||||
aliases=["First A", "Second A"],
|
||||
subtags_ids=[2, 3, 4],
|
||||
color="",
|
||||
)
|
||||
assert tag
|
||||
|
||||
def test_empty_construction(self):
|
||||
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
|
||||
assert tag
|
||||
|
||||
def test_empty_construction():
|
||||
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
|
||||
assert tag
|
||||
|
||||