mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 06:10:51 +00:00
Compare commits
65 Commits
Alpha-v9.4
...
tag-ops
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
205a362bda | ||
|
|
02c245504a | ||
|
|
047a2962d4 | ||
|
|
8a16b3adf9 | ||
|
|
91252ce710 | ||
|
|
39fcc65bcb | ||
|
|
732766d57a | ||
|
|
ff5d226480 | ||
|
|
3c842f03ed | ||
|
|
ba60a79b8f | ||
|
|
df7a850c6f | ||
|
|
34565af397 | ||
|
|
d3cd58df47 | ||
|
|
6b15a58e3f | ||
|
|
8d19bef152 | ||
|
|
a074912ac8 | ||
|
|
e0cc0dd5a7 | ||
|
|
a037a3b1e2 | ||
|
|
5c4a3c5856 | ||
|
|
12d69baa98 | ||
|
|
c377b9d875 | ||
|
|
9f688cd387 | ||
|
|
148f792c34 | ||
|
|
ccf3d788d0 | ||
|
|
a658fc4fe4 | ||
|
|
81dfb50b8f | ||
|
|
387baae6d6 | ||
|
|
e4f7055ca7 | ||
|
|
f91861d2fe | ||
|
|
a244098f8e | ||
|
|
c070f84e7f | ||
|
|
447b5e6894 | ||
|
|
6883f9ef6d | ||
|
|
8d2e67ddad | ||
|
|
ad12d64f1e | ||
|
|
c6a5202c91 | ||
|
|
39324142f1 | ||
|
|
196c1ba7f3 | ||
|
|
91ee2428ca | ||
|
|
ad53f10ecc | ||
|
|
ef8cc6cc85 | ||
|
|
086fc1e522 | ||
|
|
3bfeb3c409 | ||
|
|
ffdfd6ccdf | ||
|
|
598aa4f102 | ||
|
|
c0e56dc7c8 | ||
|
|
c582f3d370 | ||
|
|
05a486048c | ||
|
|
d2b5e31792 | ||
|
|
15297140c3 | ||
|
|
3e00a771db | ||
|
|
32257f662f | ||
|
|
127fed7aa9 | ||
|
|
cee42545f7 | ||
|
|
087176edae | ||
|
|
10d81b3fa1 | ||
|
|
dc135f7b0e | ||
|
|
d339f868a9 | ||
|
|
3144440365 | ||
|
|
c1cd96f507 | ||
|
|
ff17b93119 | ||
|
|
6b892ce2bb | ||
|
|
7ce35192b5 | ||
|
|
3c27b37b56 | ||
|
|
34f347bf55 |
@@ -1,16 +0,0 @@
|
||||
# vi: ft=bash
|
||||
#
|
||||
# If you wish to use this file, copy or symlink it to `.envrc` for direnv to read it.
|
||||
# This will use the flake development shell.
|
||||
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w="
|
||||
fi
|
||||
|
||||
devenv_root_file="$(mktemp -t devenv-root-XXXXXXXX)"
|
||||
printf %s "${PWD}" >"${devenv_root_file}"
|
||||
if ! use flake . --override-input devenv-root "file+file://${devenv_root_file}"; then
|
||||
printf '%s\n' "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2
|
||||
fi
|
||||
rm "${devenv_root_file}"
|
||||
unset devenv_root_file
|
||||
3
.github/workflows/apprun.yaml
vendored
3
.github/workflows/apprun.yaml
vendored
@@ -33,8 +33,7 @@ jobs:
|
||||
libxcb-xinerama0 \
|
||||
libopengl0 \
|
||||
libxcb-cursor0 \
|
||||
libpulse0 \
|
||||
ffmpeg
|
||||
libpulse0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -253,7 +253,3 @@ compile_commands.json
|
||||
.TagStudio
|
||||
TagStudio.ini
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,qt
|
||||
|
||||
.envrc
|
||||
.direnv
|
||||
.devenv
|
||||
|
||||
@@ -63,8 +63,6 @@ If you're interested in contributing to TagStudio, please take a look at the [co
|
||||
|
||||
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
|
||||
|
||||
For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
|
||||
|
||||
|
||||
547
flake.lock
generated
547
flake.lock
generated
@@ -1,429 +1,12 @@
|
||||
{
|
||||
"nodes": {
|
||||
"cachix": {
|
||||
"inputs": {
|
||||
"devenv": "devenv_2",
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit-hooks": [
|
||||
"devenv",
|
||||
"pre-commit-hooks"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1712055811,
|
||||
"narHash": "sha256-7FcfMm5A/f02yyzuavJe06zLa9hcMHsagE28ADcmQvk=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "02e38da89851ec7fec3356a5c04bc8349cae0e30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"nix": "nix_2",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724763216,
|
||||
"narHash": "sha256-oW2bwCrJpIzibCNK6zfIDaIQw765yMAuMSG2gyZfGv0=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "1e4ef61205b9aa20fe04bf1c468b6a316281c4f1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devenv-root": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||
"type": "file",
|
||||
"url": "file:///dev/null"
|
||||
},
|
||||
"original": {
|
||||
"type": "file",
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"devenv_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"nix": "nix",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix",
|
||||
"pre-commit-hooks": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"pre-commit-hooks"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1708704632,
|
||||
"narHash": "sha256-w+dOIW60FKMaHI1q5714CSibk99JfYxm0CzTinYWr+Q=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "2ee4450b0f4b95a1b90f2eb5ffea98b90e48c196",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "python-rewrite",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1673956053,
|
||||
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696426674,
|
||||
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1725024810,
|
||||
"narHash": "sha256-ODYRm8zHfLTH3soTFWE452ydPYz2iTvr9T8ftDMUQ3E=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "af510d4a62d071ea13925ce41c95e3dec816c01d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_3": {
|
||||
"inputs": {
|
||||
"systems": "systems_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-regression": "nixpkgs-regression"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1712911606,
|
||||
"narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=",
|
||||
"owner": "domenkozar",
|
||||
"repo": "nix",
|
||||
"rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "domenkozar",
|
||||
"ref": "devenv-2.21",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-github-actions": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"devenv",
|
||||
"poetry2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1688870561,
|
||||
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nix-github-actions",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix2container": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_3",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1724996935,
|
||||
"narHash": "sha256-njRK9vvZ1JJsP8oV2OgkBrpJhgQezI03S7gzskCcHos=",
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"rev": "fa6bb0a1159f55d071ba99331355955ae30b3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nlewo",
|
||||
"repo": "nix2container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-regression": "nixpkgs-regression_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1712911606,
|
||||
"narHash": "sha256-BGvBhepCufsjcUkXnEEXhEVjwdJAwPglCC2+bInc794=",
|
||||
"owner": "domenkozar",
|
||||
"repo": "nix",
|
||||
"rev": "b24a9318ea3f3600c1e24b4a00691ee912d4de12",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "domenkozar",
|
||||
"ref": "devenv-2.21",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1692808169,
|
||||
"narHash": "sha256-x9Opq06rIiwdwGeK2Ykj69dNc2IvUH1fY55Wm7atwrE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9201b5ff357e781bf014d0330d18555695df7ba8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-qt6": {
|
||||
"locked": {
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-regression": {
|
||||
"locked": {
|
||||
"lastModified": 1643052045,
|
||||
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-regression_2": {
|
||||
"locked": {
|
||||
"lastModified": 1643052045,
|
||||
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1710695816,
|
||||
"narHash": "sha256-3Eh7fhEID17pv9ZxrPwCLfqXnYP006RKzSs0JptsN84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "614b4613980a522ba49f0d194531beddbb7220d3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-23.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1713361204,
|
||||
"narHash": "sha256-TA6EDunWTkc5FvDCqU3W2T3SFn0gRZqh6D/hJnM02MM=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "285676e87ad9f0ca23d8714a6ab61e7e027020c6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1724819573,
|
||||
"narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=",
|
||||
"lastModified": 1718318537,
|
||||
"narHash": "sha256-4Zu0RYRcAY/VWuu6awwq4opuiD//ahpc2aFHg2CWqFY=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "71e91c409d1e654808b2621f28a327acfdad8dc2",
|
||||
"rev": "e9ee548d90ff586a6471b4ae80ae9cfcbceb3420",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -433,128 +16,26 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nix-github-actions": "nix-github-actions",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"cachix",
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"qt6Nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1692876271,
|
||||
"narHash": "sha256-IXfZEkI0Mal5y1jr6IRWMqK8GW2/f28xJenZIPQqkY0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "d5006be9c2c2417dafb2e2e5034d83fabd207ee3",
|
||||
"lastModified": 1716287118,
|
||||
"narHash": "sha256-iUTrXABmJAkPRhwPB8GEP7k52OWHVSRtMzlKQ2kIrz4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "47da0aee5616a063015f10ea593688646f2377e4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"flake-utils": "flake-utils_2",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"nixpkgs-stable": "nixpkgs-stable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1713775815,
|
||||
"narHash": "sha256-Wu9cdYTnGQQwtT20QQMg7jzkANKQjwBD9iccfGKkfls=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "2ac4dcbf55ed43f3be0bae15e181f08a57af24a4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "47da0aee5616a063015f10ea593688646f2377e4",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix2container": "nix2container",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-qt6": "nixpkgs-qt6",
|
||||
"systems": "systems_4"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_3": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_4": {
|
||||
"locked": {
|
||||
"lastModified": 1689347949,
|
||||
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default-linux",
|
||||
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default-linux",
|
||||
"type": "github"
|
||||
"nixpkgs": "nixpkgs",
|
||||
"qt6Nixpkgs": "qt6Nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
275
flake.nix
275
flake.nix
@@ -1,208 +1,111 @@
|
||||
{
|
||||
description = "TagStudio";
|
||||
description = "Tag Studio Development Environment";
|
||||
|
||||
inputs = {
|
||||
devenv.url = "github:cachix/devenv";
|
||||
|
||||
devenv-root = {
|
||||
url = "file+file:///dev/null";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
flake-parts = {
|
||||
url = "github:hercules-ci/flake-parts";
|
||||
inputs.nixpkgs-lib.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
nix2container = {
|
||||
url = "github:nlewo/nix2container";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
|
||||
# Pinned to Qt version 6.7.1
|
||||
nixpkgs-qt6.url = "github:NixOS/nixpkgs/e6cea36f83499eb4e9cd184c8a8e823296b50ad5";
|
||||
|
||||
systems.url = "github:nix-systems/default-linux";
|
||||
qt6Nixpkgs = {
|
||||
# Commit bumping to qt6.7.1
|
||||
url = "github:NixOS/nixpkgs/47da0aee5616a063015f10ea593688646f2377e4";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
flake-parts,
|
||||
nixpkgs,
|
||||
nixpkgs-qt6,
|
||||
self,
|
||||
systems,
|
||||
...
|
||||
}@inputs:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [ inputs.devenv.flakeModule ];
|
||||
outputs = { self, nixpkgs, qt6Nixpkgs }:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
|
||||
systems = import systems;
|
||||
qt6Pkgs = qt6Nixpkgs.legacyPackages.x86_64-linux;
|
||||
in {
|
||||
devShells.x86_64-linux.default = pkgs.mkShell {
|
||||
name = "Tag Studio Virtual Environment";
|
||||
venvDir = "./.venv";
|
||||
|
||||
perSystem =
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||
pkgs.gcc-unwrapped
|
||||
pkgs.zlib
|
||||
pkgs.libglvnd
|
||||
pkgs.glib
|
||||
pkgs.stdenv.cc.cc
|
||||
pkgs.fontconfig
|
||||
pkgs.libxkbcommon
|
||||
pkgs.xorg.libxcb
|
||||
pkgs.freetype
|
||||
pkgs.dbus
|
||||
pkgs.zstd
|
||||
# For PySide6 Multimedia
|
||||
pkgs.libpulseaudio
|
||||
pkgs.libkrb5
|
||||
|
||||
qt6Pkgs = import nixpkgs-qt6 { inherit system; };
|
||||
in
|
||||
{
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
qt6Pkgs.qt6.qtwayland
|
||||
qt6Pkgs.qt6.full
|
||||
qt6Pkgs.qt6.qtbase
|
||||
];
|
||||
|
||||
devenv.shells = rec {
|
||||
default = tagstudio;
|
||||
buildInputs = with pkgs; [
|
||||
cmake
|
||||
gdb
|
||||
zstd
|
||||
python312Full
|
||||
python312Packages.pip
|
||||
python312Packages.pyusb # fixes the pyusb 'No backend available' when installed directly via pip
|
||||
python312Packages.venvShellHook # Initializes a venv in $venvDir
|
||||
ruff # Ruff cannot be installed via pip
|
||||
mypy # MyPy cannot be installed via pip
|
||||
|
||||
tagstudio =
|
||||
let
|
||||
cfg = config.devenv.shells.tagstudio;
|
||||
in
|
||||
{
|
||||
# NOTE: many things were simply transferred over from previous,
|
||||
# there must be additional work in ensuring all relevant dependencies
|
||||
# are in place (and no extraneous). I have already spent much
|
||||
# work making this in the first place and just need to get it out
|
||||
# there, especially after my promises. Would appreciate any help
|
||||
# (possibly PRs!) on taking care of this. Otherwise, just expect
|
||||
# this to get ironed out over time.
|
||||
#
|
||||
# Thank you! -Xarvex
|
||||
libgcc
|
||||
glib
|
||||
libxkbcommon
|
||||
freetype
|
||||
binutils
|
||||
dbus
|
||||
coreutils
|
||||
libGL
|
||||
libGLU
|
||||
fontconfig
|
||||
xorg.libxcb
|
||||
|
||||
devenv.root =
|
||||
let
|
||||
devenvRoot = builtins.readFile inputs.devenv-root.outPath;
|
||||
in
|
||||
# If not overriden (/dev/null), --impure is necessary.
|
||||
pkgs.lib.mkIf (devenvRoot != "") devenvRoot;
|
||||
# this is for the shellhook portion
|
||||
makeWrapper
|
||||
bashInteractive
|
||||
] ++ [
|
||||
qt6Pkgs.qt6.qtbase
|
||||
qt6Pkgs.qt6.full
|
||||
qt6Pkgs.qt6.qtwayland
|
||||
qt6Pkgs.qtcreator
|
||||
|
||||
name = "TagStudio";
|
||||
# this is for the shellhook portion
|
||||
qt6Pkgs.qt6.wrapQtAppsHook
|
||||
];
|
||||
|
||||
# Derived from previous flake iteration.
|
||||
packages =
|
||||
(with pkgs; [
|
||||
cmake
|
||||
binutils
|
||||
coreutils
|
||||
dbus
|
||||
fontconfig
|
||||
freetype
|
||||
gdb
|
||||
glib
|
||||
libGL
|
||||
libGLU
|
||||
libgcc
|
||||
libxkbcommon
|
||||
mypy
|
||||
ruff
|
||||
xorg.libxcb
|
||||
zstd
|
||||
])
|
||||
++ (with qt6Pkgs; [
|
||||
qt6.full
|
||||
qt6.qtbase
|
||||
qt6.qtwayland
|
||||
qtcreator
|
||||
]);
|
||||
# Run after the virtual environment is created
|
||||
postVenvCreation = ''
|
||||
unset SOURCE_DATE_EPOCH
|
||||
|
||||
enterShell =
|
||||
let
|
||||
setQtEnv =
|
||||
pkgs.runCommand "set-qt-env"
|
||||
{
|
||||
buildInputs = with qt6Pkgs.qt6; [
|
||||
qtbase
|
||||
];
|
||||
echo Installing dependencies into virtual environment
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
# Hacky solution to not fight with other dev deps
|
||||
# May show failure if skipped due to same version with nixpkgs
|
||||
pip uninstall -y mypy ruff
|
||||
'';
|
||||
|
||||
nativeBuildInputs =
|
||||
(with pkgs; [
|
||||
makeShellWrapper
|
||||
])
|
||||
++ (with qt6Pkgs.qt6; [
|
||||
wrapQtAppsHook
|
||||
]);
|
||||
}
|
||||
''
|
||||
makeShellWrapper "$(type -p sh)" "$out" "''${qtWrapperArgs[@]}"
|
||||
sed "/^exec/d" -i "$out"
|
||||
'';
|
||||
in
|
||||
''
|
||||
source ${setQtEnv}
|
||||
'';
|
||||
# set the environment variables that Qt apps expect
|
||||
postShellHook = ''
|
||||
unset SOURCE_DATE_EPOCH
|
||||
|
||||
scripts.tagstudio.exec = ''
|
||||
python ${cfg.devenv.root}/tagstudio/tag_studio.py
|
||||
'';
|
||||
export QT_QPA_PLATFORM="wayland;xcb"
|
||||
export LIBRARY_PATH=/usr/lib:/usr/lib64:$LIBRARY_PATH
|
||||
# export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/
|
||||
export QT_PLUGIN_PATH=${pkgs.qt6.qtbase}/${pkgs.qt6.qtbase.qtPluginPrefix}
|
||||
bashdir=$(mktemp -d)
|
||||
makeWrapper "$(type -p bash)" "$bashdir/bash" "''${qtWrapperArgs[@]}"
|
||||
|
||||
env = {
|
||||
QT_QPA_PLATFORM = "wayland;xcb";
|
||||
echo Activating Virtual Environment
|
||||
source $venvDir/bin/activate
|
||||
export PYTHONPATH=$PWD/$venvDir/${pkgs.python312Full.sitePackages}:$PYTHONPATH
|
||||
|
||||
# Derived from previous flake iteration.
|
||||
# Not desired given LD_LIBRARY_PATH pollution.
|
||||
# See supposed alternative below, further research required.
|
||||
LD_LIBRARY_PATH = lib.makeLibraryPath (
|
||||
(with pkgs; [
|
||||
dbus
|
||||
fontconfig
|
||||
freetype
|
||||
gcc-unwrapped
|
||||
glib
|
||||
libglvnd
|
||||
libkrb5
|
||||
libpulseaudio
|
||||
libva
|
||||
libxkbcommon
|
||||
openssl
|
||||
stdenv.cc.cc.lib
|
||||
wayland
|
||||
xorg.libxcb
|
||||
xorg.libXrandr
|
||||
zlib
|
||||
zstd
|
||||
])
|
||||
++ (with qt6Pkgs.qt6; [
|
||||
qtbase
|
||||
qtwayland
|
||||
full
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
languages.python = {
|
||||
enable = true;
|
||||
venv = {
|
||||
enable = true;
|
||||
quiet = true;
|
||||
requirements =
|
||||
let
|
||||
excludeDeps =
|
||||
req: deps:
|
||||
builtins.concatStringsSep "\n" (
|
||||
builtins.filter (line: !(lib.any (elem: lib.hasPrefix elem line) deps)) (lib.splitString "\n" req)
|
||||
);
|
||||
in
|
||||
''
|
||||
${builtins.readFile ./requirements.txt}
|
||||
${excludeDeps (builtins.readFile ./requirements-dev.txt) [
|
||||
"mypy"
|
||||
"ruff"
|
||||
]}
|
||||
'';
|
||||
};
|
||||
|
||||
# Should be able to replace LD_LIBRARY_PATH?
|
||||
# Was not quite able to get working,
|
||||
# will be consulting cachix community. -Xarvex
|
||||
# libraries = with pkgs; [ ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
exec "$bashdir/bash"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[tool.ruff]
|
||||
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py", "**/vendored/"]
|
||||
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
|
||||
exclude = ['tests', 'src/qt/helpers/vendored']
|
||||
exclude = ['tests']
|
||||
|
||||
@@ -14,5 +14,4 @@ pydub==0.25.1
|
||||
mutagen==1.47.0
|
||||
numpy==1.26.4
|
||||
ffmpeg-python==0.2.0
|
||||
Send2Trash==1.8.3
|
||||
vtf2img==0.1.0
|
||||
Send2Trash==1.8.3
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 229 KiB |
@@ -1,4 +1,4 @@
|
||||
VERSION: str = "9.4.2" # Major.Minor.Patch
|
||||
VERSION: str = "9.4.0" # Major.Minor.Patch
|
||||
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
|
||||
|
||||
# The folder & file names where TagStudio keeps its data relative to a library.
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import traceback
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -480,7 +479,7 @@ class Library:
|
||||
|
||||
return tag_list
|
||||
|
||||
def open_library(self, path: str | Path, is_path_file: bool = False) -> int:
|
||||
def open_library(self, path: str | Path) -> int:
|
||||
"""
|
||||
Opens a TagStudio v9+ Library.
|
||||
Returns 0 if library does not exist, 1 if successfully opened, 2 if corrupted.
|
||||
@@ -488,264 +487,242 @@ class Library:
|
||||
|
||||
return_code: int = 2
|
||||
|
||||
_path: Path = self._fix_lib_path(path) if not is_path_file else Path(path)
|
||||
lib_path: Path = (
|
||||
_path / TS_FOLDER_NAME / "ts_library.json" if not is_path_file else _path
|
||||
)
|
||||
logging.info(f"[LIBRARY] Library Save File Loaded From: {lib_path}")
|
||||
_path: Path = self._fix_lib_path(path)
|
||||
|
||||
# if (lib_path).exists():
|
||||
# json_dump: JsonLibary = None
|
||||
if (_path / TS_FOLDER_NAME / "ts_library.json").exists():
|
||||
try:
|
||||
with open(
|
||||
_path / TS_FOLDER_NAME / "ts_library.json",
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as file:
|
||||
json_dump: JsonLibary = ujson.load(file)
|
||||
self.library_dir = Path(_path)
|
||||
self.verify_ts_folders()
|
||||
major, minor, patch = json_dump["ts-version"].split(".")
|
||||
|
||||
try:
|
||||
with open(
|
||||
lib_path,
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as file:
|
||||
json_dump = ujson.load(file)
|
||||
|
||||
except (ujson.JSONDecodeError, FileNotFoundError):
|
||||
logging.info(
|
||||
"[LIBRARY][ERROR] Blank/Corrupted Library file found. Searching for Auto Backup..."
|
||||
)
|
||||
backup_folder: Path = (
|
||||
self._fix_lib_path(path) / TS_FOLDER_NAME / BACKUP_FOLDER_NAME
|
||||
)
|
||||
if backup_folder.exists():
|
||||
auto_backup: Path = None
|
||||
dir_obj = os.scandir(backup_folder)
|
||||
|
||||
for backup_file in dir_obj:
|
||||
if backup_file.is_file() and "ts_library_backup_auto" in str(
|
||||
backup_file
|
||||
):
|
||||
auto_backup = Path(backup_file)
|
||||
break
|
||||
|
||||
if auto_backup and "ts_library_backup_auto" not in str(path):
|
||||
logging.info(f"[LIBRARY] Loading Auto Backup: {auto_backup}")
|
||||
return self.open_library(auto_backup, is_path_file=True)
|
||||
|
||||
else:
|
||||
self.library_dir = self._fix_lib_path(path)
|
||||
logging.info(f"[LIBRARY] Library Save Target Directory: {self.library_dir}")
|
||||
self.verify_ts_folders()
|
||||
major, minor, patch = json_dump["ts-version"].split(".")
|
||||
|
||||
# Load Extension List --------------------------------------
|
||||
start_time = time.time()
|
||||
if "ignored_extensions" in json_dump:
|
||||
self.ext_list = json_dump.get(
|
||||
"ignored_extensions", self.default_ext_exclude_list
|
||||
)
|
||||
else:
|
||||
self.ext_list = json_dump.get("ext_list", self.default_ext_exclude_list)
|
||||
|
||||
# Sanitizes older lists (v9.2.1) that don't use leading periods.
|
||||
# Without this, existing lists (including default lists)
|
||||
# have to otherwise be updated by hand in order to restore
|
||||
# previous functionality.
|
||||
sanitized_list: list[str] = []
|
||||
for ext in self.ext_list:
|
||||
if not ext.startswith("."):
|
||||
ext = "." + ext
|
||||
sanitized_list.append(ext)
|
||||
self.ext_list = sanitized_list
|
||||
|
||||
self.is_exclude_list = json_dump.get("is_exclude_list", True)
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Extension list loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
|
||||
# Parse Tags -----------------------------------------------
|
||||
if "tags" in json_dump.keys():
|
||||
start_time = time.time()
|
||||
|
||||
# Step 1: Verify default built-in tags are present.
|
||||
json_dump["tags"] = self.verify_default_tags(json_dump["tags"])
|
||||
|
||||
for tag in json_dump["tags"]:
|
||||
# Step 2: Create a Tag object and append it to the internal Tags list,
|
||||
# then map that Tag's ID to its index in the Tags list.
|
||||
|
||||
id = int(tag.get("id", 0))
|
||||
|
||||
# Don't load tags with duplicate IDs
|
||||
if id not in {t.id for t in self.tags}:
|
||||
if id >= self._next_tag_id:
|
||||
self._next_tag_id = id + 1
|
||||
|
||||
name = tag.get("name", "")
|
||||
shorthand = tag.get("shorthand", "")
|
||||
aliases = tag.get("aliases", [])
|
||||
subtag_ids = tag.get("subtag_ids", [])
|
||||
color = tag.get("color", "")
|
||||
|
||||
t = Tag(
|
||||
id=id,
|
||||
name=name,
|
||||
shorthand=shorthand,
|
||||
aliases=aliases,
|
||||
subtags_ids=subtag_ids,
|
||||
color=color,
|
||||
# Load Extension List --------------------------------------
|
||||
start_time = time.time()
|
||||
if "ignored_extensions" in json_dump:
|
||||
self.ext_list = json_dump.get(
|
||||
"ignored_extensions", self.default_ext_exclude_list
|
||||
)
|
||||
else:
|
||||
self.ext_list = json_dump.get(
|
||||
"ext_list", self.default_ext_exclude_list
|
||||
)
|
||||
|
||||
# NOTE: This does NOT use the add_tag_to_library() method!
|
||||
# That method is only used for Tags added at runtime.
|
||||
# This process uses the same inner methods, but waits until all of the
|
||||
# Tags are registered in the Tags list before creating the Tag clusters.
|
||||
self.tags.append(t)
|
||||
self._map_tag_id_to_index(t, -1)
|
||||
self._map_tag_strings_to_tag_id(t)
|
||||
else:
|
||||
logging.info(f"[LIBRARY]Skipping Tag with duplicate ID: {tag}")
|
||||
# Sanitizes older lists (v9.2.1) that don't use leading periods.
|
||||
# Without this, existing lists (including default lists)
|
||||
# have to otherwise be updated by hand in order to restore
|
||||
# previous functionality.
|
||||
sanitized_list: list[str] = []
|
||||
for ext in self.ext_list:
|
||||
if not ext.startswith("."):
|
||||
ext = "." + ext
|
||||
sanitized_list.append(ext)
|
||||
self.ext_list = sanitized_list
|
||||
|
||||
# Step 3: Map each Tag's subtags together now that all Tag objects in it.
|
||||
for t in self.tags:
|
||||
self._map_tag_id_to_cluster(t)
|
||||
self.is_exclude_list = json_dump.get("is_exclude_list", True)
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Extension list loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
# Parse Tags -----------------------------------------------
|
||||
if "tags" in json_dump.keys():
|
||||
start_time = time.time()
|
||||
|
||||
# Parse Entries --------------------------------------------
|
||||
if entries := json_dump.get("entries"):
|
||||
start_time = time.time()
|
||||
for entry in entries:
|
||||
if "id" in entry:
|
||||
id = int(entry["id"])
|
||||
if id >= self._next_entry_id:
|
||||
self._next_entry_id = id + 1
|
||||
else:
|
||||
# Version 9.1.x+ Compatibility
|
||||
id = self._next_entry_id
|
||||
self._next_entry_id += 1
|
||||
# Step 1: Verify default built-in tags are present.
|
||||
json_dump["tags"] = self.verify_default_tags(json_dump["tags"])
|
||||
|
||||
filename = entry.get("filename", "")
|
||||
e_path = entry.get("path", "")
|
||||
fields: list = []
|
||||
if "fields" in entry:
|
||||
# Cast JSON str keys to ints
|
||||
for tag in json_dump["tags"]:
|
||||
# Step 2: Create a Tag object and append it to the internal Tags list,
|
||||
# then map that Tag's ID to its index in the Tags list.
|
||||
|
||||
for f in entry["fields"]:
|
||||
f[int(list(f.keys())[0])] = f[list(f.keys())[0]]
|
||||
del f[list(f.keys())[0]]
|
||||
fields = entry["fields"]
|
||||
id = int(tag.get("id", 0))
|
||||
|
||||
# Look through fields for legacy Collation data ----
|
||||
if int(major) >= 9 and int(minor) < 1:
|
||||
for f in fields:
|
||||
if self.get_field_attr(f, "type") == "collation":
|
||||
# NOTE: This legacy support will be removed in
|
||||
# a later version, probably 9.2.
|
||||
# Legacy Collation data present in v9.0.x
|
||||
# DATA SHAPE: {name: str, page: int}
|
||||
# Don't load tags with duplicate IDs
|
||||
if id not in {t.id for t in self.tags}:
|
||||
if id >= self._next_tag_id:
|
||||
self._next_tag_id = id + 1
|
||||
|
||||
# We'll do an inefficient linear search each
|
||||
# time to convert the legacy data.
|
||||
matched = False
|
||||
collation_id = -1
|
||||
for c in self.collations:
|
||||
if (
|
||||
c.title
|
||||
== self.get_field_attr(f, "content")["name"]
|
||||
):
|
||||
c.e_ids_and_pages.append(
|
||||
(
|
||||
id,
|
||||
int(
|
||||
self.get_field_attr(f, "content")[
|
||||
"page"
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
matched = True
|
||||
collation_id = c.id
|
||||
if not matched:
|
||||
c = Collation(
|
||||
id=self._next_collation_id,
|
||||
title=self.get_field_attr(f, "content")["name"],
|
||||
e_ids_and_pages=[],
|
||||
sort_order="",
|
||||
)
|
||||
collation_id = self._next_collation_id
|
||||
self._next_collation_id += 1
|
||||
c.e_ids_and_pages.append(
|
||||
(
|
||||
id,
|
||||
int(
|
||||
self.get_field_attr(f, "content")[
|
||||
"page"
|
||||
name = tag.get("name", "")
|
||||
shorthand = tag.get("shorthand", "")
|
||||
aliases = tag.get("aliases", [])
|
||||
subtag_ids = tag.get("subtag_ids", [])
|
||||
color = tag.get("color", "")
|
||||
|
||||
t = Tag(
|
||||
id=id,
|
||||
name=name,
|
||||
shorthand=shorthand,
|
||||
aliases=aliases,
|
||||
subtags_ids=subtag_ids,
|
||||
color=color,
|
||||
)
|
||||
|
||||
# NOTE: This does NOT use the add_tag_to_library() method!
|
||||
# That method is only used for Tags added at runtime.
|
||||
# This process uses the same inner methods, but waits until all of the
|
||||
# Tags are registered in the Tags list before creating the Tag clusters.
|
||||
self.tags.append(t)
|
||||
self._map_tag_id_to_index(t, -1)
|
||||
self._map_tag_strings_to_tag_id(t)
|
||||
else:
|
||||
logging.info(
|
||||
f"[LIBRARY]Skipping Tag with duplicate ID: {tag}"
|
||||
)
|
||||
|
||||
# Step 3: Map each Tag's subtags together now that all Tag objects in it.
|
||||
for t in self.tags:
|
||||
self._map_tag_id_to_cluster(t)
|
||||
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Tags loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
|
||||
# Parse Entries --------------------------------------------
|
||||
if entries := json_dump.get("entries"):
|
||||
start_time = time.time()
|
||||
for entry in entries:
|
||||
if "id" in entry:
|
||||
id = int(entry["id"])
|
||||
if id >= self._next_entry_id:
|
||||
self._next_entry_id = id + 1
|
||||
else:
|
||||
# Version 9.1.x+ Compatibility
|
||||
id = self._next_entry_id
|
||||
self._next_entry_id += 1
|
||||
|
||||
filename = entry.get("filename", "")
|
||||
e_path = entry.get("path", "")
|
||||
fields: list = []
|
||||
if "fields" in entry:
|
||||
# Cast JSON str keys to ints
|
||||
|
||||
for f in entry["fields"]:
|
||||
f[int(list(f.keys())[0])] = f[list(f.keys())[0]]
|
||||
del f[list(f.keys())[0]]
|
||||
fields = entry["fields"]
|
||||
|
||||
# Look through fields for legacy Collation data ----
|
||||
if int(major) >= 9 and int(minor) < 1:
|
||||
for f in fields:
|
||||
if self.get_field_attr(f, "type") == "collation":
|
||||
# NOTE: This legacy support will be removed in
|
||||
# a later version, probably 9.2.
|
||||
# Legacy Collation data present in v9.0.x
|
||||
# DATA SHAPE: {name: str, page: int}
|
||||
|
||||
# We'll do an inefficient linear search each
|
||||
# time to convert the legacy data.
|
||||
matched = False
|
||||
collation_id = -1
|
||||
for c in self.collations:
|
||||
if (
|
||||
c.title
|
||||
== self.get_field_attr(f, "content")[
|
||||
"name"
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
self.collations.append(c)
|
||||
self._map_collation_id_to_index(c, -1)
|
||||
f_id = self.get_field_attr(f, "id")
|
||||
f.clear()
|
||||
f[int(f_id)] = collation_id
|
||||
# Collation Field data present in v9.1.x+
|
||||
# DATA SHAPE: int
|
||||
elif int(major) >= 9 and int(minor) >= 1:
|
||||
pass
|
||||
):
|
||||
c.e_ids_and_pages.append(
|
||||
(
|
||||
id,
|
||||
int(
|
||||
self.get_field_attr(
|
||||
f, "content"
|
||||
)["page"]
|
||||
),
|
||||
)
|
||||
)
|
||||
matched = True
|
||||
collation_id = c.id
|
||||
if not matched:
|
||||
c = Collation(
|
||||
id=self._next_collation_id,
|
||||
title=self.get_field_attr(f, "content")[
|
||||
"name"
|
||||
],
|
||||
e_ids_and_pages=[],
|
||||
sort_order="",
|
||||
)
|
||||
collation_id = self._next_collation_id
|
||||
self._next_collation_id += 1
|
||||
c.e_ids_and_pages.append(
|
||||
(
|
||||
id,
|
||||
int(
|
||||
self.get_field_attr(
|
||||
f, "content"
|
||||
)["page"]
|
||||
),
|
||||
)
|
||||
)
|
||||
self.collations.append(c)
|
||||
self._map_collation_id_to_index(c, -1)
|
||||
f_id = self.get_field_attr(f, "id")
|
||||
f.clear()
|
||||
f[int(f_id)] = collation_id
|
||||
# Collation Field data present in v9.1.x+
|
||||
# DATA SHAPE: int
|
||||
elif int(major) >= 9 and int(minor) >= 1:
|
||||
pass
|
||||
|
||||
e = Entry(
|
||||
id=int(id),
|
||||
filename=filename,
|
||||
path=e_path,
|
||||
fields=fields,
|
||||
)
|
||||
self.entries.append(e)
|
||||
self._map_entry_id_to_index(e, -1)
|
||||
e = Entry(
|
||||
id=int(id),
|
||||
filename=filename,
|
||||
path=e_path,
|
||||
fields=fields,
|
||||
)
|
||||
self.entries.append(e)
|
||||
self._map_entry_id_to_index(e, -1)
|
||||
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
|
||||
# Parse Collations -----------------------------------------
|
||||
if "collations" in json_dump.keys():
|
||||
start_time = time.time()
|
||||
for collation in json_dump["collations"]:
|
||||
# Step 1: Create a Collation object and append it to
|
||||
# the internal Collations list, then map that
|
||||
# Collation's ID to its index in the Collations list.
|
||||
# Parse Collations -----------------------------------------
|
||||
if "collations" in json_dump.keys():
|
||||
start_time = time.time()
|
||||
for collation in json_dump["collations"]:
|
||||
# Step 1: Create a Collation object and append it to
|
||||
# the internal Collations list, then map that
|
||||
# Collation's ID to its index in the Collations list.
|
||||
|
||||
id = int(collation.get("id", 0))
|
||||
if id >= self._next_collation_id:
|
||||
self._next_collation_id = id + 1
|
||||
id = int(collation.get("id", 0))
|
||||
if id >= self._next_collation_id:
|
||||
self._next_collation_id = id + 1
|
||||
|
||||
title = collation.get("title", "")
|
||||
e_ids_and_pages = collation.get("e_ids_and_pages", [])
|
||||
sort_order = collation.get("sort_order", "")
|
||||
cover_id = collation.get("cover_id", -1)
|
||||
title = collation.get("title", "")
|
||||
e_ids_and_pages = collation.get("e_ids_and_pages", [])
|
||||
sort_order = collation.get("sort_order", "")
|
||||
cover_id = collation.get("cover_id", -1)
|
||||
|
||||
c = Collation(
|
||||
id=id,
|
||||
title=title,
|
||||
e_ids_and_pages=e_ids_and_pages,
|
||||
sort_order=sort_order,
|
||||
cover_id=cover_id,
|
||||
)
|
||||
c = Collation(
|
||||
id=id,
|
||||
title=title,
|
||||
e_ids_and_pages=e_ids_and_pages, # type: ignore
|
||||
sort_order=sort_order,
|
||||
cover_id=cover_id,
|
||||
)
|
||||
|
||||
# NOTE: This does NOT use the add_collation_to_library() method
|
||||
# which is intended to be used at runtime. However, there is
|
||||
# currently no reason why it couldn't be used here, and is
|
||||
# instead not used for consistency.
|
||||
self.collations.append(c)
|
||||
self._map_collation_id_to_index(c, -1)
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
# NOTE: This does NOT use the add_collation_to_library() method
|
||||
# which is intended to be used at runtime. However, there is
|
||||
# currently no reason why it couldn't be used here, and is
|
||||
# instead not used for consistency.
|
||||
self.collations.append(c)
|
||||
self._map_collation_id_to_index(c, -1)
|
||||
end_time = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Collations loaded in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
|
||||
return_code = 1
|
||||
self.save_library_backup_to_disk(is_auto=True)
|
||||
return_code = 1
|
||||
except ujson.JSONDecodeError:
|
||||
logging.info("[LIBRARY][ERROR]: Empty JSON file!")
|
||||
|
||||
# If the Library is loaded, continue other processes.
|
||||
if return_code == 1:
|
||||
@@ -759,9 +736,7 @@ class Library:
|
||||
"""Maps a full filepath to its corresponding Entry's ID."""
|
||||
self.filename_to_entry_id_map.clear()
|
||||
for entry in self.entries:
|
||||
self.filename_to_entry_id_map[
|
||||
(self.library_dir / entry.path / entry.filename)
|
||||
] = entry.id
|
||||
self.filename_to_entry_id_map[(entry.path / entry.filename)] = entry.id
|
||||
|
||||
# def _map_filenames_to_entry_ids(self):
|
||||
# """Maps the file paths of entries to their index in the library list."""
|
||||
@@ -820,13 +795,13 @@ class Library:
|
||||
filename = "ts_library.json"
|
||||
|
||||
self.verify_ts_folders()
|
||||
json_library: JsonLibary = self.to_json()
|
||||
|
||||
with open(
|
||||
self.library_dir / TS_FOLDER_NAME / filename, "w", encoding="utf-8"
|
||||
) as outfile:
|
||||
outfile.flush()
|
||||
ujson.dump(
|
||||
json_library,
|
||||
self.to_json(),
|
||||
outfile,
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
@@ -837,22 +812,16 @@ class Library:
|
||||
f"[LIBRARY] Library saved to disk in {(end_time - start_time):.3f} seconds"
|
||||
)
|
||||
|
||||
def save_library_backup_to_disk(self, is_auto: bool = False) -> str:
|
||||
def save_library_backup_to_disk(self) -> str:
|
||||
"""
|
||||
Saves a backup file of the Library to disk at the default TagStudio folder location.
|
||||
Returns the filename used, including the date and time."""
|
||||
|
||||
logging.info(f"[LIBRARY] Saving Library Backup to Disk...")
|
||||
start_time = time.time()
|
||||
|
||||
filename = (
|
||||
"ts_library_backup_auto.json"
|
||||
if is_auto
|
||||
else f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json'
|
||||
)
|
||||
filename = f'ts_library_backup_{datetime.datetime.utcnow().strftime("%F_%T").replace(":", "")}.json'
|
||||
|
||||
self.verify_ts_folders()
|
||||
json_library: JsonLibary = self.to_json()
|
||||
with open(
|
||||
self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename,
|
||||
"w",
|
||||
@@ -860,7 +829,7 @@ class Library:
|
||||
) as outfile:
|
||||
outfile.flush()
|
||||
ujson.dump(
|
||||
json_library,
|
||||
self.to_json(),
|
||||
outfile,
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
@@ -914,72 +883,54 @@ class Library:
|
||||
|
||||
# Scans the directory for files, keeping track of:
|
||||
# - Total file count
|
||||
# - Files without Library entries
|
||||
start_time_total = time.time()
|
||||
start_time_loop = time.time()
|
||||
ext_set = set(self.ext_list) # Should be slightly faster
|
||||
# - Files without library entries
|
||||
# for type in TYPES:
|
||||
start_time = time.time()
|
||||
for f in self.library_dir.glob("**/*"):
|
||||
end_time_loop = time.time()
|
||||
# Yield output every 1/30 of a second
|
||||
if (end_time_loop - start_time_loop) > 0.034:
|
||||
yield self.dir_file_count
|
||||
start_time_loop = time.time()
|
||||
try:
|
||||
# Skip this file if it should be excluded
|
||||
ext: str = f.suffix.lower()
|
||||
if (ext in ext_set and self.is_exclude_list) or (
|
||||
ext not in ext_set and not self.is_exclude_list
|
||||
):
|
||||
continue
|
||||
|
||||
# Finish if the file/path is already mapped in the Library
|
||||
if self.filename_to_entry_id_map.get(f) is not None:
|
||||
# No other checks are required.
|
||||
self.dir_file_count += 1
|
||||
continue
|
||||
|
||||
# If the file is new, check for validity
|
||||
if (
|
||||
"$RECYCLE.BIN" in f.parts
|
||||
or TS_FOLDER_NAME in f.parts
|
||||
or "tagstudio_thumbs" in f.parts
|
||||
or f.is_dir()
|
||||
"$RECYCLE.BIN" not in f.parts
|
||||
and TS_FOLDER_NAME not in f.parts
|
||||
and "tagstudio_thumbs" not in f.parts
|
||||
and not f.is_dir()
|
||||
):
|
||||
continue
|
||||
|
||||
# Add the validated new file to the Library
|
||||
self.dir_file_count += 1
|
||||
self.files_not_in_library.append(f)
|
||||
|
||||
if f.suffix.lower() not in self.ext_list and self.is_exclude_list:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
if file not in self.filename_to_entry_id_map:
|
||||
self.files_not_in_library.append(file)
|
||||
elif f.suffix.lower() in self.ext_list and not self.is_exclude_list:
|
||||
self.dir_file_count += 1
|
||||
file = f.relative_to(self.library_dir)
|
||||
try:
|
||||
_ = self.filename_to_entry_id_map[file]
|
||||
except KeyError:
|
||||
# print(file)
|
||||
self.files_not_in_library.append(file)
|
||||
except PermissionError:
|
||||
logging.info(f'[LIBRARY] Cannot access "{f}": PermissionError')
|
||||
|
||||
yield self.dir_file_count
|
||||
end_time_total = time.time()
|
||||
logging.info(
|
||||
f"[LIBRARY] Scanned directories in {(end_time_total - start_time_total):.3f} seconds"
|
||||
)
|
||||
# Sorts the files by date modified, descending
|
||||
if len(self.files_not_in_library) <= 150000:
|
||||
try:
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(t).stat().st_birthtime, # type: ignore[attr-defined]
|
||||
)
|
||||
else:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(t).stat().st_ctime,
|
||||
)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
logging.info(
|
||||
"[LIBRARY][ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
f"The File/Folder {f} cannot be accessed, because it requires higher permission!"
|
||||
)
|
||||
end_time = time.time()
|
||||
# Yield output every 1/30 of a second
|
||||
if (end_time - start_time) > 0.034:
|
||||
yield self.dir_file_count
|
||||
start_time = time.time()
|
||||
# Sorts the files by date modified, descending.
|
||||
if len(self.files_not_in_library) <= 100000:
|
||||
try:
|
||||
self.files_not_in_library = sorted(
|
||||
self.files_not_in_library,
|
||||
key=lambda t: -(self.library_dir / t).stat().st_ctime,
|
||||
)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
print(
|
||||
"[LIBRARY] [ERROR] Couldn't sort files, some were moved during the scanning/sorting process."
|
||||
)
|
||||
pass
|
||||
else:
|
||||
logging.info(
|
||||
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 150,000! Better sorting methods will be added in the future."
|
||||
print(
|
||||
"[LIBRARY][INFO] Not bothering to sort files because there's OVER 100,000! Better sorting methods will be added in the future."
|
||||
)
|
||||
|
||||
def refresh_missing_files(self):
|
||||
@@ -999,7 +950,7 @@ class Library:
|
||||
# Step [1/2]:
|
||||
# Remove this Entry from the Entries list.
|
||||
entry = self.get_entry(entry_id)
|
||||
path = self.library_dir / entry.path / entry.filename
|
||||
path = entry.path / entry.filename
|
||||
# logging.info(f'Removing path: {path}')
|
||||
|
||||
del self.filename_to_entry_id_map[path]
|
||||
@@ -1129,22 +1080,25 @@ class Library:
|
||||
)
|
||||
)
|
||||
for match in matches:
|
||||
file_1 = files[match[0]]
|
||||
file_2 = files[match[1]]
|
||||
# print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}')
|
||||
file_1 = files[match[0]].relative_to(self.library_dir)
|
||||
file_2 = files[match[1]].relative_to(self.library_dir)
|
||||
|
||||
if (
|
||||
file_1 in self.filename_to_entry_id_map.keys()
|
||||
file_1.resolve in self.filename_to_entry_id_map.keys()
|
||||
and file_2 in self.filename_to_entry_id_map.keys()
|
||||
):
|
||||
self.dupe_files.append(
|
||||
(files[match[0]], files[match[1]], match[2])
|
||||
)
|
||||
print("")
|
||||
|
||||
for dupe in self.dupe_files:
|
||||
print(
|
||||
f"[LIBRARY] MATCHED ({dupe[2]}%): \n {dupe[0]} \n-> {dupe[1]}",
|
||||
end="\n",
|
||||
)
|
||||
# self.dupe_files.append(full_path)
|
||||
|
||||
def remove_missing_files(self):
|
||||
deleted = []
|
||||
@@ -1331,7 +1285,8 @@ class Library:
|
||||
"""Adds files from the `files_not_in_library` list to the Library as Entries. Returns list of added indices."""
|
||||
new_ids: list[int] = []
|
||||
for file in self.files_not_in_library:
|
||||
path = Path(*file.parts[len(self.library_dir.parts) :])
|
||||
path = Path(file)
|
||||
# print(os.path.split(file))
|
||||
entry = Entry(
|
||||
id=self._next_entry_id, filename=path.name, path=path.parent, fields=[]
|
||||
)
|
||||
@@ -1342,6 +1297,8 @@ class Library:
|
||||
self.files_not_in_library.clear()
|
||||
return new_ids
|
||||
|
||||
self.files_not_in_library.clear()
|
||||
|
||||
def get_entry(self, entry_id: int) -> Entry:
|
||||
"""Returns an Entry object given an Entry ID."""
|
||||
return self.entries[self._entry_id_to_index_map[int(entry_id)]]
|
||||
@@ -1362,7 +1319,9 @@ class Library:
|
||||
"""Returns an Entry ID given the full filepath it points to."""
|
||||
try:
|
||||
if self.entries:
|
||||
return self.filename_to_entry_id_map[filename]
|
||||
return self.filename_to_entry_id_map[
|
||||
Path(filename).relative_to(self.library_dir)
|
||||
]
|
||||
except KeyError:
|
||||
return -1
|
||||
|
||||
@@ -1393,18 +1352,10 @@ class Library:
|
||||
only_missing: bool = "missing" in query or "no file" in query
|
||||
allow_adv: bool = "filename:" in query_words
|
||||
tag_only: bool = "tag_id:" in query_words
|
||||
tag_only_ids: list[int] = []
|
||||
if allow_adv:
|
||||
query_words.remove("filename:")
|
||||
if tag_only:
|
||||
query_words.remove("tag_id:")
|
||||
if query_words and query_words[0].isdigit():
|
||||
tag_only_ids.append(int(query_words[0]))
|
||||
tag_only_ids.extend(self.get_tag_cluster(int(query_words[0])))
|
||||
else:
|
||||
logging.error(
|
||||
f"[Library][ERROR] Invalid Tag ID in query: {query_words}"
|
||||
)
|
||||
# TODO: Expand this to allow for dynamic fields to work.
|
||||
only_no_author: bool = "no author" in query or "no artist" in query
|
||||
|
||||
@@ -1431,9 +1382,15 @@ class Library:
|
||||
all_tag_terms.remove(all_tag_terms[i])
|
||||
break
|
||||
|
||||
# print(all_tag_terms)
|
||||
|
||||
# non_entry_count = 0
|
||||
# Iterate over all Entries =============================================================
|
||||
for entry in self.entries:
|
||||
allowed_ext: bool = entry.filename.suffix.lower() not in self.ext_list
|
||||
# try:
|
||||
# entry: Entry = self.entries[self.file_to_library_index_map[self._source_filenames[i]]]
|
||||
# print(f'{entry}')
|
||||
|
||||
if allowed_ext == self.is_exclude_list:
|
||||
# If the entry has tags of any kind, append them to this main tag list.
|
||||
@@ -1475,6 +1432,7 @@ class Library:
|
||||
# elif query in entry.path.lower():
|
||||
|
||||
# NOTE: This searches path and filenames.
|
||||
|
||||
if allow_adv:
|
||||
if [q for q in query_words if (q in str(entry.path).lower())]:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
@@ -1483,14 +1441,17 @@ class Library:
|
||||
]:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
elif tag_only:
|
||||
for id in tag_only_ids:
|
||||
if entry.has_tag(self, id) and entry.id not in results:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
break
|
||||
if entry.has_tag(self, int(query_words[0])):
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
|
||||
# elif query in entry.filename.lower():
|
||||
# self.filtered_entries.append(index)
|
||||
elif entry_tags:
|
||||
# function to add entry to results
|
||||
def add_entry(entry: Entry):
|
||||
# self.filter_entries.append()
|
||||
# self.filtered_file_list.append(file)
|
||||
# results.append((SearchItemType.ENTRY, entry.id))
|
||||
added = False
|
||||
for f in entry.fields:
|
||||
if self.get_field_attr(f, "type") == "collation":
|
||||
@@ -1560,6 +1521,26 @@ class Library:
|
||||
add_entry(entry)
|
||||
break
|
||||
|
||||
# sys.stdout.write(
|
||||
# f'\r[INFO][FILTER]: {len(self.filtered_file_list)} matches found')
|
||||
# sys.stdout.flush()
|
||||
|
||||
# except:
|
||||
# # # Put this here to have new non-registered images show up
|
||||
# # if query == "untagged" or query == "no author" or query == "no artist":
|
||||
# # self.filtered_file_list.append(file)
|
||||
# # non_entry_count = non_entry_count + 1
|
||||
# pass
|
||||
|
||||
# end_time = time.time()
|
||||
# print(
|
||||
# f'[INFO][FILTER]: {len(self.filtered_entries)} matches found ({(end_time - start_time):.3f} seconds)')
|
||||
|
||||
# if non_entry_count:
|
||||
# print(
|
||||
# f'[INFO][FILTER]: There are {non_entry_count} new files in {self.source_dir} that do not have entries. These will not appear in most filtered results.')
|
||||
# if not self.filtered_entries:
|
||||
# print("[INFO][FILTER]: Filter returned no results.")
|
||||
else:
|
||||
for entry in self.entries:
|
||||
added = False
|
||||
@@ -1584,6 +1565,8 @@ class Library:
|
||||
|
||||
if not added:
|
||||
results.append((ItemType.ENTRY, entry.id))
|
||||
# for file in self._source_filenames:
|
||||
# self.filtered_file_list.append(file)
|
||||
results.reverse()
|
||||
return results
|
||||
|
||||
@@ -1912,6 +1895,31 @@ class Library:
|
||||
for t in self.tags:
|
||||
self._map_tag_strings_to_tag_id(t)
|
||||
|
||||
def merge_tag(self, source_tag: Tag, target_tag: Tag) -> None:
|
||||
source_tag_id: int = source_tag.id
|
||||
target_tag_id: int = target_tag.id
|
||||
|
||||
if source_tag.name not in target_tag.aliases:
|
||||
target_tag.aliases.append(source_tag.name)
|
||||
|
||||
for alias in source_tag.aliases:
|
||||
if alias not in target_tag.aliases:
|
||||
target_tag.aliases.append(alias)
|
||||
|
||||
for subtag_id in source_tag.subtag_ids:
|
||||
if subtag_id not in target_tag.subtag_ids:
|
||||
target_tag.subtag_ids.append(subtag_id)
|
||||
|
||||
for entry in self.entries:
|
||||
for field in entry.fields:
|
||||
if self.get_field_attr(field, "type") == "tag_box":
|
||||
if source_tag_id in self.get_field_attr(field, "content"):
|
||||
self.get_field_attr(field, "content").remove(source_tag_id)
|
||||
if target_tag_id not in self.get_field_attr(field, "content"):
|
||||
self.get_field_attr(field, "content").append(target_tag_id)
|
||||
|
||||
self.remove_tag(source_tag_id)
|
||||
|
||||
def get_tag_ref_count(self, tag_id: int) -> tuple[int, int]:
|
||||
"""Returns an int tuple (entry_ref_count, subtag_ref_count) of Tag reference counts."""
|
||||
entry_ref_count: int = 0
|
||||
@@ -2068,7 +2076,7 @@ class Library:
|
||||
# elif meta_tags_field_indices:
|
||||
# priority_field_index = meta_tags_field_indices[0]
|
||||
|
||||
if priority_field_index >= 0:
|
||||
if priority_field_index > 0:
|
||||
self.update_entry_field(
|
||||
entry_id, priority_field_index, [matching[0]], "append"
|
||||
)
|
||||
@@ -2326,7 +2334,7 @@ class Library:
|
||||
return self.tags[self._tag_id_to_index_map[int(tag_id)]]
|
||||
|
||||
def get_tag_cluster(self, tag_id: int) -> list[int]:
|
||||
"""Returns a list of Tag IDs that reference this Tag as its parent."""
|
||||
"""Returns a list of Tag IDs that reference this Tag."""
|
||||
if tag_id in self._tag_id_to_cluster_map:
|
||||
return self._tag_id_to_cluster_map[int(tag_id)]
|
||||
return []
|
||||
|
||||
@@ -36,7 +36,6 @@ class MediaType(str, Enum):
|
||||
PRESENTATION: str = "presentation"
|
||||
PROGRAM: str = "program"
|
||||
SHORTCUT: str = "shortcut"
|
||||
SOURCE_ENGINE: str = "source_engine"
|
||||
SPREADSHEET: str = "spreadsheet"
|
||||
TEXT: str = "text"
|
||||
VIDEO: str = "video"
|
||||
@@ -183,8 +182,6 @@ class MediaCategories:
|
||||
".crw",
|
||||
".dng",
|
||||
".nef",
|
||||
".orf",
|
||||
".raf",
|
||||
".raw",
|
||||
".rw2",
|
||||
}
|
||||
@@ -246,20 +243,6 @@ class MediaCategories:
|
||||
".ts",
|
||||
".txt",
|
||||
".xml",
|
||||
".vmt",
|
||||
".fgd",
|
||||
".nut",
|
||||
".cfg",
|
||||
".conf",
|
||||
".vdf",
|
||||
".vcfg",
|
||||
".gi",
|
||||
".inf",
|
||||
".vqlayout",
|
||||
".qss",
|
||||
".vsc",
|
||||
".kv3",
|
||||
".vsnd_template",
|
||||
}
|
||||
_PRESENTATION_SET: set[str] = {
|
||||
".key",
|
||||
@@ -268,9 +251,6 @@ class MediaCategories:
|
||||
".pptx",
|
||||
}
|
||||
_PROGRAM_SET: set[str] = {".app", ".exe"}
|
||||
_SOURCE_ENGINE_SET: set[str] = {
|
||||
".vtf",
|
||||
}
|
||||
_SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"}
|
||||
_SPREADSHEET_SET: set[str] = {
|
||||
".csv",
|
||||
@@ -409,11 +389,6 @@ class MediaCategories:
|
||||
extensions=_SHORTCUT_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SOURCE_ENGINE,
|
||||
extensions=_SOURCE_ENGINE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
SPREADSHEET_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.SPREADSHEET,
|
||||
extensions=_SPREADSHEET_SET,
|
||||
@@ -454,7 +429,6 @@ class MediaCategories:
|
||||
PRESENTATION_TYPES,
|
||||
PROGRAM_TYPES,
|
||||
SHORTCUT_TYPES,
|
||||
SOURCE_ENGINE_TYPES,
|
||||
SPREADSHEET_TYPES,
|
||||
TEXT_TYPES,
|
||||
VIDEO_TYPES,
|
||||
|
||||
@@ -284,12 +284,6 @@ _UI_COLORS: dict = {
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"green": {
|
||||
ColorType.PRIMARY: "#28bb48",
|
||||
ColorType.BORDER: "#43c568",
|
||||
@@ -302,6 +296,12 @@ _UI_COLORS: dict = {
|
||||
ColorType.LIGHT_ACCENT: "#EFD4FB",
|
||||
ColorType.DARK_ACCENT: "#3E1555",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"theme_dark": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
@@ -317,18 +317,18 @@ _UI_COLORS: dict = {
|
||||
}
|
||||
|
||||
|
||||
def get_tag_color(color_type, color):
|
||||
def get_tag_color(type, color):
|
||||
color = color.lower()
|
||||
try:
|
||||
if color_type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][color_type], color)
|
||||
if type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][type], color)
|
||||
else:
|
||||
return _TAG_COLORS[color][color_type]
|
||||
return _TAG_COLORS[color][type]
|
||||
except KeyError:
|
||||
return "#FF00FF"
|
||||
|
||||
|
||||
def get_ui_color(color_type: ColorType, color: str):
|
||||
def get_ui_color(type: ColorType, color: str):
|
||||
"""Returns a hex value given a color name and ColorType."""
|
||||
color = color.lower()
|
||||
return _UI_COLORS.get(color).get(color_type)
|
||||
return _UI_COLORS.get(color).get(type)
|
||||
|
||||
@@ -31,7 +31,7 @@ class TagStudioCore:
|
||||
json_dump = {}
|
||||
info = {}
|
||||
_filepath: Path = Path(filepath)
|
||||
_filepath = _filepath.parent / (_filepath.name + ".json")
|
||||
_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.
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import ffmpeg
|
||||
from pathlib import Path
|
||||
|
||||
from src.qt.helpers.vendored.ffmpeg import _probe
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
"""Test if a video is in a readable format. Examples of unreadable videos
|
||||
@@ -17,7 +15,7 @@ def is_readable_video(filepath: Path | str):
|
||||
filepath (Path | str):
|
||||
"""
|
||||
try:
|
||||
probe = _probe(Path(filepath))
|
||||
probe = ffmpeg.probe(Path(filepath))
|
||||
for stream in probe["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
|
||||
@@ -9,7 +9,17 @@ def four_corner_gradient(
|
||||
image: Image.Image, size: tuple[int, int], mask: Image.Image
|
||||
) -> Image.Image:
|
||||
if image.size != size:
|
||||
# Old 1 color method.
|
||||
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
|
||||
# bg = Image.new(mode='RGB',size=size,color=bg_col)
|
||||
# bg.thumbnail((1, 1))
|
||||
# bg = bg.resize(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(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)))
|
||||
@@ -31,7 +41,13 @@ def four_corner_gradient(
|
||||
final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
|
||||
final.paste(bg, mask=mask.getchannel(0))
|
||||
|
||||
# bg.putalpha(mask)
|
||||
# final = bg
|
||||
|
||||
else:
|
||||
# image.putalpha(mask)
|
||||
# final = image
|
||||
|
||||
final = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
final.paste(image, mask=mask.getchannel(0))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# https://creativecommons.org/licenses/by-sa/4.0/
|
||||
# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap
|
||||
from PySide6.QtGui import QPixmap, QPainter, QBrush
|
||||
from PySide6.QtWidgets import (
|
||||
QProxyStyle,
|
||||
)
|
||||
@@ -18,10 +18,10 @@ class RoundedPixmapStyle(QProxyStyle):
|
||||
def drawItemPixmap(self, painter, rectangle, alignment, pixmap):
|
||||
painter.save()
|
||||
pix = QPixmap(pixmap.size())
|
||||
pix.fill(QColor("transparent"))
|
||||
pix.fill("#00000000")
|
||||
p = QPainter(pix)
|
||||
p.setBrush(QBrush(pixmap))
|
||||
p.setPen(QColor("transparent"))
|
||||
p.setPen("#00000000")
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
|
||||
p.end()
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def promptless_Popen(
|
||||
args,
|
||||
bufsize=-1,
|
||||
executable=None,
|
||||
stdin=None,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
preexec_fn=None,
|
||||
close_fds=True,
|
||||
shell=False,
|
||||
cwd=None,
|
||||
env=None,
|
||||
universal_newlines=None,
|
||||
startupinfo=None,
|
||||
restore_signals=True,
|
||||
start_new_session=False,
|
||||
pass_fds=(),
|
||||
*,
|
||||
group=None,
|
||||
extra_groups=None,
|
||||
user=None,
|
||||
umask=-1,
|
||||
encoding=None,
|
||||
errors=None,
|
||||
text=None,
|
||||
pipesize=-1,
|
||||
process_group=None,
|
||||
):
|
||||
creation_flags = 0
|
||||
if sys.platform == "win32":
|
||||
creation_flags = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
return subprocess.Popen(
|
||||
args=args,
|
||||
bufsize=bufsize,
|
||||
executable=executable,
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
preexec_fn=preexec_fn,
|
||||
close_fds=close_fds,
|
||||
shell=shell,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
universal_newlines=universal_newlines,
|
||||
startupinfo=startupinfo,
|
||||
creationflags=creation_flags,
|
||||
restore_signals=restore_signals,
|
||||
start_new_session=start_new_session,
|
||||
pass_fds=pass_fds,
|
||||
group=group,
|
||||
extra_groups=extra_groups,
|
||||
user=user,
|
||||
umask=umask,
|
||||
encoding=encoding,
|
||||
errors=errors,
|
||||
text=text,
|
||||
pipesize=pipesize,
|
||||
process_group=process_group,
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
# Copyright (C) 2022 Karl Kroening (kkroening).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import sys
|
||||
|
||||
import ffmpeg
|
||||
|
||||
from src.qt.helpers.silent_popen import promptless_Popen
|
||||
|
||||
def _probe(filename, cmd='ffprobe', timeout=None, **kwargs):
|
||||
"""Run ffprobe on the specified file and return a JSON representation of the output.
|
||||
|
||||
Raises:
|
||||
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
|
||||
an :class:`Error` is returned with a generic error message.
|
||||
The stderr output can be retrieved by accessing the
|
||||
``stderr`` property of the exception.
|
||||
"""
|
||||
args = [cmd, '-show_format', '-show_streams', '-of', 'json']
|
||||
args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs)
|
||||
args += [filename]
|
||||
|
||||
# PATCHED
|
||||
p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
communicate_kwargs = {}
|
||||
if timeout is not None:
|
||||
communicate_kwargs['timeout'] = timeout
|
||||
out, err = p.communicate(**communicate_kwargs)
|
||||
if p.returncode != 0:
|
||||
raise ffmpeg.Error('ffprobe', out, err)
|
||||
return json.loads(out.decode('utf-8'))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from pydub.utils import (
|
||||
get_prober_name,
|
||||
fsdecode,
|
||||
_fd_or_path_or_tempfile,
|
||||
get_extra_info,
|
||||
)
|
||||
|
||||
from src.qt.helpers.silent_popen import promptless_Popen
|
||||
|
||||
def _mediainfo_json(filepath, read_ahead_limit=-1):
|
||||
"""Return json dictionary with media info(codec, duration, size, bitrate...) from filepath
|
||||
"""
|
||||
prober = get_prober_name()
|
||||
command_args = [
|
||||
"-v", "info",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
]
|
||||
try:
|
||||
command_args += [fsdecode(filepath)]
|
||||
stdin_parameter = None
|
||||
stdin_data = None
|
||||
except TypeError:
|
||||
if prober == 'ffprobe':
|
||||
command_args += ["-read_ahead_limit", str(read_ahead_limit),
|
||||
"cache:pipe:0"]
|
||||
else:
|
||||
command_args += ["-"]
|
||||
stdin_parameter = subprocess.PIPE
|
||||
file, close_file = _fd_or_path_or_tempfile(filepath, 'rb', tempfile=False)
|
||||
file.seek(0)
|
||||
stdin_data = file.read()
|
||||
if close_file:
|
||||
file.close()
|
||||
|
||||
command = [prober, '-of', 'json'] + command_args
|
||||
# PATCHED
|
||||
res = promptless_Popen(command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, stderr = res.communicate(input=stdin_data)
|
||||
output = output.decode("utf-8", 'ignore')
|
||||
stderr = stderr.decode("utf-8", 'ignore')
|
||||
|
||||
try:
|
||||
info = json.loads(output)
|
||||
except json.decoder.JSONDecodeError:
|
||||
# If ffprobe didn't give any information, just return it
|
||||
# (for example, because the file doesn't exist)
|
||||
return None
|
||||
if not info:
|
||||
return info
|
||||
|
||||
extra_info = get_extra_info(stderr)
|
||||
|
||||
audio_streams = [x for x in info['streams'] if x['codec_type'] == 'audio']
|
||||
if len(audio_streams) == 0:
|
||||
return info
|
||||
|
||||
# We just operate on the first audio stream in case there are more
|
||||
stream = audio_streams[0]
|
||||
|
||||
def set_property(stream, prop, value):
|
||||
if prop not in stream or stream[prop] == 0:
|
||||
stream[prop] = value
|
||||
|
||||
for token in extra_info[stream['index']]:
|
||||
m = re.match(r'([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$', token)
|
||||
m2 = re.match(r'([su]([0-9]{1,2})p?)( \(default\))?$', token)
|
||||
if m:
|
||||
set_property(stream, 'sample_fmt', m.group(1))
|
||||
set_property(stream, 'bits_per_sample', int(m.group(2)))
|
||||
set_property(stream, 'bits_per_raw_sample', int(m.group(3)))
|
||||
elif m2:
|
||||
set_property(stream, 'sample_fmt', m2.group(1))
|
||||
set_property(stream, 'bits_per_sample', int(m2.group(2)))
|
||||
set_property(stream, 'bits_per_raw_sample', int(m2.group(2)))
|
||||
elif re.match(r'(flt)p?( \(default\))?$', token):
|
||||
set_property(stream, 'sample_fmt', token)
|
||||
set_property(stream, 'bits_per_sample', 32)
|
||||
set_property(stream, 'bits_per_raw_sample', 32)
|
||||
elif re.match(r'(dbl)p?( \(default\))?$', token):
|
||||
set_property(stream, 'sample_fmt', token)
|
||||
set_property(stream, 'bits_per_sample', 64)
|
||||
set_property(stream, 'bits_per_raw_sample', 64)
|
||||
return info
|
||||
@@ -5,28 +5,30 @@
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QTextEdit,
|
||||
QComboBox,
|
||||
)
|
||||
from src.core.constants import TAG_COLORS
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.modals.tag_search import TagSearchPanel
|
||||
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
@@ -34,7 +36,7 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
class BuildTagPanel(PanelWidget):
|
||||
on_edit = Signal(Tag)
|
||||
|
||||
def __init__(self, library, tag_id: int = -1, tag_name: str = "New Tag"):
|
||||
def __init__(self, library, tag_id: int = -1):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
# self.callback = callback
|
||||
@@ -115,7 +117,7 @@ class BuildTagPanel(PanelWidget):
|
||||
self.subtags_add_button = QPushButton()
|
||||
self.subtags_add_button.setText("+")
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_created.connect(lambda x: self.add_subtag_callback(x))
|
||||
tsp.tag_chosen.connect(lambda x: self.add_subtag_callback(x))
|
||||
self.add_tag_modal = PanelModal(tsp, "Add Parent Tags", "Add Parent Tags")
|
||||
self.subtags_add_button.clicked.connect(self.add_tag_modal.show)
|
||||
self.subtags_layout.addWidget(self.subtags_add_button)
|
||||
@@ -161,7 +163,7 @@ class BuildTagPanel(PanelWidget):
|
||||
if tag_id >= 0:
|
||||
self.tag = self.lib.get_tag(tag_id)
|
||||
else:
|
||||
self.tag = Tag(-1, tag_name, "", [], [], "")
|
||||
self.tag = Tag(-1, "New Tag", "", [], [], "")
|
||||
self.set_tag(self.tag)
|
||||
|
||||
def add_subtag_callback(self, tag_id: int):
|
||||
|
||||
@@ -106,7 +106,6 @@ class DropImport:
|
||||
continue
|
||||
|
||||
dest_file = self.get_relative_path(file)
|
||||
full_dest_path: Path = self.driver.lib.library_dir / dest_file
|
||||
|
||||
if file in self.duplicate_files:
|
||||
duplicated_files_progress += 1
|
||||
@@ -116,12 +115,14 @@ class DropImport:
|
||||
if self.choice == 2: # rename
|
||||
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
|
||||
dest_file = dest_file.with_name(new_name)
|
||||
self.driver.lib.files_not_in_library.append(full_dest_path)
|
||||
self.driver.lib.files_not_in_library.append(dest_file)
|
||||
else: # override is simply copying but not adding a new entry
|
||||
self.driver.lib.files_not_in_library.append(full_dest_path)
|
||||
self.driver.lib.files_not_in_library.append(dest_file)
|
||||
|
||||
(full_dest_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, full_dest_path)
|
||||
(self.driver.lib.library_dir / dest_file).parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
|
||||
|
||||
fileCount += 1
|
||||
yield [fileCount, duplicated_files_progress]
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
import subprocess
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Signal, Qt, QUrl
|
||||
from PySide6.QtGui import QPixmap, QDesktopServices
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
class FfmpegChecker(QMessageBox):
|
||||
"""A warning dialog for if FFmpeg is missing."""
|
||||
|
||||
HELP_URL = "https://docs.tagstud.io/help/ffmpeg/"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle("Warning: Missing dependency")
|
||||
self.setText("Warning: Could not find FFmpeg installation")
|
||||
self.setIcon(QMessageBox.Warning)
|
||||
# Blocks other application interactions until resolved
|
||||
self.setWindowModality(Qt.ApplicationModal)
|
||||
|
||||
self.setStandardButtons(
|
||||
QMessageBox.Help | QMessageBox.Ignore | QMessageBox.Cancel
|
||||
)
|
||||
self.setDefaultButton(QMessageBox.Ignore)
|
||||
# Enables the cancel button but hides it to allow for click X to close dialog
|
||||
self.button(QMessageBox.Cancel).hide()
|
||||
|
||||
self.ffmpeg = False
|
||||
self.ffprobe = False
|
||||
|
||||
def installed(self):
|
||||
"""Checks if both FFmpeg and FFprobe are installed and in the PATH."""
|
||||
if which("ffmpeg"):
|
||||
self.ffmpeg = True
|
||||
if which("ffprobe"):
|
||||
self.ffprobe = True
|
||||
|
||||
logging.info(
|
||||
f"[FFmpegChecker] FFmpeg found: {self.ffmpeg}, FFprobe found: {self.ffprobe}"
|
||||
)
|
||||
return self.ffmpeg and self.ffprobe
|
||||
|
||||
def show_warning(self):
|
||||
"""Displays the warning to the user and awaits respone."""
|
||||
missing = "FFmpeg"
|
||||
# If ffmpeg is installed but not ffprobe
|
||||
if not self.ffprobe and self.ffmpeg:
|
||||
missing = "FFprobe"
|
||||
|
||||
self.setText(f"Warning: Could not find {missing} installation")
|
||||
self.setInformativeText(
|
||||
f"{missing} is required for multimedia thumbnails and playback"
|
||||
)
|
||||
# Shows the dialog
|
||||
selection = self.exec()
|
||||
|
||||
# Selection will either be QMessageBox.Help or (QMessageBox.Ignore | QMessageBox.Cancel) which can be ignored
|
||||
if selection == QMessageBox.Help:
|
||||
QDesktopServices.openUrl(QUrl(self.HELP_URL))
|
||||
@@ -342,8 +342,8 @@ class ModifiedTagWidget(
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, tag.color)};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
|
||||
f"border-style:inset;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
|
||||
63
tagstudio/src/qt/modals/merge_tag.py
Normal file
63
tagstudio/src/qt/modals/merge_tag.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QComboBox, QPushButton
|
||||
from PySide6.QtCore import Qt
|
||||
import logging
|
||||
|
||||
|
||||
class MergeTagModal(QDialog):
|
||||
def __init__(self, library, current_tag):
|
||||
super().__init__()
|
||||
self.lib = library
|
||||
self.current_tag = current_tag
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Selected tag:"))
|
||||
self.selected_tag_dropdown = QComboBox(self)
|
||||
for tag in self.lib.tags:
|
||||
self.selected_tag_dropdown.addItem(tag.display_name(self.lib), tag)
|
||||
self.selected_tag_dropdown.setCurrentIndex(self.find_current_tag_index())
|
||||
self.selected_tag_dropdown.currentIndexChanged.connect(self.update_current_tag)
|
||||
layout.addWidget(self.selected_tag_dropdown)
|
||||
|
||||
arrow_label = QLabel("↓")
|
||||
arrow_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(arrow_label)
|
||||
|
||||
layout.addWidget(QLabel("Select tag to merge with:"))
|
||||
self.tag2_dropdown = QComboBox(self)
|
||||
self.update_tag2_dropdown()
|
||||
layout.addWidget(self.tag2_dropdown)
|
||||
|
||||
self.merge_button = QPushButton("Merge", self)
|
||||
layout.addWidget(self.merge_button)
|
||||
|
||||
self.merge_button.clicked.connect(self.merge_tags)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def find_current_tag_index(self):
|
||||
for index in range(self.selected_tag_dropdown.count()):
|
||||
if self.selected_tag_dropdown.itemData(index) == self.current_tag:
|
||||
return index
|
||||
return 0
|
||||
|
||||
def update_current_tag(self):
|
||||
self.current_tag = self.selected_tag_dropdown.currentData()
|
||||
self.update_tag2_dropdown()
|
||||
|
||||
def update_tag2_dropdown(self):
|
||||
self.tag2_dropdown.clear()
|
||||
for tag in self.lib.tags:
|
||||
if tag.id != self.current_tag.id:
|
||||
self.tag2_dropdown.addItem(tag.display_name(self.lib), tag)
|
||||
|
||||
def merge_tags(self):
|
||||
target_tag = self.tag2_dropdown.currentData()
|
||||
if target_tag and self.current_tag != target_tag:
|
||||
self.lib.merge_tag(self.current_tag, target_tag)
|
||||
self.accept()
|
||||
else:
|
||||
logging.error("MergeTagModal: Invalid tag selection.")
|
||||
self.reject()
|
||||
@@ -2,37 +2,33 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QFrame,
|
||||
)
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
if typing.TYPE_CHECKING:
|
||||
from src.core.library import Library, Tag
|
||||
from src.qt.ts_qt import QtDriver
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.core.library import Library
|
||||
from src.qt.widgets.panel import PanelWidget, PanelModal
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
from src.qt.modals.build_tag import BuildTagPanel
|
||||
|
||||
|
||||
class TagDatabasePanel(PanelWidget):
|
||||
tag_chosen = Signal(int)
|
||||
|
||||
def __init__(self, library: "Library", driver: "QtDriver"):
|
||||
def __init__(self, library):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
self.driver: QtDriver = driver
|
||||
# self.callback = callback
|
||||
self.first_tag_id = -1
|
||||
self.tag_limit = 30
|
||||
# self.selected_tag: int = 0
|
||||
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
@@ -137,14 +133,9 @@ class TagDatabasePanel(PanelWidget):
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(3)
|
||||
tag: Tag = self.lib.get_tag(tag_id)
|
||||
tw = TagWidget(self.lib, tag, True, False)
|
||||
tw.on_edit.connect(lambda checked=False, t=tag: (self.edit_tag(t.id)))
|
||||
tw.on_click.connect(
|
||||
lambda checked=False, q=f"tag_id: {tag_id}": (
|
||||
self.driver.main_window.searchField.setText(q),
|
||||
self.driver.filter_items(q),
|
||||
)
|
||||
tw = TagWidget(self.lib, self.lib.get_tag(tag_id), True, False)
|
||||
tw.on_edit.connect(
|
||||
lambda checked=False, t=self.lib.get_tag(tag_id): (self.edit_tag(t.id))
|
||||
)
|
||||
row.addWidget(tw)
|
||||
self.scroll_layout.addWidget(container)
|
||||
|
||||
@@ -5,38 +5,42 @@
|
||||
|
||||
import logging
|
||||
import math
|
||||
import src.qt.modals.build_tag as bt
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
|
||||
from PySide6.QtCore import Signal, Qt, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
)
|
||||
|
||||
from src.core.constants import TAG_COLORS
|
||||
from src.core.library import Library
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.widgets.panel import PanelModal, PanelWidget
|
||||
from src.qt.widgets.panel import PanelWidget
|
||||
from src.qt.widgets.tag import TagWidget
|
||||
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
|
||||
|
||||
class TagSearchPanel(PanelWidget):
|
||||
tag_created = Signal(int)
|
||||
tag_chosen = Signal(int)
|
||||
|
||||
def __init__(self, library: "Library"):
|
||||
def __init__(self, library):
|
||||
super().__init__()
|
||||
self.lib: Library = library
|
||||
self.first_tag_id: int | None = None
|
||||
# self.callback = callback
|
||||
self.first_tag_id = None
|
||||
self.tag_limit = 100
|
||||
# self.selected_tag: int = 0
|
||||
self.setMinimumSize(300, 400)
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setContentsMargins(6, 0, 6, 0)
|
||||
@@ -52,38 +56,57 @@ class TagSearchPanel(PanelWidget):
|
||||
lambda checked=False: self.on_return(self.search_field.text())
|
||||
)
|
||||
|
||||
# self.content_container = QWidget()
|
||||
# self.content_layout = QHBoxLayout(self.content_container)
|
||||
|
||||
self.scroll_contents = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_contents)
|
||||
self.scroll_layout.setContentsMargins(6, 0, 6, 0)
|
||||
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.scroll_area = QScrollArea()
|
||||
# self.scroll_area.setStyleSheet('background: #000000;')
|
||||
self.scroll_area.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
)
|
||||
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setFrameShadow(QFrame.Shadow.Plain)
|
||||
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
# sa.setMaximumWidth(self.preview_size[0])
|
||||
self.scroll_area.setWidget(self.scroll_contents)
|
||||
|
||||
# self.add_button = QPushButton()
|
||||
# self.root_layout.addWidget(self.add_button)
|
||||
# self.add_button.setText('Add Tag')
|
||||
# # self.done_button.clicked.connect(lambda checked=False, x=1101: (callback(x), self.hide()))
|
||||
# self.add_button.clicked.connect(lambda checked=False, x=1101: callback(x))
|
||||
# # self.setLayout(self.root_layout)
|
||||
|
||||
self.root_layout.addWidget(self.search_field)
|
||||
self.root_layout.addWidget(self.scroll_area)
|
||||
self.update_tags("")
|
||||
|
||||
# def reset(self):
|
||||
# self.search_field.setText('')
|
||||
# self.update_tags('')
|
||||
# self.search_field.setFocus()
|
||||
|
||||
def on_return(self, text: str):
|
||||
if text and self.first_tag_id is not None:
|
||||
# callback(self.first_tag_id)
|
||||
self.tag_created.emit(self.first_tag_id)
|
||||
self.tag_chosen.emit(self.first_tag_id)
|
||||
self.search_field.setText("")
|
||||
self.update_tags()
|
||||
elif text:
|
||||
self.create_and_add_tag(text)
|
||||
self.parentWidget().hide()
|
||||
else:
|
||||
self.search_field.setFocus()
|
||||
self.parentWidget().hide()
|
||||
|
||||
def update_tags(self, query: str = ""):
|
||||
# for c in self.scroll_layout.children():
|
||||
# c.widget().deleteLater()
|
||||
while self.scroll_layout.count():
|
||||
# logging.info(f"I'm deleting { self.scroll_layout.itemAt(0).widget()}")
|
||||
self.scroll_layout.takeAt(0).widget().deleteLater()
|
||||
|
||||
found_tags = self.lib.search_tags(query, include_cluster=True)[: self.tag_limit]
|
||||
@@ -130,7 +153,10 @@ class TagSearchPanel(PanelWidget):
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(1*self.devicePixelRatio())}px;"
|
||||
# f'padding-top: 1.5px;'
|
||||
# f'padding-right: 4px;'
|
||||
f"padding-bottom: 5px;"
|
||||
# f'padding-left: 4px;'
|
||||
f"font-size: 20px;"
|
||||
f"}}"
|
||||
f"QPushButton::hover"
|
||||
@@ -141,95 +167,15 @@ class TagSearchPanel(PanelWidget):
|
||||
f"}}"
|
||||
)
|
||||
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_created.emit(x))
|
||||
ab.clicked.connect(lambda checked=False, x=tag_id: self.tag_chosen.emit(x))
|
||||
|
||||
l.addWidget(tw)
|
||||
l.addWidget(ab)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
# Add a create tag button if a query is entered
|
||||
if query:
|
||||
c = self.create_tag_button(query)
|
||||
self.scroll_layout.addWidget(c)
|
||||
|
||||
self.search_field.setFocus()
|
||||
|
||||
def create_tag_button(self, name: str) -> QWidget:
|
||||
"""Construct a "Create Tag" button.
|
||||
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
c = QWidget()
|
||||
l = QHBoxLayout(c)
|
||||
l.setContentsMargins(0, 0, 0, 0)
|
||||
l.setSpacing(3)
|
||||
|
||||
create_and_add_button = QPushButton(self)
|
||||
create_and_add_button.setFlat(True)
|
||||
create_and_add_button.setText(
|
||||
f"Create && Add \"{name.replace("&", "&&")}\" Tag"
|
||||
)
|
||||
|
||||
inner_layout = QHBoxLayout()
|
||||
inner_layout.setObjectName("innerLayout")
|
||||
inner_layout.setContentsMargins(2, 2, 2, 2)
|
||||
create_and_add_button.setLayout(inner_layout)
|
||||
create_and_add_button.setMinimumSize(math.ceil(22 * 1.5), 22)
|
||||
|
||||
create_and_add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: {get_tag_color(ColorType.PRIMARY, "dark gray")};"
|
||||
f"color: {get_tag_color(ColorType.TEXT, "dark gray")};"
|
||||
f"font-weight: 600;"
|
||||
f"border-color:{get_tag_color(ColorType.BORDER, "dark gray")};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width: {math.ceil(self.devicePixelRatio())}px;"
|
||||
f"padding-right: 4px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 4px;"
|
||||
f"font-size: 13px"
|
||||
f"}}"
|
||||
f"QPushButton::hover{{"
|
||||
f"border-color:{get_tag_color(ColorType.LIGHT_ACCENT, "dark gray")};"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
create_and_add_button.clicked.connect(
|
||||
lambda x=name: self.create_and_add_tag(name)
|
||||
)
|
||||
l.addWidget(create_and_add_button)
|
||||
|
||||
return c
|
||||
|
||||
def create_and_add_tag(self, name: str):
|
||||
"""Open "Add Tag" panel to create and add a new tag with the given name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
self.add_tag_modal = PanelModal(
|
||||
bt.BuildTagPanel(self.lib, tag_name=name),
|
||||
"New Tag",
|
||||
"Add Tag",
|
||||
has_save=True,
|
||||
)
|
||||
self.add_tag_modal.saved.connect(lambda n=name: self.on_tag_modal_saved(n))
|
||||
self.add_tag_modal.save_button.setFocus()
|
||||
self.add_tag_modal.show()
|
||||
|
||||
def on_tag_modal_saved(self, name):
|
||||
"""Callback for actions to perform when a new "Create & Add" tag is confirmed.
|
||||
Args:
|
||||
name (str): The name of the tag to give.
|
||||
"""
|
||||
panel: bt.BuildTagPanel = self.add_tag_modal.widget
|
||||
self.tag_created.emit(self.lib.add_tag_to_library(panel.build_tag()))
|
||||
self.add_tag_modal.hide()
|
||||
self.update_tags(name)
|
||||
|
||||
def showEvent(self, event):
|
||||
# Clear search field and focus when showing modal
|
||||
self.search_field.setText("")
|
||||
self.search_field.setFocus()
|
||||
# def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# self.search_field.setFocus()
|
||||
# return super().enterEvent(event)
|
||||
# self.focusOutEvent
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,6 @@ from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
|
||||
from src.qt.modals.fix_dupes import FixDupeFilesModal
|
||||
from src.qt.modals.folders_to_tags import FoldersToTagsModal
|
||||
from src.qt.modals.drop_import import DropImport
|
||||
from src.qt.modals.ffmpeg_checker import FfmpegChecker
|
||||
|
||||
# this import has side-effect of import PySide resources
|
||||
import src.qt.resources_rc # pylint: disable=unused-import
|
||||
@@ -293,20 +292,8 @@ class QtDriver(QObject):
|
||||
|
||||
splash_pixmap = QPixmap(":/images/splash.png")
|
||||
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
|
||||
splash_pixmap = splash_pixmap.scaledToWidth(
|
||||
math.floor(
|
||||
min(
|
||||
(
|
||||
QGuiApplication.primaryScreen().geometry().width()
|
||||
* self.main_window.devicePixelRatio()
|
||||
)
|
||||
/ 4,
|
||||
splash_pixmap.width(),
|
||||
)
|
||||
),
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
|
||||
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.splash.show()
|
||||
|
||||
if os.name == "nt":
|
||||
@@ -495,24 +482,14 @@ class QtDriver(QObject):
|
||||
window_menu.addAction(check_action)
|
||||
|
||||
# Tools Menu ===========================================================
|
||||
def create_fix_unlinked_entries_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "unlinked_modal"):
|
||||
self.unlinked_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
self.unlinked_modal.show()
|
||||
|
||||
fix_unlinked_entries_action = QAction("Fix &Unlinked Entries", menu_bar)
|
||||
fix_unlinked_entries_action.triggered.connect(create_fix_unlinked_entries_modal)
|
||||
fue_modal = FixUnlinkedEntriesModal(self.lib, self)
|
||||
fix_unlinked_entries_action.triggered.connect(lambda: fue_modal.show())
|
||||
tools_menu.addAction(fix_unlinked_entries_action)
|
||||
|
||||
def create_dupe_files_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "dupe_modal"):
|
||||
self.dupe_modal = FixDupeFilesModal(self.lib, self)
|
||||
self.dupe_modal.show()
|
||||
|
||||
fix_dupe_files_action = QAction("Fix Duplicate &Files", menu_bar)
|
||||
fix_dupe_files_action.triggered.connect(create_dupe_files_modal)
|
||||
fdf_modal = FixDupeFilesModal(self.lib, self)
|
||||
fix_dupe_files_action.triggered.connect(lambda: fdf_modal.show())
|
||||
tools_menu.addAction(fix_dupe_files_action)
|
||||
|
||||
create_collage_action = QAction("Create Collage", menu_bar)
|
||||
@@ -563,14 +540,9 @@ class QtDriver(QObject):
|
||||
)
|
||||
window_menu.addAction(show_libs_list_action)
|
||||
|
||||
def create_folders_tags_modal():
|
||||
"""Postpone the creation of the modal until it is needed."""
|
||||
if not hasattr(self, "folders_modal"):
|
||||
self.folders_modal = FoldersToTagsModal(self.lib, self)
|
||||
self.folders_modal.show()
|
||||
|
||||
folders_to_tags_action = QAction("Folders to Tags", menu_bar)
|
||||
folders_to_tags_action.triggered.connect(create_folders_tags_modal)
|
||||
ftt_modal = FoldersToTagsModal(self.lib, self)
|
||||
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show())
|
||||
macros_menu.addAction(folders_to_tags_action)
|
||||
|
||||
# Help Menu ============================================================
|
||||
@@ -640,9 +612,6 @@ class QtDriver(QObject):
|
||||
if self.args.ci:
|
||||
# gracefully terminate the app in CI environment
|
||||
self.thumb_job_queue.put((self.SIGTERM.emit, []))
|
||||
else:
|
||||
# Startup Checks
|
||||
self.check_ffmpeg()
|
||||
|
||||
app.exec()
|
||||
|
||||
@@ -856,10 +825,7 @@ class QtDriver(QObject):
|
||||
|
||||
def show_tag_database(self):
|
||||
self.modal = PanelModal(
|
||||
TagDatabasePanel(self.lib, self),
|
||||
"Library Tags",
|
||||
"Library Tags",
|
||||
has_save=False,
|
||||
TagDatabasePanel(self.lib), "Library Tags", "Library Tags", has_save=False
|
||||
)
|
||||
self.modal.show()
|
||||
|
||||
@@ -900,10 +866,7 @@ class QtDriver(QObject):
|
||||
pending.append(filepath)
|
||||
|
||||
if pending:
|
||||
return_code = self.delete_file_confirmation(len(pending), pending[0])
|
||||
logging.info(return_code)
|
||||
# If there was a confirmation and not a cancellation
|
||||
if return_code == 2 and return_code != 3:
|
||||
if self.delete_file_confirmation(len(pending), pending[0]) == 3:
|
||||
for i, f in enumerate(pending):
|
||||
if (origin_path == f) or (not origin_path):
|
||||
self.preview_panel.stop_file_use()
|
||||
@@ -920,23 +883,25 @@ class QtDriver(QObject):
|
||||
self.selected.clear()
|
||||
|
||||
if deleted_count > 0:
|
||||
self.refresh_frame(self.nav_frames[self.cur_frame_idx].contents)
|
||||
self.filter_items()
|
||||
self.preview_panel.update_widgets()
|
||||
|
||||
if len(self.selected) <= 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage("No files deleted.")
|
||||
self.main_window.statusbar.showMessage(
|
||||
"No files deleted. Check if any of the files are currently in use."
|
||||
)
|
||||
elif len(self.selected) <= 1 and deleted_count == 1:
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!")
|
||||
elif len(self.selected) > 1 and deleted_count == 0:
|
||||
self.main_window.statusbar.showMessage("No files deleted.")
|
||||
self.main_window.statusbar.showMessage(
|
||||
"No files deleted! Check if any of the files are currently in use."
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count < len(self.selected):
|
||||
self.main_window.statusbar.showMessage(
|
||||
f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently missing or in use."
|
||||
f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! Check if any of the files are currently in use"
|
||||
)
|
||||
elif len(self.selected) > 1 and deleted_count == len(self.selected):
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
|
||||
else:
|
||||
self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!")
|
||||
self.main_window.statusbar.repaint()
|
||||
|
||||
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
|
||||
@@ -947,18 +912,17 @@ class QtDriver(QObject):
|
||||
filename(Path | None): The filename to show if only one file is to be deleted.
|
||||
"""
|
||||
trash_term: str = "Trash"
|
||||
perm_warning: str = ""
|
||||
if platform.system() == "Windows":
|
||||
trash_term = "Recycle Bin"
|
||||
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
|
||||
# This is done without any warning, so this message is currently the best way I've got to inform the user.
|
||||
# https://github.com/arsenetar/send2trash/issues/28
|
||||
# This warning is applied to all platforms until at least macOS and Linux can be verified to
|
||||
# not exhibit this same behavior.
|
||||
perm_warning: str = (
|
||||
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
|
||||
f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, "
|
||||
f"</b>it will be <b>permanently deleted!</b></h4>"
|
||||
)
|
||||
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the Recycle Bin.
|
||||
# This is done without any warning, so this message is currently the best way I've got to inform the user.
|
||||
# https://github.com/arsenetar/send2trash/issues/28
|
||||
perm_warning = (
|
||||
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>"
|
||||
f"<b>WARNING!</b> If this file can't be moved to the Recycle Bin, "
|
||||
f"</b>it will be <b>permanently deleted!</b></h4>"
|
||||
)
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setTextFormat(Qt.TextFormat.RichText)
|
||||
@@ -977,10 +941,8 @@ class QtDriver(QObject):
|
||||
"<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>"
|
||||
f"{perm_warning}<br>"
|
||||
)
|
||||
|
||||
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
|
||||
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
|
||||
msg.setDefaultButton(yes_button)
|
||||
msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
|
||||
|
||||
return msg.exec()
|
||||
|
||||
@@ -1095,13 +1057,7 @@ class QtDriver(QObject):
|
||||
)
|
||||
)
|
||||
r = CustomRunnable(lambda: iterator.run())
|
||||
r.done.connect(
|
||||
lambda: (
|
||||
pw.hide(),
|
||||
pw.deleteLater(),
|
||||
self.filter_items(self.main_window.searchField.text()),
|
||||
)
|
||||
)
|
||||
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.filter_items("")))
|
||||
QThreadPool.globalInstance().start(r)
|
||||
|
||||
def new_file_macros_runnable(self, new_ids):
|
||||
@@ -1131,7 +1087,7 @@ class QtDriver(QObject):
|
||||
"""Runs a specific Macro on an Entry given a Macro name."""
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
path = self.lib.library_dir / entry.path / entry.filename
|
||||
source = "" if entry.path == Path(".") else entry.path.parts[0].lower()
|
||||
source = entry.path.parts[0]
|
||||
if name == "sidecar":
|
||||
self.lib.add_generic_data_to_entry(
|
||||
self.core.get_gdl_sidecar(path, source), entry_id
|
||||
@@ -1290,8 +1246,6 @@ class QtDriver(QObject):
|
||||
Args:
|
||||
index (int): The index of the item_thumbs/ComboBox list to use.
|
||||
"""
|
||||
SPACING_DIVISOR: int = 10
|
||||
MIN_SPACING: int = 12
|
||||
# Index 2 is the default (Medium)
|
||||
if index < len(self.thumb_sizes) and index >= 0:
|
||||
self.thumb_size = self.thumb_sizes[index][1]
|
||||
@@ -1310,9 +1264,7 @@ class QtDriver(QObject):
|
||||
it.setMinimumSize(self.thumb_size, self.thumb_size)
|
||||
it.setMaximumSize(self.thumb_size, self.thumb_size)
|
||||
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
|
||||
self.flow_container.layout().setSpacing(
|
||||
min(self.thumb_size // SPACING_DIVISOR, MIN_SPACING)
|
||||
)
|
||||
self.flow_container.layout().setSpacing(min(self.thumb_size // 10, 12))
|
||||
|
||||
def mouse_navigation(self, event: QMouseEvent):
|
||||
# print(event.button())
|
||||
@@ -1856,12 +1808,6 @@ class QtDriver(QObject):
|
||||
self.filter_items()
|
||||
self.main_window.toggle_landing_page(False)
|
||||
|
||||
def check_ffmpeg(self) -> None:
|
||||
"""Checks if FFmpeg is installed and displays a warning if not."""
|
||||
self.ffmpeg_checker = FfmpegChecker()
|
||||
if not self.ffmpeg_checker.installed():
|
||||
self.ffmpeg_checker.show_warning()
|
||||
|
||||
def create_collage(self) -> None:
|
||||
"""Generates and saves an image collage based on Library Entries."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,15 +12,25 @@ from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QThread,
|
||||
Signal,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QSize,
|
||||
QEvent,
|
||||
QTimer,
|
||||
QSettings,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
@@ -44,6 +54,7 @@ class CollageIconRenderer(QObject):
|
||||
):
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
@@ -75,11 +86,14 @@ class CollageIconRenderer(QObject):
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logging.info(
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{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()
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaType.IMAGE in MediaCategories.get_types(ext):
|
||||
try:
|
||||
@@ -95,6 +109,7 @@ class CollageIconRenderer(QObject):
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
@@ -106,22 +121,12 @@ class CollageIconRenderer(QObject):
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
MAX_FRAME_SEEK: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
MAX_FRAME_SEEK,
|
||||
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
|
||||
),
|
||||
):
|
||||
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()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
@@ -132,9 +137,12 @@ class CollageIconRenderer(QObject):
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}")
|
||||
logging.info(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
with Image.open(
|
||||
str(
|
||||
Path(__file__).parents[2]
|
||||
@@ -145,16 +153,22 @@ class CollageIconRenderer(QObject):
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except KeyboardInterrupt:
|
||||
# self.quit(save=False, backup=True)
|
||||
run = False
|
||||
# clear()
|
||||
logging.info("\n")
|
||||
logging.info(f"{INFO} Collage operation cancelled.")
|
||||
except Exception:
|
||||
logging.info(f"{ERROR} {entry.path}/{entry.filename}")
|
||||
clear_scr = False
|
||||
except:
|
||||
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
|
||||
traceback.print_exc()
|
||||
logging.info("Continuing...")
|
||||
|
||||
self.done.emit()
|
||||
# logging.info('Done!')
|
||||
|
||||
# NOTE: Depreciated
|
||||
def get_file_color(self, ext: str):
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType, MethodType
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast, Callable
|
||||
from typing import Optional, cast, Callable, Any
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.color_overlay import theme_fg_overlay
|
||||
|
||||
@@ -35,17 +36,17 @@ class FieldContainer(QWidget):
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
# self.mode:str = mode
|
||||
self.setObjectName("fieldContainer")
|
||||
# self.item = item
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
# self.editable:bool = editable
|
||||
self.copy_callback: FunctionType = None
|
||||
self.edit_callback: FunctionType = None
|
||||
self.remove_callback: Callable = None
|
||||
button_size = 24
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
|
||||
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
@@ -54,6 +55,7 @@ class FieldContainer(QWidget):
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
@@ -65,6 +67,7 @@ class FieldContainer(QWidget):
|
||||
self.root_layout.addWidget(self.inner_container)
|
||||
|
||||
self.title_container = QWidget()
|
||||
# self.title_container.setStyleSheet('background:black;')
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_layout.setObjectName("fieldLayout")
|
||||
@@ -77,7 +80,9 @@ class FieldContainer(QWidget):
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
# self.title_widget.setStyleSheet('background-color:orange;')
|
||||
self.title_widget.setText(title)
|
||||
# self.inner_layout.addWidget(self.title_widget)
|
||||
self.title_layout.addWidget(self.title_widget)
|
||||
|
||||
self.title_layout.addStretch(2)
|
||||
@@ -119,8 +124,11 @@ class FieldContainer(QWidget):
|
||||
self.field_layout.setObjectName("fieldLayout")
|
||||
self.field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.field_container.setLayout(self.field_layout)
|
||||
# self.field_container.setStyleSheet('background-color:#666600;')
|
||||
self.inner_layout.addWidget(self.field_container)
|
||||
|
||||
# self.set_inner_widget(mode)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
if self.copy_button.is_connected:
|
||||
self.copy_button.clicked.disconnect()
|
||||
@@ -152,7 +160,12 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.is_connected = True
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
# widget.setStyleSheet('background-color:green;')
|
||||
# self.inner_container.dumpObjectTree()
|
||||
# logging.info('')
|
||||
if self.field_layout.itemAt(0):
|
||||
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
|
||||
# self.field_layout.removeItem(self.field_layout.itemAt(0))
|
||||
self.field_layout.itemAt(0).widget().deleteLater()
|
||||
self.field_layout.addWidget(widget)
|
||||
|
||||
@@ -168,7 +181,12 @@ class FieldContainer(QWidget):
|
||||
def set_inline(self, inline: bool):
|
||||
self.inline = inline
|
||||
|
||||
# def set_editable(self, editable:bool):
|
||||
# self.editable = editable
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# if self.field_layout.itemAt(1):
|
||||
# self.field_layout.itemAt(1).
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(False)
|
||||
@@ -193,4 +211,5 @@ class FieldWidget(QWidget):
|
||||
|
||||
def __init__(self, title) -> None:
|
||||
super().__init__()
|
||||
# self.item = item
|
||||
self.title = title
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import typing
|
||||
from pathlib import Path
|
||||
@@ -197,16 +196,7 @@ class ItemThumb(FlowWidget):
|
||||
self.opener = FileOpenerHelper("")
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
|
||||
system = platform.system()
|
||||
open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default (mainly going to be for linux)
|
||||
if system == "Darwin":
|
||||
open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif system == "Windows":
|
||||
open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
open_explorer_action = QAction("Open file in explorer", self)
|
||||
open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
trash_term: str = "Trash"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import time
|
||||
@@ -99,35 +98,13 @@ class PreviewPanel(QWidget):
|
||||
image_layout = QHBoxLayout(self.image_container)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
file_label_style = "font-size: 12px"
|
||||
properties_style = (
|
||||
f"background-color:{self.label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
date_style = "font-size:12px;"
|
||||
|
||||
self.open_file_action = QAction("Open file", self)
|
||||
self.open_explorer_action = QAction("Open file in explorer", self)
|
||||
self.trash_term: str = "Trash"
|
||||
if platform.system() == "Windows":
|
||||
self.trash_term = "Recycle Bin"
|
||||
self.delete_action = QAction(f"Send file to {self.trash_term}", self)
|
||||
|
||||
self.open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default text (Linux, etc.)
|
||||
if platform.system() == "Darwin":
|
||||
self.open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif platform.system() == "Windows":
|
||||
self.open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
self.preview_img = QPushButtonWrapper()
|
||||
self.preview_img.setMinimumSize(*self.img_button_size)
|
||||
self.preview_img.setFlat(True)
|
||||
@@ -173,26 +150,31 @@ class PreviewPanel(QWidget):
|
||||
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.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.file_label = FileOpenerLabel("Filename")
|
||||
self.file_label.setWordWrap(True)
|
||||
self.file_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
self.file_label.setStyleSheet(file_label_style)
|
||||
self.file_label.setStyleSheet("font-weight: bold; font-size: 12px")
|
||||
|
||||
self.date_created_label = QLabel("dateCreatedLabel")
|
||||
self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.date_created_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.date_created_label.setStyleSheet(date_style)
|
||||
|
||||
self.date_modified_label = QLabel("dateModifiedLabel")
|
||||
self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.date_modified_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.date_modified_label.setStyleSheet(date_style)
|
||||
|
||||
self.dimensions_label = QLabel("dimensionsLabel")
|
||||
self.dimensions_label = QLabel("Dimensions")
|
||||
self.dimensions_label.setWordWrap(True)
|
||||
# self.dim_label.setTextInteractionFlags(
|
||||
# Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
properties_style = (
|
||||
f"background-color:{self.label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
self.dimensions_label.setStyleSheet(properties_style)
|
||||
|
||||
self.scroll_layout = QVBoxLayout()
|
||||
@@ -230,15 +212,7 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
scroll_area.setWidget(scroll_container)
|
||||
|
||||
date_container = QWidget()
|
||||
date_layout = QVBoxLayout(date_container)
|
||||
date_layout.setContentsMargins(0, 2, 0, 0)
|
||||
date_layout.setSpacing(0)
|
||||
date_layout.addWidget(self.date_created_label)
|
||||
date_layout.addWidget(self.date_modified_label)
|
||||
|
||||
info_layout.addWidget(self.file_label)
|
||||
info_layout.addWidget(date_container)
|
||||
info_layout.addWidget(self.dimensions_label)
|
||||
info_layout.addWidget(scroll_area)
|
||||
|
||||
@@ -489,32 +463,6 @@ class PreviewPanel(QWidget):
|
||||
for field_item in field_list:
|
||||
self.lib.add_field_to_entry(item_id, field_item.row())
|
||||
|
||||
def update_date_label(self, filepath: Path | None = None) -> None:
|
||||
"""Update the "Date Created" and "Date Modified" file property labels."""
|
||||
if filepath and filepath.is_file():
|
||||
created: dt = None
|
||||
if platform.system() == "Windows" or platform.system() == "Darwin":
|
||||
created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined]
|
||||
else:
|
||||
created = dt.fromtimestamp(filepath.stat().st_ctime)
|
||||
modified: dt = dt.fromtimestamp(filepath.stat().st_mtime)
|
||||
self.date_created_label.setText(
|
||||
f"<b>Date Created:</b> {dt.strftime(created, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_modified_label.setText(
|
||||
f"<b>Date Modified:</b> {dt.strftime(modified, "%a, %x, %X")}"
|
||||
)
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
elif filepath:
|
||||
self.date_created_label.setText("<b>Date Created:</b> <i>N/A</i>")
|
||||
self.date_modified_label.setText("<b>Date Modified:</b> <i>N/A</i>")
|
||||
self.date_created_label.setHidden(False)
|
||||
self.date_modified_label.setHidden(False)
|
||||
else:
|
||||
self.date_created_label.setHidden(True)
|
||||
self.date_modified_label.setHidden(True)
|
||||
|
||||
# def update_widgets(self, item: Union[Entry, Collation, Tag]):
|
||||
def update_widgets(self):
|
||||
"""
|
||||
@@ -531,12 +479,11 @@ class PreviewPanel(QWidget):
|
||||
# 0 Selected Items
|
||||
if not self.driver.selected:
|
||||
if self.selected or not self.initialized:
|
||||
self.file_label.setText("<i>No Items Selected</i>")
|
||||
self.file_label.setText("No Items Selected")
|
||||
self.file_label.setFilePath("")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
self.dimensions_label.setText("")
|
||||
self.update_date_label()
|
||||
self.preview_img.setContextMenuPolicy(
|
||||
Qt.ContextMenuPolicy.NoContextMenu
|
||||
)
|
||||
@@ -592,17 +539,7 @@ class PreviewPanel(QWidget):
|
||||
ratio,
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
file_str: str = ""
|
||||
separator: str = (
|
||||
f"<a style='color: #777777'><b>{os.path.sep}</a>" # Gray
|
||||
)
|
||||
for i, part in enumerate(filepath.parts):
|
||||
part_ = part.strip(os.path.sep)
|
||||
if i != len(filepath.parts) - 1:
|
||||
file_str += f"{"\u200b".join(part_)}{separator}</b>"
|
||||
else:
|
||||
file_str += f"<br><b>{"\u200b".join(part_)}</b>"
|
||||
self.file_label.setText(file_str)
|
||||
self.file_label.setText("\u200b".join(str(filepath)))
|
||||
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.preview_img.setContextMenuPolicy(
|
||||
@@ -731,7 +668,6 @@ class PreviewPanel(QWidget):
|
||||
self.dimensions_label.setText(
|
||||
f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}"
|
||||
)
|
||||
self.update_date_label(filepath)
|
||||
|
||||
if not filepath.is_file():
|
||||
raise FileNotFoundError
|
||||
@@ -741,14 +677,12 @@ class PreviewPanel(QWidget):
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label()
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label()
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
@@ -759,7 +693,6 @@ class PreviewPanel(QWidget):
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
self.update_date_label(filepath)
|
||||
|
||||
# TODO: Implement a clickable label to use for the GIF preview.
|
||||
if self.preview_img.is_connected:
|
||||
@@ -795,11 +728,8 @@ class PreviewPanel(QWidget):
|
||||
self.preview_gif.hide()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.update_date_label()
|
||||
if self.selected != self.driver.selected:
|
||||
self.file_label.setText(
|
||||
f"<b>{len(self.driver.selected)}</b> Items Selected"
|
||||
)
|
||||
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
|
||||
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.file_label.setFilePath("")
|
||||
self.dimensions_label.setText("")
|
||||
|
||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
|
||||
|
||||
from src.core.library import Library, Tag
|
||||
from src.core.palette import ColorType, get_tag_color
|
||||
from src.qt.modals.merge_tag import MergeTagModal
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
@@ -74,6 +75,12 @@ class TagWidget(QWidget):
|
||||
# search_for_tag_action.triggered.connect(on_click_callback)
|
||||
search_for_tag_action.triggered.connect(self.on_click.emit)
|
||||
self.bg_button.addAction(search_for_tag_action)
|
||||
add_to_search_action = QAction("Add to Search", self)
|
||||
self.bg_button.addAction(add_to_search_action)
|
||||
|
||||
merge_tag_action = QAction("Merge Tag", self)
|
||||
merge_tag_action.triggered.connect(self.show_merge_tag_modal)
|
||||
self.bg_button.addAction(merge_tag_action)
|
||||
|
||||
self.inner_layout = QHBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
@@ -232,6 +239,9 @@ class TagWidget(QWidget):
|
||||
# pass
|
||||
# # self.bg.clicked.connect(lambda checked=False, filepath=filepath: open_file(filepath))
|
||||
# # self.bg.clicked.connect(function)
|
||||
def show_merge_tag_modal(self):
|
||||
modal = MergeTagModal(self.lib, self.tag)
|
||||
modal.exec_()
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
if self.has_remove:
|
||||
|
||||
@@ -93,7 +93,7 @@ class TagBoxWidget(FieldWidget):
|
||||
f"}}"
|
||||
)
|
||||
tsp = TagSearchPanel(self.lib)
|
||||
tsp.tag_created.connect(lambda x: self.add_tag_callback(x))
|
||||
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()) # type: ignore
|
||||
|
||||
@@ -8,7 +8,6 @@ import math
|
||||
from copy import deepcopy
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -27,8 +26,7 @@ from PIL import (
|
||||
)
|
||||
from PIL.Image import DecompressionBombError
|
||||
from pillow_heif import register_avif_opener, register_heif_opener
|
||||
from pydub import exceptions
|
||||
from src.qt.helpers.vendored.pydub.audio_segment import _AudioSegment as AudioSegment # type: ignore
|
||||
from pydub import AudioSegment, exceptions
|
||||
from PySide6.QtCore import QObject, QSize, Qt, Signal
|
||||
from PySide6.QtGui import QGuiApplication, QPixmap
|
||||
from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
|
||||
@@ -41,7 +39,6 @@ from src.qt.helpers.file_tester import is_readable_video
|
||||
from src.qt.helpers.gradient import four_corner_gradient
|
||||
from src.qt.helpers.text_wrapper import wrap_full_text
|
||||
from src.qt.resource_manager import ResourceManager
|
||||
from vtf2img import Parser
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
@@ -57,6 +54,13 @@ class ThumbRenderer(QObject):
|
||||
updated = Signal(float, QPixmap, QSize, str)
|
||||
updated_ratio = Signal(float)
|
||||
|
||||
# TODO: Make dynamic font sizes given different pixel ratios
|
||||
FONT_PIXEL_RATIO: float = 1
|
||||
ext_font = ImageFont.truetype(
|
||||
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
|
||||
math.floor(12 * FONT_PIXEL_RATIO),
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the class."""
|
||||
super().__init__()
|
||||
@@ -108,10 +112,9 @@ class ThumbRenderer(QObject):
|
||||
pixel_ratio (float): The screen pixel ratio.
|
||||
scale_radius (bool): Option to scale the radius up (Used for Preview Panel).
|
||||
"""
|
||||
THUMB_SCALE: int = 512
|
||||
radius_scale: float = 1
|
||||
if scale_radius:
|
||||
radius_scale = max(size[0], size[1]) / THUMB_SCALE
|
||||
radius_scale = max(size[0], size[1]) / 512
|
||||
|
||||
item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale))
|
||||
if not item:
|
||||
@@ -410,9 +413,9 @@ class ThumbRenderer(QObject):
|
||||
faded (bool): Whether or not to apply a faded version of the edge.
|
||||
Used for light themes.
|
||||
"""
|
||||
opacity: float = 1.0 if not faded else 0.8
|
||||
opacity: float = 0.75 if not faded else 0.6
|
||||
shade_reduction: float = (
|
||||
0
|
||||
0.15
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else 0.3
|
||||
)
|
||||
@@ -565,7 +568,6 @@ class ThumbRenderer(QObject):
|
||||
logging.error(
|
||||
f"[ThumbRenderer][WAVEFORM][ERROR]: Couldn't render waveform for {filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
|
||||
return im
|
||||
|
||||
def _blender(self, filepath: Path) -> Image.Image:
|
||||
@@ -604,33 +606,6 @@ class ThumbRenderer(QObject):
|
||||
)
|
||||
return im
|
||||
|
||||
def _source_engine(self, filepath: Path) -> Image.Image:
|
||||
"""
|
||||
This is a function to convert the VTF (Valve Texture Format) files to thumbnails, it works using the VTF2IMG library for PILLOW.
|
||||
"""
|
||||
parser = Parser(filepath)
|
||||
im: Image.Image = None
|
||||
try:
|
||||
im = parser.get_image()
|
||||
|
||||
except (
|
||||
AttributeError,
|
||||
UnidentifiedImageError,
|
||||
FileNotFoundError,
|
||||
TypeError,
|
||||
struct.error,
|
||||
) as e:
|
||||
if str(e) == "expected string or buffer":
|
||||
logging.info(
|
||||
f"[ThumbRenderer][VTF][INFO] {filepath.name} Doesn't have an embedded thumbnail. ({type(e).__name__})"
|
||||
)
|
||||
|
||||
else:
|
||||
logging.error(
|
||||
f"[ThumbRenderer][VTF][ERROR]: Couldn't render thumbnail for {filepath.name} ({type(e).__name__})"
|
||||
)
|
||||
return im
|
||||
|
||||
def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image:
|
||||
"""Render a small font preview ("Aa") thumbnail from a font file.
|
||||
|
||||
@@ -882,21 +857,12 @@ class ThumbRenderer(QObject):
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
# NOTE: Depending on the video format, compression, and
|
||||
# frame count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
MAX_FRAME_SEEK: int = 10
|
||||
for i in range(
|
||||
0,
|
||||
min(
|
||||
MAX_FRAME_SEEK, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
),
|
||||
):
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
else:
|
||||
break
|
||||
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)
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
im = Image.fromarray(frame)
|
||||
except (
|
||||
@@ -951,6 +917,13 @@ class ThumbRenderer(QObject):
|
||||
"thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio
|
||||
)
|
||||
|
||||
if ThumbRenderer.FONT_PIXEL_RATIO != pixel_ratio:
|
||||
ThumbRenderer.FONT_PIXEL_RATIO = pixel_ratio
|
||||
ThumbRenderer.ext_font = ImageFont.truetype(
|
||||
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
|
||||
math.floor(12 * ThumbRenderer.FONT_PIXEL_RATIO),
|
||||
)
|
||||
|
||||
if is_loading:
|
||||
final = loading_thumb.resize(
|
||||
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
|
||||
@@ -1001,14 +974,8 @@ class ThumbRenderer(QObject):
|
||||
elif MediaType.BLENDER in MediaCategories.get_types(ext):
|
||||
image = self._blender(_filepath)
|
||||
|
||||
# VTF ==========================================================
|
||||
elif MediaType.SOURCE_ENGINE in MediaCategories.get_types(ext):
|
||||
image = self._source_engine(_filepath)
|
||||
|
||||
# No Rendered Thumbnail ========================================
|
||||
if not _filepath.exists():
|
||||
raise FileNotFoundError
|
||||
elif not image:
|
||||
if not image:
|
||||
raise UnidentifiedImageError
|
||||
|
||||
orig_x, orig_y = image.size
|
||||
@@ -1061,8 +1028,6 @@ class ThumbRenderer(QObject):
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
DecompressionBombError,
|
||||
ValueError,
|
||||
ChildProcessError,
|
||||
) as e:
|
||||
logging.info(
|
||||
f"[ThumbRenderer][ERROR]: Couldn't render thumbnail for {_filepath.name} ({type(e).__name__})"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import typing
|
||||
|
||||
from PySide6.QtCore import (
|
||||
@@ -82,8 +81,6 @@ class VideoPlayer(QGraphicsView):
|
||||
self.scene().addItem(self.video_preview)
|
||||
self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
|
||||
self.setStyleSheet("border-style:solid;border-width:0px;")
|
||||
|
||||
# Set up the video tint.
|
||||
self.video_tint = self.scene().addRect(
|
||||
0,
|
||||
@@ -132,16 +129,7 @@ class VideoPlayer(QGraphicsView):
|
||||
|
||||
open_file_action = QAction("Open file", self)
|
||||
open_file_action.triggered.connect(self.opener.open_file)
|
||||
|
||||
system = platform.system()
|
||||
open_explorer_action = QAction(
|
||||
"Open in explorer", self
|
||||
) # Default (mainly going to be for linux)
|
||||
if system == "Darwin":
|
||||
open_explorer_action = QAction("Reveal in Finder", self)
|
||||
elif system == "Windows":
|
||||
open_explorer_action = QAction("Open in Explorer", self)
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user