diff --git a/NEWS.md b/NEWS.md
index 625a8bdda..89d69632f 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -6,6 +6,20 @@ width: 128px;
border-radius: 128px;
" />
+## v2.5.6
+
+- Features
+ - Filter, search, view, edit, and online tag by series years, series status,
+ and series summary. End year, status and summary are saved in Codex only,
+ not comic metadata.
+ - Browser title shows series years and a Tags button.
+ - Online tag match picker shows series and issue details to help
+ disambiguate similar covers and titles.
+- Fixes
+ - Browsing comics without embedded tags, or re-importing unchanged comics,
+ no longer rebuilds their covers and reloads the page.
+ - Searches for a universe designation no longer miss imported comics.
+
## v2.5.5
- Features
diff --git a/bin/manage.py b/bin/manage.py
index 47c71ced3..730a49c31 100755
--- a/bin/manage.py
+++ b/bin/manage.py
@@ -48,6 +48,11 @@ def _discover() -> str:
def main():
"""Run the server."""
+ # Python puts the script's own bin/ first on sys.path, not the root, so
+ # the project would come from whatever copy the venv installed (another
+ # worktree's editable install, say). Put the root first, as a root-level
+ # manage.py gets for free.
+ sys.path.insert(0, str(ROOT))
# Not setdefault: its argument runs first, and _discover() raises in a
# project with two settings modules even when the variable picks one.
if not os.environ.get("DJANGO_SETTINGS_MODULE"):
diff --git a/bun.lock b/bun.lock
index ea223047a..2f2d14e3c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -23,7 +23,7 @@
"eslint-plugin-no-secrets": "^2.3.3",
"eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-no-use-extend-native": "^0.7.3",
- "eslint-plugin-perfectionist": "^5.12.1",
+ "eslint-plugin-perfectionist": "^5.13.0",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-regexp": "^3.3.1",
@@ -39,7 +39,7 @@
"prettier": "^3.9.10",
"prettier-plugin-nginx": "^2.0.0",
"prettier-plugin-packagejson": "^3.0.2",
- "prettier-plugin-toml": "^4.0.0",
+ "prettier-plugin-toml": "^4.1.0",
"svgo": "^4.1.0",
},
},
@@ -124,23 +124,51 @@
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
- "@tombi-toml/lib": ["@tombi-toml/lib@1.8.8", "", { "optionalDependencies": { "@tombi-toml/lib-darwin-arm64": "1.8.8", "@tombi-toml/lib-darwin-x64": "1.8.8", "@tombi-toml/lib-linux-arm64": "1.8.8", "@tombi-toml/lib-linux-arm64-musl": "1.8.8", "@tombi-toml/lib-linux-x64": "1.8.8", "@tombi-toml/lib-linux-x64-musl": "1.8.8", "@tombi-toml/lib-win32-arm64": "1.8.8", "@tombi-toml/lib-win32-x64": "1.8.8" } }, "sha512-LhXK7qS7wd834bRmJRcVmPHu4hzPmelgWfIMTCs8sXrkIqHRmLYzvxfXbSbmAN89gOouBxgiSekZ9y8HL+uoZQ=="],
+ "@tombit/binding-android-arm-eabi": ["@tombit/binding-android-arm-eabi@0.1.5", "", { "os": "android", "cpu": "arm" }, "sha512-3JH3GZbVTTekI+9gMimjK+6iFIO63F6apfhnKAenFfmoXHPR2kvkL65AawggWMFQ62IPU5HGU1lrJSneQwKioA=="],
- "@tombi-toml/lib-darwin-arm64": ["@tombi-toml/lib-darwin-arm64@1.8.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u1A5blxDS+8wTKz84N++uH5Cr+M5Msh3fC5Vwurdk3Zi9jBsvGLgy3EiIvUD/q6li8JrgUdKWsp8c1qKe3uhoA=="],
+ "@tombit/binding-android-arm64": ["@tombit/binding-android-arm64@0.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-iUmxa+OBE5gY/uFzBt4AOjWI1YB7tjnxbIGYpcRMaso8ftwcVigExiLzikUHQQS7AmqtOIdHM6v6QcU9+wg/qQ=="],
- "@tombi-toml/lib-darwin-x64": ["@tombi-toml/lib-darwin-x64@1.8.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-y5rBcy9pqRmdFUEZ5TrSNbu/OQMFlS4vNJ9wMABe3U4i1A9cSEOfsTU39Cs0AB8dBZz93u9BRFEQeWaGBVqgpQ=="],
+ "@tombit/binding-darwin-arm64": ["@tombit/binding-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-x7LRqoGuvV/cfSVQrc06BPk0S6CwRZkKzvHguGFXsAIyGBfUA9DHTvOI5WSJKrz788F9CSWiKjLxey+zEj1wYg=="],
- "@tombi-toml/lib-linux-arm64": ["@tombi-toml/lib-linux-arm64@1.8.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-uz/+hG/oGiD1hNkpqOIulY5NDN100aOa7aIcEzXWhE4RKdqXuQTQpfd/OGssNMu/YXT/RRT1wKIi1DX7WPzO2w=="],
+ "@tombit/binding-darwin-x64": ["@tombit/binding-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-+LValJITWhuNFQEfP62a1Rl/idbengROFIH6Xwcpw1YSXnyk9DjTpiXz2mxJ1JofIxU9QbsBLXlF76sYxwNIMA=="],
- "@tombi-toml/lib-linux-arm64-musl": ["@tombi-toml/lib-linux-arm64-musl@1.8.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/ZSds0WqX+P2tV5d52e/qOfDvvk4u7tg/pXA9qJPavxcejS6LZAdI7KgCGA0qZfRcIux7KgvFxiowbQ+yv3+w=="],
+ "@tombit/binding-freebsd-x64": ["@tombit/binding-freebsd-x64@0.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bSt6UHk4L12iVwZVXgdu8zy9DggsWMTug1Td5+ila8GOlUpTzd+7mgqx9054TLjO/ilsc21jLisU8/qCbfi3jQ=="],
- "@tombi-toml/lib-linux-x64": ["@tombi-toml/lib-linux-x64@1.8.8", "", { "os": "linux", "cpu": "x64" }, "sha512-GFxfMw1WhUvo3PO2lKKr0I1/oejBbHLTAqYp4hamX3iKMQO8reIs/f4BWu8tyFtrkLy52WcULm/PoqCTtJzJiw=="],
+ "@tombit/binding-linux-arm-gnueabihf": ["@tombit/binding-linux-arm-gnueabihf@0.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-5AfdesZufQNvdnDfVutAdY+MNdaD8E8n31iuoMXzaFUxQYsv6m2Ch3MP86k3vfJRMT6KUMuHC0ZiyehPbxGL+g=="],
- "@tombi-toml/lib-linux-x64-musl": ["@tombi-toml/lib-linux-x64-musl@1.8.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Jvot8wP4Vz+3rrL35EDgJx13/oj0B5LxN3cPc5o7VIafqDxOOSNqJsxc7c4Y4AKaXY37NtHBDaKIzNXjnhoyJg=="],
+ "@tombit/binding-linux-arm-musleabihf": ["@tombit/binding-linux-arm-musleabihf@0.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-pqq0H6zyV1RLHLLSv5f/5McZ6Kwc2sM426KJJEuN/hq6wwNQ/9wdPYif8HIfTbOs2OxXmDkTCMcbXRM2O9Zkyw=="],
- "@tombi-toml/lib-win32-arm64": ["@tombi-toml/lib-win32-arm64@1.8.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-gtuuH1dbXghaq6h63jn6YojYOvwndeUHmKRv+cHHIhfVcs3obF7EMgDKfeTy0H/nla8ojM2ZUh+XheqWcJZisA=="],
+ "@tombit/binding-linux-arm64-gnu": ["@tombit/binding-linux-arm64-gnu@0.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-NYdPD51rnPrwrQFk66vk6MDm1EdBzAgRtmk37IpGi2sRDCzemyFSk3D/E4mgMvk1eFzqACa4LBcjpfxCU/4Qzg=="],
- "@tombi-toml/lib-win32-x64": ["@tombi-toml/lib-win32-x64@1.8.8", "", { "os": "win32", "cpu": "x64" }, "sha512-QyCwnMFurpZ+kxafZvB+7zFPi18lkl4KuMORNqgDlPUWx5/uuU+h1LK50rTv8SwQCLwyTVOupZYokWlHf/bO6Q=="],
+ "@tombit/binding-linux-arm64-musl": ["@tombit/binding-linux-arm64-musl@0.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VTTHJQVJHXAvbUqiAIZsDGBrW6wCXYk/XL4FR1XKFBLww1rAqaP1TNqk7Yf33IFnLFlPwZCiWB3E6b1usRGwEA=="],
+
+ "@tombit/binding-linux-loong64-gnu": ["@tombit/binding-linux-loong64-gnu@0.1.5", "", { "os": "linux", "cpu": "none" }, "sha512-6R2YxCBcA1PBT0aYBeXKDA8f7EY9h4flPlHUjpBDDW1mUDnJueJ78IlvmEogWA8mwg1C+jq+dMjC8tGR1L0tew=="],
+
+ "@tombit/binding-linux-loong64-musl": ["@tombit/binding-linux-loong64-musl@0.1.5", "", { "os": "linux", "cpu": "none" }, "sha512-Qh58HFWqQtfZvTuLMU3L0sjY2QWJ+4sY0m8EVYBxmHJzedFiWnm/eUULIzbS5GxDQ89J9TZTD0Ma0xaerlfwnw=="],
+
+ "@tombit/binding-linux-ppc64-gnu": ["@tombit/binding-linux-ppc64-gnu@0.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fI4BaLPZqm3hEMpVDR0atameS7WXP69pW+Se81+UL3c59v4kuZ9ezbUQxB0EtEwLilfq/Ia7boS699GhEHk41A=="],
+
+ "@tombit/binding-linux-ppc64-musl": ["@tombit/binding-linux-ppc64-musl@0.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-l0cIwDJvaVICrVuBxkc8/9MxPX7qXNvFqSUnIXIT/RvG9RVNiopG9yt4JOiIroxdd51UEpLO/rw1EyEH/KTtsg=="],
+
+ "@tombit/binding-linux-riscv64-gnu": ["@tombit/binding-linux-riscv64-gnu@0.1.5", "", { "os": "linux", "cpu": "none" }, "sha512-1cRv4TpoRWN+L3my+wab55EfQKnepo/1Xabv9g6wCjtgeKDqAUy9ALrD4Fg7rNtMCI214dU1EIEjlRyBJaZ0pw=="],
+
+ "@tombit/binding-linux-riscv64-musl": ["@tombit/binding-linux-riscv64-musl@0.1.5", "", { "os": "linux", "cpu": "none" }, "sha512-3YDRSyH15ZEcv5l8GyrUcvYuS2PNs2tAL/ZAcC24rofGwRvaorWLSgQ0NekZVrfNp/+4k+Pxam7sNDIIukhcgw=="],
+
+ "@tombit/binding-linux-s390x-gnu": ["@tombit/binding-linux-s390x-gnu@0.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-Gk/M/EF8uBZkcgD3maMSEVEQDuHsAPf2Bu4uLJgYQsftZZIEMsCnsYtIeH3WAmdizR5Wg+IcCOXBcmv71703NQ=="],
+
+ "@tombit/binding-linux-x64-gnu": ["@tombit/binding-linux-x64-gnu@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-aFz1BX46waZWrBYUappjHssrD3FL00IKuzk+kqFBhP9etdXZGTxDtnA5pa4+bDwVgHHzJYumEtwguSoH3VqvcA=="],
+
+ "@tombit/binding-linux-x64-musl": ["@tombit/binding-linux-x64-musl@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-PSUZqzy9tHkoBOFTiC6Vzv/RzuKo6D3rhgH8xWV91frPODC3zB/rR2KPxHv4n+meS9tCyKHiVEBJLTWxOGf3zA=="],
+
+ "@tombit/binding-openharmony-arm64": ["@tombit/binding-openharmony-arm64@0.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-ZGW6BjXimOBfWgvKQ+llJhOvKQONE6Q4hhYMvw6IUec+U+ScTnWGRckhfzuu3VSdPMH5vSPcvGlUlajMf9JB7A=="],
+
+ "@tombit/binding-openharmony-x64": ["@tombit/binding-openharmony-x64@0.1.5", "", { "os": "none", "cpu": "x64" }, "sha512-6+3JcVPYIbMfBgqTCZXkjQ9me9IpIoxHuJQGIvmAp7/QZBDhBihk8YF3uCX+t7ZB2ClDtIWXRaBaAJK2XPTYIg=="],
+
+ "@tombit/binding-win32-arm64-msvc": ["@tombit/binding-win32-arm64-msvc@0.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-30u9y6bONbeMsxpI2ikbtzfSZa5n/EG3jO1VTuNqz319obe9MK+3nj+BQz3avYLjDq5uMszCYSMm7yQK2vaYoA=="],
+
+ "@tombit/binding-win32-ia32-msvc": ["@tombit/binding-win32-ia32-msvc@0.1.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-fyrqcLVIjL0b+AXLK3gj+IQewbkYxihBJdG5k2INKgAzHB6kfBlf0/HtXTbPeqe72LYeAt/nBnX5IPfcpKqA8Q=="],
+
+ "@tombit/binding-win32-x64-msvc": ["@tombit/binding-win32-x64-msvc@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-EOIixNDdVxMgJzqViUXYzZIoXEteZvvftW+PxS5TSd+lCVMED2jW3R6uziYC61aY60hLBrhVFSs3J4MJ6DXv/A=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
@@ -162,19 +190,19 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
- "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.71.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.71.2", "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw=="],
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.72.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.72.0", "@typescript-eslint/types": "^8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ=="],
- "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.71.2", "", { "dependencies": { "@typescript-eslint/types": "8.71.2", "@typescript-eslint/visitor-keys": "8.71.2" } }, "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg=="],
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0" } }, "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA=="],
- "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.71.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg=="],
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.72.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA=="],
- "@typescript-eslint/types": ["@typescript-eslint/types@8.71.2", "", {}, "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw=="],
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.72.0", "", {}, "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ=="],
- "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.71.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.71.2", "@typescript-eslint/tsconfig-utils": "8.71.2", "@typescript-eslint/types": "8.71.2", "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA=="],
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.72.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.72.0", "@typescript-eslint/tsconfig-utils": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug=="],
- "@typescript-eslint/utils": ["@typescript-eslint/utils@8.71.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.71.2", "@typescript-eslint/types": "8.71.2", "@typescript-eslint/typescript-estree": "8.71.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw=="],
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.72.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg=="],
- "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.71.2", "", { "dependencies": { "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg=="],
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="],
@@ -234,13 +262,13 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.11.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
- "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="],
+ "browserslist": ["browserslist@4.29.4", "", { "dependencies": { "baseline-browser-mapping": "^2.11.29", "caniuse-lite": "^1.0.30001816", "electron-to-chromium": "^1.5.459", "node-releases": "^2.0.59", "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA=="],
"builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="],
@@ -248,7 +276,7 @@
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001816", "", {}, "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001818", "", {}, "sha512-XJbgo4adscw7R4MZU4/qVFkoMyPaNUAWmdNY4o/k9x7fFwmv4frIlndI9lWuaxviW9ObiZel9LuncnQPn6hQ5A=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -264,7 +292,7 @@
"comment-parser": ["comment-parser@1.4.9", "", {}, "sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ=="],
- "core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
+ "core-js-compat": ["core-js-compat@3.51.0", "", { "dependencies": { "browserslist": "^4.29.3" } }, "sha512-52r6fkXgxv4GLMjmFcMWRx7X9bw071xTTdrDcdyPGRqU2oE+9pCG0bAOMifSBdLw0JRBZKEF0q3B55qPlLuE8g=="],
"core_d": ["core_d@6.1.1", "", { "dependencies": { "supports-color": "^8.1.0" } }, "sha512-96Pba8AFV6AjBwOz0PxHXkKm4CzmrNA2Im8NlPCsi70OefzgXxw3VDuKIMC18SXV1GF2oyNfIdh3w+JaNkSCCA=="],
@@ -304,7 +332,7 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.459", "", {}, "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.463", "", {}, "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w=="],
"empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="],
@@ -340,7 +368,7 @@
"eslint-plugin-no-use-extend-native": ["eslint-plugin-no-use-extend-native@0.7.3", "", { "dependencies": { "is-get-set-prop": "^2.0.0", "is-js-type": "^3.0.0", "is-obj-prop": "^2.0.0", "is-proto-prop": "^3.0.1" }, "peerDependencies": { "eslint": "^9.3.0 || ^10.0.0" } }, "sha512-kYJhgZkiZIavu/wIwrO+n4GemQcMX53kWCNZNr7nGMkRD1aBFLkDpBivEYP7nIJINCo9fzPbFjrpeX5kr2Qbww=="],
- "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.12.1", "", { "dependencies": { "@typescript-eslint/types": "^8.70.0", "@typescript-eslint/utils": "^8.70.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-krdSE7Dz+TCtcVr2+rfNhlOIFFBJcUdoRvjaN7JQnsaVnxJw+BZh9wMkY3MSLCJat+yW+UPTQLKS82Epc7MGZA=="],
+ "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.13.0", "", { "dependencies": { "@typescript-eslint/types": "^8.71.2", "@typescript-eslint/utils": "^8.71.2", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-kPlqE2Kdh0njsad9S9xL/xnq+NNVUsga/uS3yf6axE0RddACM3Q+idJyethC7R+C9b73+NRhAWvKyXPrkl0nFA=="],
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.7", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.12.3" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg=="],
@@ -428,7 +456,7 @@
"identifier-regex": ["identifier-regex@1.1.0", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA=="],
- "ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="],
+ "ignore": ["ignore@7.0.13", "", {}, "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -454,7 +482,7 @@
"js-types": ["js-types@4.0.0", "", {}, "sha512-/c+n06zvqFQGxdz1BbElF7S3nEghjNchLN1TjQnk2j10HYDaUc57rcvl6BbnziTx8NQmrg0JOs/iwRpvcYaxjQ=="],
- "jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@9.2.3", "", { "dependencies": { "@types/estree": "^1.0.9" } }, "sha512-xsCb5EbmLWNlQ8v5zQIgSYSyf1rSyVPjh8p9G2xviOl1weHm8a611fHWUOGn7ISquCL6LoBKZvh0tcKmpVGgzw=="],
+ "jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@9.3.0", "", { "dependencies": { "@types/estree": "^1.0.9" } }, "sha512-JZ8lKMCZXDlM1jAjAK5tTmtFJtgv6iFQQfjE3Fza6jBRACUueaEXLY17n1u05JO1lcPkNxgP+ddEDtiPsk5ktg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -580,7 +608,7 @@
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
- "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
+ "napi-postinstall": ["napi-postinstall@0.3.7", "", { "bin": { "napi-patch": "./lib/patch.js", "napi-postinstall": "./lib/cli.js" } }, "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -616,7 +644,7 @@
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
- "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="],
+ "postcss-selector-parser": ["postcss-selector-parser@7.1.7", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -628,7 +656,7 @@
"prettier-plugin-packagejson": ["prettier-plugin-packagejson@3.0.2", "", { "dependencies": { "sort-package-json": "^3.6.0" }, "peerDependencies": { "prettier": "^3" }, "optionalPeers": ["prettier"] }, "sha512-kmoj3hEynXwoHDo8ZhmWAIjRBoQWCDUVackiWfSDWdgD0rS3LGB61T9zoVbume/cotYdCoadUh4sqViAmXvpBQ=="],
- "prettier-plugin-toml": ["prettier-plugin-toml@4.0.0", "", { "dependencies": { "@tombi-toml/lib": "^1.8.4", "smol-toml": "^1.8.0" }, "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-kx4a0gdNKH40/NOj/gSkiRKqXmOq8hXb6fNWW6Txd3T0fyq+Wq8ly0qtjNoGGiLUPhvS/3PIRVyVJpzHw6RNwA=="],
+ "prettier-plugin-toml": ["prettier-plugin-toml@4.1.0", "", { "dependencies": { "tombit": "^0.1.3" }, "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-6+qgmRqD79kSsincO3odPzMpteTCpeP+WxxYmI7vDrhfpiQMFeciozuqUcnWc4XfJ97OZiuWeUfVcDktvACIDg=="],
"prototype-properties": ["prototype-properties@5.1.0", "", {}, "sha512-lq/vK1+nYV/bbjZ70pDmaTzin143xX9hVAU5CrsNQL57A8j4Q6AfjsY8kRFoUpCBncaUdfkz9MdsQ8PFtiKrUQ=="],
@@ -664,8 +692,6 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
- "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="],
-
"sort-object-keys": ["sort-object-keys@2.1.0", "", {}, "sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g=="],
"sort-package-json": ["sort-package-json@3.7.1", "", { "dependencies": { "detect-indent": "^7.0.2", "detect-newline": "^4.0.1", "git-hooks-list": "^4.1.1", "is-plain-obj": "^4.1.0", "semver": "^7.7.3", "sort-object-keys": "^2.0.1", "tinyglobby": "^0.2.15" }, "bin": { "sort-package-json": "cli.js" } }, "sha512-ssk1HG7whF8N/T1IsNAQrtHG5Cbdi0rAgRJZXYBr9hF5xaHnBNzUx/W6LcthEW7FhOwvZssbESZuO+GxssqAyA=="],
@@ -684,6 +710,8 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+ "tombit": ["tombit@0.1.5", "", { "dependencies": { "napi-postinstall": "^0.3.5" }, "optionalDependencies": { "@tombit/binding-android-arm-eabi": "0.1.5", "@tombit/binding-android-arm64": "0.1.5", "@tombit/binding-darwin-arm64": "0.1.5", "@tombit/binding-darwin-x64": "0.1.5", "@tombit/binding-freebsd-x64": "0.1.5", "@tombit/binding-linux-arm-gnueabihf": "0.1.5", "@tombit/binding-linux-arm-musleabihf": "0.1.5", "@tombit/binding-linux-arm64-gnu": "0.1.5", "@tombit/binding-linux-arm64-musl": "0.1.5", "@tombit/binding-linux-loong64-gnu": "0.1.5", "@tombit/binding-linux-loong64-musl": "0.1.5", "@tombit/binding-linux-ppc64-gnu": "0.1.5", "@tombit/binding-linux-ppc64-musl": "0.1.5", "@tombit/binding-linux-riscv64-gnu": "0.1.5", "@tombit/binding-linux-riscv64-musl": "0.1.5", "@tombit/binding-linux-s390x-gnu": "0.1.5", "@tombit/binding-linux-x64-gnu": "0.1.5", "@tombit/binding-linux-x64-musl": "0.1.5", "@tombit/binding-openharmony-arm64": "0.1.5", "@tombit/binding-openharmony-x64": "0.1.5", "@tombit/binding-win32-arm64-msvc": "0.1.5", "@tombit/binding-win32-ia32-msvc": "0.1.5", "@tombit/binding-win32-x64-msvc": "0.1.5" } }, "sha512-pVltqjGZroTsKoWBiUcGipnNJHIrtd0A5N/Joi9q4kWw9hQv03UcC5Bw2/3QXgLGtN5qFR8fp2ZReGcJoZA0pQ=="],
+
"toml-eslint-parser": ["toml-eslint-parser@1.0.3", "", { "dependencies": { "eslint-visitor-keys": "^5.0.0" } }, "sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
diff --git a/codex/choices/choices_to_json.py b/codex/choices/choices_to_json.py
index 788d6d991..775545013 100755
--- a/codex/choices/choices_to_json.py
+++ b/codex/choices/choices_to_json.py
@@ -33,6 +33,7 @@
IDENTIFIER_TYPE_BY_CODEX_NAME,
IDENTIFIER_TYPES,
LANGUAGES,
+ SERIES_STATUSES,
)
_DEFAULTS = MappingProxyType(
@@ -81,6 +82,7 @@
"reader-defaults.json": READER_DEFAULTS,
"reader-map.json": READER_CHOICES,
"search-map.json": SEARCH_FIELDS,
+ "series-statuses.json": SERIES_STATUSES,
"tagging-estimate.json": TAGGING_ESTIMATE,
}
)
diff --git a/codex/choices/search.py b/codex/choices/search.py
index cd1f1ceee..237cbac27 100644
--- a/codex/choices/search.py
+++ b/codex/choices/search.py
@@ -14,6 +14,8 @@
"month",
"year",
"page_count",
+ "series_end_year",
+ "series_start_year",
"size",
"decade",
"volume",
@@ -114,7 +116,15 @@ def _get_fieldmap_values(*args) -> tuple:
"review": (),
"scan_info": ("scan",),
"series": (),
+ "series_end_year": _get_fieldmap_values("series_end_year", "end_year"),
"series_groups": _get_fieldmap_values("series_groups"),
+ "series_start_year": _get_fieldmap_values("series_start_year", "start_year"),
+ # Hand-written: the generator would strip the final "s" into a
+ # bogus singular.
+ "series_status": ("series-status", "seriesstatus", "status"),
+ "series_summary": _get_fieldmap_values(
+ "series_summary", "series_desc", "series_description"
+ ),
"size": (),
"sources": _get_fieldmap_values("sources", "id_sources"),
"stories": ("story",),
diff --git a/codex/choices/tagging.py b/codex/choices/tagging.py
index abd9f8993..c8e0d6ce0 100644
--- a/codex/choices/tagging.py
+++ b/codex/choices/tagging.py
@@ -27,6 +27,7 @@
from comicbox.formats.metron_info.transform import MetronInfoTransform
from codex.choices.reader import READER_CHOICES
+from codex.models.choices import SeriesStatusChoices
from codex.models.identifier import IdentifierType, to_comicbox_id_type
# comicbox transform per codex write-format id.
@@ -84,6 +85,13 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType
{id_type.value: to_comicbox_id_type(id_type.value) for id_type in IdentifierType}
)
+# A series' publication status (Ongoing, Completed, ...). No format codex
+# writes holds it, so the editor saves it to the database only and offers
+# it under every format.
+SERIES_STATUSES = _vuetify_choices(
+ (str(label), str(value)) for value, label in SeriesStatusChoices.choices
+)
+
# ---------------------------------------------------------------------------
# Per-format field support, derived from the comicbox transforms
@@ -156,16 +164,17 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType
# Editor sub-fields comicbox stores only for some formats and which its
# transform specs don't surface at the SPECS_TO top level (the
# hand-maintained support data): MetronInfo's Series carries a volume
-# count, only MetronInfo's CommunityRating persists a rating count, and
-# only MetronInfo encodes a reprint's volume number (in Reprints) and
-# language (in Series/AlternativeNames). Whether a reprint row is one of
-# the series' other names is MetronInfo's too, and for the same reason:
-# ComicInfo has no reprint tag at all, since comicbox 5 reads its
-# AlternateSeries and AlternateNumber as a story arc.
+# count and a start year, only MetronInfo's CommunityRating persists a
+# rating count, and only MetronInfo encodes a reprint's volume number (in
+# Reprints) and language (in Series/AlternativeNames). Whether a reprint
+# row is one of the series' other names is MetronInfo's too, and for the
+# same reason: ComicInfo has no reprint tag at all, since comicbox 5 reads
+# its AlternateSeries and AlternateNumber as a story arc.
_EXTRA_FORMAT_FIELDS: MappingProxyType[str, tuple[str, ...]] = MappingProxyType(
{
"METRON_INFO": (
"volume_count",
+ "start_year",
"community_rating_count",
"reprint_volume",
"reprint_language",
diff --git a/codex/librarian/onlinetag/session_state.py b/codex/librarian/onlinetag/session_state.py
index bf25c4ab5..d66a68c26 100644
--- a/codex/librarian/onlinetag/session_state.py
+++ b/codex/librarian/onlinetag/session_state.py
@@ -215,6 +215,21 @@ def serialize_candidate(c) -> dict[str, Any]:
# The series' ordinal volume. Metron's issue rows carry one;
# Comic Vine's search results have no equivalent.
"volume": getattr(summary, "volume", None),
+ # What tells apart candidates whose covers and titles read
+ # identically: a reprint volume, a foreign or a collected
+ # edition. Comic Vine fills most of these from rows it already
+ # fetched. Metron always has the start year, but gets the
+ # format, publisher and issue count only when titles collide.
+ # Absent before comicbox 5.4, and None wherever a source has
+ # nothing.
+ "series_start_year": getattr(summary, "series_start_year", None),
+ "series_publisher": getattr(summary, "series_publisher", None),
+ "series_issue_count": getattr(summary, "series_issue_count", None),
+ # Publication format, e.g. "Trade Paperback". Metron only.
+ "series_type": getattr(summary, "series_type", None),
+ # The issue's own title and one-line deck. Comic Vine only.
+ "issue_title": getattr(summary, "issue_title", None),
+ "issue_summary": getattr(summary, "issue_summary", None),
},
"score": float(c.score),
# What the blended score is made of. Two candidates can read
diff --git a/codex/librarian/scribe/importer/const.py b/codex/librarian/scribe/importer/const.py
index da89fff74..f3e77f30a 100644
--- a/codex/librarian/scribe/importer/const.py
+++ b/codex/librarian/scribe/importer/const.py
@@ -56,6 +56,10 @@
PARENT_FOLDER_FIELD_NAME = "parent_folder"
VOLUME_COUNT_FIELD_NAME = "volume_count"
ISSUE_COUNT_FIELD_NAME = "issue_count"
+START_YEAR_FIELD_NAME = "start_year"
+END_YEAR_FIELD_NAME = "end_year"
+STATUS_FIELD_NAME = "status"
+SUMMARY_FIELD_NAME = "summary"
PATH_FIELD_NAME = "path"
IDENTIFIERS_FIELD_NAME = "identifiers"
CREDIT_PRIMARY_FIELD_NAME = "primary"
@@ -153,20 +157,25 @@ def _metron_fts_values(values: tuple) -> tuple:
FTS_UPDATE = "fts_update"
FTS_CREATE = "fts_create"
FTS_EXISTING_M2MS = "fts_existing_m2ms"
-FTS_CREATED_M2MS = "fts_created_m2ms"
FTS_UPDATED_M2MS = "fts_updated_m2ms"
#######
# M2M #
#######
-COLLECTION_MODEL_COUNT_FIELDS: MappingProxyType[
- type[BrowserCollectionModel], str | None
+COLLECTION_MODEL_ATTR_FIELDS: MappingProxyType[
+ type[BrowserCollectionModel], tuple[str, ...]
] = MappingProxyType(
{
- Publisher: None,
- Imprint: None,
- Series: VOLUME_COUNT_FIELD_NAME,
- Volume: ISSUE_COUNT_FIELD_NAME,
+ Publisher: (),
+ Imprint: (),
+ Series: (
+ VOLUME_COUNT_FIELD_NAME,
+ START_YEAR_FIELD_NAME,
+ END_YEAR_FIELD_NAME,
+ STATUS_FIELD_NAME,
+ SUMMARY_FIELD_NAME,
+ ),
+ Volume: (ISSUE_COUNT_FIELD_NAME,),
}
)
COMIC_M2M_FIELDS: tuple[ManyToManyField, ...] = cast(
@@ -201,6 +210,8 @@ def _metron_fts_values(values: tuple) -> tuple:
REPRINT_LANGUAGE_FIELD_NAME = "language"
# Reprints index only their series names, under their own ComicFTS column.
ALTERNATE_SERIES_FTS_FIELD_NAME = "alternate_series"
+# The linked series' summary, indexed on each of its comics.
+SERIES_SUMMARY_FTS_FIELD_NAME = "series_summary"
UNIVERSES_FIELD_NAME = "universes"
NAME_FIELD_NAME = "name"
NUMBER_TO_FIELD_NAME = "number_to"
@@ -282,7 +293,7 @@ def _metron_fts_values(values: tuple) -> tuple:
NAME_FIELD_NAME,
),
_IDENTIFIER_RELS,
- VOLUME_COUNT_FIELD_NAME,
+ *COLLECTION_MODEL_ATTR_FIELDS[Series],
),
Volume: (
(
@@ -293,7 +304,7 @@ def _metron_fts_values(values: tuple) -> tuple:
NUMBER_TO_FIELD_NAME,
),
"",
- ISSUE_COUNT_FIELD_NAME,
+ *COLLECTION_MODEL_ATTR_FIELDS[Volume],
),
Folder: (
(PATH_FIELD_NAME,),
@@ -324,23 +335,6 @@ def _metron_fts_values(values: tuple) -> tuple:
Universe: ((NAME_FIELD_NAME,), _IDENTIFIER_RELS, DESIGNATION_FIELD_NAME),
}
)
-_IDENTIFIED_SELECT_RELATED = ("identifier", "identifier__source")
-MODEL_SELECT_RELATED: MappingProxyType[type[BaseModel], tuple[str, ...]] = (
- MappingProxyType(
- {
- **dict.fromkeys(IDENTIFIED_MODELS, _IDENTIFIED_SELECT_RELATED),
- Identifier: (IDENTIFIER_SOURCE_FIELD_NAME,),
- Publisher: _IDENTIFIED_SELECT_RELATED,
- Imprint: ("publisher",),
- Series: ("publisher", "imprint"),
- Volume: ("publisher", "imprint", "series"),
- Credit: (CREDIT_PERSON_FIELD_NAME, CREDIT_ROLE_FIELD_NAME),
- Reprint: _IDENTIFIED_SELECT_RELATED,
- StoryArcNumber: (STORY_ARC_FIELD_NAME,),
- Universe: _IDENTIFIED_SELECT_RELATED,
- }
- )
-)
FIELD_NAME_KEYS_REL_MAP = MappingProxyType(
{
# ``related_model`` is typed ``type[Model] | Literal['self']
diff --git a/codex/librarian/scribe/importer/create/comics.py b/codex/librarian/scribe/importer/create/comics.py
index 80f2f93c6..f6aa34f13 100644
--- a/codex/librarian/scribe/importer/create/comics.py
+++ b/codex/librarian/scribe/importer/create/comics.py
@@ -12,9 +12,11 @@
BULK_UPDATE_COMIC_FIELDS_SET,
COLLECTION_FIELD_NAMES,
CREATE_COMICS,
+ DELETE_M2MS,
FTS_CREATE,
FTS_UPDATE,
LINK_FKS,
+ LINK_M2MS,
NON_FTS_FIELDS,
PATH_FIELD_NAME,
UPDATE_COMICS,
@@ -33,6 +35,10 @@
if TYPE_CHECKING:
from codex.models.collections import BrowserCollectionModel
+# What ``presave`` derives from the file and the row. An update that would
+# change none of them, the timestamp aside, writes nothing new.
+_NOOP_CHECK_FIELDS = tuple(sorted(ALWAYS_UPDATE_COMIC_FIELDS - {"updated_at"}))
+
class CreateComicsImporter(CreateForeignKeyLinksImporter):
"""Create comics methods."""
@@ -66,10 +72,55 @@ def _record_moved_source_collections(
)
self.moved_source_collections.setdefault(model, set()).add(old_pk)
+ def _is_noop_update(
+ self,
+ comic: Comic,
+ md: dict,
+ link_md: dict,
+ m2m_deleted_pks: frozenset[int],
+ ) -> bool:
+ """
+ Return whether updating this comic would write nothing new.
+
+ A forced or lazy import of an unchanged comic, typically one with no
+ embedded tags, used to rewrite the row anyway: that purged and rebuilt
+ its cover and broadcast LIBRARY_CHANGED, reloading every browser. The
+ query phase has already pruned every link that is already correct, so
+ no scalar, FK or m2m change remains. If ``presave`` then derives the
+ same stat and the same values from the file on disk, the row is
+ current. A chunk that changed tag or collection rows keeps every comic
+ (see ``chunk_fks_updated``).
+ """
+ if (
+ self.chunk_fks_updated
+ or md
+ or link_md
+ or comic.path in self.task.series_attrs
+ ):
+ return False
+ if (
+ comic.path in self.metadata.get(LINK_M2MS, {})
+ or comic.pk in m2m_deleted_pks
+ ):
+ return False
+ before = tuple(getattr(comic, field_name) for field_name in _NOOP_CHECK_FIELDS)
+ comic.presave()
+ return before == tuple(
+ getattr(comic, field_name) for field_name in _NOOP_CHECK_FIELDS
+ )
+
def _update_comic_values(
- self, comic: Comic, update_comics: list, comic_pks: list, changed_fields: set
+ self,
+ comic: Comic,
+ update_comics: list,
+ comic_pks: list,
+ changed_fields: set,
+ m2m_deleted_pks: frozenset[int],
) -> None:
md = self.metadata[UPDATE_COMICS].pop(comic.pk, {})
+ link_md = self.get_comic_fk_links(comic.pk, comic.path, for_create=False)
+ if self._is_noop_update(comic, md, link_md, m2m_deleted_pks):
+ return
for field_name, value in md.items():
setattr(comic, field_name, value)
changed_fields.update(md)
@@ -82,7 +133,6 @@ def _update_comic_values(
field_name: getattr(comic, f"{field_name}_id")
for field_name in COLLECTION_FIELD_NAMES
}
- link_md = self.get_comic_fk_links(comic.pk, comic.path, for_create=False)
changed_fields.update(link_md)
for field_name, value in link_md.items():
set_value = value
@@ -125,12 +175,17 @@ def update_comics(self) -> int:
update_comics = []
comic_pks = []
changed_fields: set[str] = set()
+ m2m_deleted_pks = frozenset(
+ comic_pk
+ for rows in self.metadata.get(DELETE_M2MS, {}).values()
+ for comic_pk, _ in rows
+ )
for comic in comics:
if self.abort_event.is_set():
return count
try:
self._update_comic_values(
- comic, update_comics, comic_pks, changed_fields
+ comic, update_comics, comic_pks, changed_fields, m2m_deleted_pks
)
except Exception:
self.log.exception(f"Error preparing {comic} for update.")
diff --git a/codex/librarian/scribe/importer/create/const.py b/codex/librarian/scribe/importer/create/const.py
index 48f9c10e0..0fd4a448a 100644
--- a/codex/librarian/scribe/importer/create/const.py
+++ b/codex/librarian/scribe/importer/create/const.py
@@ -4,6 +4,7 @@
from types import MappingProxyType
from codex.librarian.scribe.importer.const import (
+ COLLECTION_MODEL_ATTR_FIELDS,
CREDIT_PERSON_FIELD_NAME,
CREDIT_PRIMARY_FIELD_NAME,
CREDIT_ROLE_FIELD_NAME,
@@ -28,7 +29,6 @@
REPRINT_VOLUME_NUMBER_FIELD_NAME,
SERIES_FIELD_NAME,
STORY_ARC_FIELD_NAME,
- VOLUME_COUNT_FIELD_NAME,
)
from codex.models import (
Credit,
@@ -68,6 +68,7 @@
{
Universe: frozenset({"designation"}),
Identifier: frozenset({"id_type", "key", "url"}),
+ Series: DEFAULT_NON_NULL_CHARFIELD_NAMES | {"status", "summary"},
Comic: frozenset({"path"}),
Volume: frozenset({}),
}
@@ -104,7 +105,10 @@
IMPRINT_FIELD_NAME: Imprint,
NAME_FIELD_NAME: None,
},
- {IDENTIFIER_FIELD_NAME: Identifier, VOLUME_COUNT_FIELD_NAME: None},
+ {
+ IDENTIFIER_FIELD_NAME: Identifier,
+ **dict.fromkeys(COLLECTION_MODEL_ATTR_FIELDS[Series]),
+ },
),
Volume: (
{
diff --git a/codex/librarian/scribe/importer/create/foreign_keys.py b/codex/librarian/scribe/importer/create/foreign_keys.py
index b4b386166..512fbd0bf 100644
--- a/codex/librarian/scribe/importer/create/foreign_keys.py
+++ b/codex/librarian/scribe/importer/create/foreign_keys.py
@@ -8,12 +8,13 @@
from itertools import chain
from codex.librarian.scribe.importer.const import (
- COLLECTION_MODEL_COUNT_FIELDS,
+ COLLECTION_MODEL_ATTR_FIELDS,
COMPLEX_M2M_MODELS,
CREATE_FKS,
- FTS_CREATED_M2MS,
FTS_UPDATED_M2MS,
MODEL_REL_MAP,
+ SERIES_SUMMARY_FTS_FIELD_NAME,
+ SUMMARY_FIELD_NAME,
TOTAL,
UPDATE_FKS,
)
@@ -35,7 +36,7 @@
)
from codex.librarian.status import Status
from codex.models.base import BaseModel
-from codex.models.collections import Folder
+from codex.models.collections import Folder, Series
from codex.models.named import Universe
@@ -93,7 +94,7 @@ def _get_create_update_args(
value = values_tuple[index]
arg_map = key_args if index < num_keys else update_args
if field_model and value is not None:
- if field_model in COLLECTION_MODEL_COUNT_FIELDS and index < num_keys:
+ if field_model in COLLECTION_MODEL_ATTR_FIELDS and index < num_keys:
key = tuple(values_tuple[: index + 1])
elif isinstance(value, tuple):
key = value
@@ -145,7 +146,6 @@ def _bulk_create_models(
parent_pk_maps = self._build_parent_fk_pk_maps(model)
create_objs = []
create_tuples = sorted(create_tuples, key=str)
- created_fts_values = {}
for values_tuple in create_tuples:
key_args, update_args = self._get_create_update_args(
model,
@@ -159,15 +159,6 @@ def _bulk_create_models(
if model in CUSTOM_COVER_MODELS:
self._add_custom_cover(model, obj)
create_objs.append(obj)
- if model == Universe and update_args:
- fts_update_values = tuple(
- value for value in update_args.values() if isinstance(value, str)
- )
- created_fts_values[tuple(key_args.values())] = fts_update_values
-
- if created_fts_values:
- field_name = model.__name__.lower() + "s"
- self.metadata[FTS_CREATED_M2MS][field_name] = created_fts_values
update_fields = tuple(key_args_map.keys()) + tuple(update_args_map.keys())
if model in COLLECTION_BASE_MODELS:
@@ -213,6 +204,19 @@ def bulk_create_all_models(self, status) -> int:
)
return count
+ @staticmethod
+ def _series_summary_changed(obj: BaseModel, update_args: Mapping) -> bool:
+ """Whether a series update changes the summary its comics index."""
+ return (
+ isinstance(obj, Series)
+ and update_args.get(SUMMARY_FIELD_NAME, obj.summary) != obj.summary
+ )
+
+ def _add_fts_updated_pks(self, field_name: str, pks: set[int]) -> None:
+ """Queue comics to re-index, merged across the per-comic chunks."""
+ if pks:
+ self.metadata[FTS_UPDATED_M2MS].setdefault(field_name, set()).update(pks)
+
def _bulk_update_models(self, model: type[BaseModel], status) -> int:
count = 0
update_tuples: list[tuple] | None = self.metadata[UPDATE_FKS].pop(model, None)
@@ -226,6 +230,7 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int:
update_objs = []
update_tuples = sorted(update_tuples, key=str)
fts_update_pks = set()
+ series_summary_pks = set()
for values_tuple in update_tuples:
key_args, update_args = self._get_create_update_args(
model,
@@ -235,6 +240,10 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int:
parent_pk_maps,
)
obj = model.objects.get(**key_args)
+ # The importer prunes unchanged series links, so a summary
+ # change has to re-index every comic of the series.
+ if self._series_summary_changed(obj, update_args):
+ series_summary_pks.add(obj.pk)
for key, value in update_args.items():
setattr(obj, key, value)
obj.presave()
@@ -248,9 +257,8 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int:
if model == Universe:
fts_update_pks.add(obj.pk)
- if fts_update_pks:
- field_name = model.__name__.lower() + "s"
- self.metadata[FTS_UPDATED_M2MS][field_name] = fts_update_pks
+ self._add_fts_updated_pks(model.__name__.lower() + "s", fts_update_pks)
+ self._add_fts_updated_pks(SERIES_SUMMARY_FTS_FIELD_NAME, series_summary_pks)
update_fields = tuple(update_args_map.keys())
if model in COLLECTION_BASE_MODELS:
@@ -274,9 +282,6 @@ def create_all_fks(self) -> int:
fkc = self.metadata.get(CREATE_FKS, {})
create_status = ImporterCreateTagsStatus(0, fkc.pop(TOTAL, 0))
try:
- # FTS_CREATED_M2MS accumulates across chunks (consumed later
- # by full_text_search); setdefault preserves prior chunks.
- self.metadata.setdefault(FTS_CREATED_M2MS, {})
if not fkc:
return count
self.status_controller.start(create_status)
@@ -304,6 +309,7 @@ def update_all_fks(self) -> int:
)
count += self.bulk_update_all_models(update_status)
finally:
+ self.chunk_fks_updated = bool(count)
self.metadata.pop(UPDATE_FKS, None)
self.status_controller.finish(update_status)
return count
diff --git a/codex/librarian/scribe/importer/create/link_fks.py b/codex/librarian/scribe/importer/create/link_fks.py
index d14c60c49..e1dbaa10f 100644
--- a/codex/librarian/scribe/importer/create/link_fks.py
+++ b/codex/librarian/scribe/importer/create/link_fks.py
@@ -11,10 +11,11 @@
LINK_FKS,
PARENT_FOLDER_FIELD_NAME,
PROTAGONIST_FIELD_MODEL_MAP,
+ SERIES_SUMMARY_FTS_FIELD_NAME,
VOLUME_FIELD_NAME,
)
from codex.librarian.scribe.importer.link import LinkComicsImporter
-from codex.models import Folder
+from codex.models import Folder, Series
from codex.models.comic import Comic
from codex.settings import IMPORTER_LINK_FK_BATCH_SIZE
@@ -161,6 +162,10 @@ def _get_comic_simple_fk_links(
field_name,
(values[-1],),
)
+ if isinstance(instance, Series):
+ self.add_links_to_fts(
+ subkey, SERIES_SUMMARY_FTS_FIELD_NAME, (instance.summary,)
+ )
def get_comic_fk_links(
self, subkey: str | int, path: str, *, for_create: bool
diff --git a/codex/librarian/scribe/importer/finish.py b/codex/librarian/scribe/importer/finish.py
index 24daced27..d5d2fe651 100644
--- a/codex/librarian/scribe/importer/finish.py
+++ b/codex/librarian/scribe/importer/finish.py
@@ -1,5 +1,6 @@
"""The main importer class."""
+from itertools import batched
from operator import itemgetter
from time import perf_counter
from types import MappingProxyType
@@ -8,6 +9,7 @@
from django.db.models.functions import Now
from humanize import intcomma, naturaldelta
+from codex.choices.admin import AdminFlagChoices
from codex.librarian.notifier.tasks import (
FAILED_IMPORTS_CHANGED_TASK,
LIBRARY_CHANGED_TASK,
@@ -18,7 +20,9 @@
from codex.librarian.scribe.importer.statii import IMPORTER_STATII
from codex.librarian.scribe.search.status import SEARCH_INDEX_STATII
from codex.librarian.scribe.status import SCRIBE_STATII
+from codex.models.admin import AdminFlag
from codex.models.comic import Comic
+from codex.settings import IMPORTER_LINK_FK_BATCH_SIZE
_REPORT_MAP = MappingProxyType(
{
@@ -91,24 +95,34 @@ def _log_finish(self) -> None:
self.log.success(log_txt)
self._log_phase_times()
+ def is_import_metadata(self) -> bool:
+ """Return whether this pass reads tags, forced or by admin flag."""
+ if self.task.force_import_metadata:
+ return True
+ key = AdminFlagChoices.IMPORT_METADATA.value
+ return AdminFlag.objects.only("on").get(key=key).on
+
def _stamp_metadata_imported(self) -> None:
"""
- Mark every comic this forced import pass touched as import-complete.
+ Mark every comic this tag-reading pass touched as import-complete.
Path-scoped so it covers SKIPPED no-metadata comics that never reach
- the per-comic write path. Idempotent (``__isnull=True``) and gated on
- ``force_import_metadata`` so ordinary watcher-driven imports never
- rewrite the column.
+ the per-comic write path. Idempotent (``__isnull=True``). A scan with
+ the import-tags flag off reads no tags, so it leaves the column NULL
+ for the lazy importer to pick up.
"""
- if not self.task.force_import_metadata:
- return
if not (paths := self.metadata_import_paths):
return
- Comic.objects.filter(
- library=self.library,
- path__in=paths,
- metadata_imported_at__isnull=True,
- ).update(metadata_imported_at=Now())
+ if not self.is_import_metadata():
+ return
+ # A first scan reaches here with every path in the library, so
+ # batch path__in to stay under SQLite's variable limit.
+ for batch_paths in batched(paths, IMPORTER_LINK_FK_BATCH_SIZE):
+ Comic.objects.filter(
+ library=self.library,
+ path__in=batch_paths,
+ metadata_imported_at__isnull=True,
+ ).update(metadata_imported_at=Now())
def finish(self) -> None:
"""Perform final tasks when the apply is done."""
diff --git a/codex/librarian/scribe/importer/init.py b/codex/librarian/scribe/importer/init.py
index 9f820c1cd..25d8051b8 100644
--- a/codex/librarian/scribe/importer/init.py
+++ b/codex/librarian/scribe/importer/init.py
@@ -156,6 +156,11 @@ def __init__(
self.moved_source_collections: dict[
type[BrowserCollectionModel | Reprint], set[int]
] = {}
+ # Whether this chunk's ``update_all_fks`` changed any rows. Those
+ # rows reach the browser's refresh gate and the search index only
+ # through the comics this import rewrites, so ``update_comics`` keeps
+ # every comic of such a chunk, changed or not.
+ self.chunk_fks_updated = False
# Full set of paths this import touched, captured before the chunking
# loop and extract phase zero out ``task.files_*``. Consumed at finish
# to stamp ``Comic.metadata_imported_at`` on every comic a forced/lazy
diff --git a/codex/librarian/scribe/importer/query/foreign_keys.py b/codex/librarian/scribe/importer/query/foreign_keys.py
index 010588395..b2bfadc02 100644
--- a/codex/librarian/scribe/importer/query/foreign_keys.py
+++ b/codex/librarian/scribe/importer/query/foreign_keys.py
@@ -4,7 +4,6 @@
CREATE_FKS,
FK_KEYS,
MODEL_REL_MAP,
- MODEL_SELECT_RELATED,
QUERY_MODELS,
TOTAL,
UPDATE_FKS,
@@ -37,13 +36,10 @@ def query_existing_mds(
batch_proposed_key_tuples,
)
rels = MODEL_REL_MAP[model]
- select_related = MODEL_SELECT_RELATED.get(model, ())
# A comprehension, not filter(bool, ...): bool's signature widens the
# element type to object, which values_list refuses.
fields = tuple(field for field in flatten(rels) if field)
- qs = model.objects
- qs = qs.select_related(*select_related)
- qs = qs.filter(fk_filter).distinct().values_list(*fields)
+ qs = model.objects.filter(fk_filter).distinct().values_list(*fields)
extra_index = get_key_index(model)
has_id = bool(rels[1])
existing_mds = {}
diff --git a/codex/librarian/scribe/importer/query/update_comics.py b/codex/librarian/scribe/importer/query/update_comics.py
index 727f84431..d330682d2 100644
--- a/codex/librarian/scribe/importer/query/update_comics.py
+++ b/codex/librarian/scribe/importer/query/update_comics.py
@@ -33,7 +33,8 @@ def _query_update_comic(self, comic: Comic, status: Status) -> None:
for key, value in proposed_comic_dict.items()
if getattr(comic, key) != value
}
- # Update even if update_comic_dict is empty to set stat
+ # Keep it even if update_comic_dict is empty: the update phase still
+ # writes a changed stat, and skips the comic only if nothing changed.
self.metadata[UPDATE_COMICS][comic.pk] = update_comic_dict
status.increment_complete()
self.status_controller.update(status)
diff --git a/codex/librarian/scribe/importer/read/aggregate_path.py b/codex/librarian/scribe/importer/read/aggregate_path.py
index 031697057..4cb63c4c6 100644
--- a/codex/librarian/scribe/importer/read/aggregate_path.py
+++ b/codex/librarian/scribe/importer/read/aggregate_path.py
@@ -123,6 +123,9 @@ def _aggregate_path(self, path, status) -> None:
"""Aggregate metadata for one path."""
# Prepare
md = self.metadata[EXTRACTED].pop(path)
+ if series_attrs := self.task.series_attrs.get(path):
+ # A tag write's values the file couldn't hold. See ImportTask.
+ md[SERIES_KEY] = {**(md.get(SERIES_KEY) or {}), **series_attrs}
self._transform_metadata(md)
# Aggregate
diff --git a/codex/librarian/scribe/importer/read/extract.py b/codex/librarian/scribe/importer/read/extract.py
index e91f1711c..c4749eaf0 100644
--- a/codex/librarian/scribe/importer/read/extract.py
+++ b/codex/librarian/scribe/importer/read/extract.py
@@ -9,7 +9,6 @@
from comicbox.formats.comicbox.schema import PAGE_COUNT_KEY
from comicbox.process import ReadResult, iter_process_files
-from codex.choices.admin import AdminFlagChoices
from codex.librarian.scribe.importer.const import (
EXTRACTED,
EXTRACTED_STAT_ONLY,
@@ -20,7 +19,6 @@
AggregateMetadataImporter,
)
from codex.librarian.scribe.importer.statii.read import ImporterReadComicsStatus
-from codex.models.admin import AdminFlag
from codex.models.comic import Comic
from codex.settings import COMICBOX_CONFIG, LOGLEVEL
from codex.startup.loguru import CODEX_LOG_FORMAT
@@ -102,11 +100,7 @@ def _filesystem_mtime_prefilter(
def _get_import_metadata_flag(self) -> bool:
"""Set import_metadata flag."""
- if self.task.force_import_metadata:
- import_metadata = True
- else:
- key = AdminFlagChoices.IMPORT_METADATA.value
- import_metadata = AdminFlag.objects.only("on").get(key=key).on
+ import_metadata = self.is_import_metadata()
if not import_metadata:
self.log.warning("Admin flag set to NOT import tags.")
return import_metadata
diff --git a/codex/librarian/scribe/importer/read/foreign_keys.py b/codex/librarian/scribe/importer/read/foreign_keys.py
index 21f39764b..1f38ec802 100644
--- a/codex/librarian/scribe/importer/read/foreign_keys.py
+++ b/codex/librarian/scribe/importer/read/foreign_keys.py
@@ -1,7 +1,7 @@
"""Aggregate Browser Collection Trees."""
from collections.abc import Mapping
-from contextlib import suppress
+from types import MappingProxyType
from comicbox.enums.comicbox import IdSources
from comicbox.formats.comicbox.schema import (
@@ -20,9 +20,13 @@
from codex.librarian.scribe.importer.const import (
COLLECTION_FIELD_NAMES,
COLLECTION_FIELD_NAMES_SET,
- COLLECTION_MODEL_COUNT_FIELDS,
+ COLLECTION_MODEL_ATTR_FIELDS,
+ END_YEAR_FIELD_NAME,
LINK_FKS,
QUERY_MODELS,
+ START_YEAR_FIELD_NAME,
+ STATUS_FIELD_NAME,
+ SUMMARY_FIELD_NAME,
)
from codex.librarian.scribe.importer.query import QueryForeignKeysImporter
from codex.librarian.scribe.importer.read.const import (
@@ -38,11 +42,22 @@
to_codex_id_type,
to_comicbox_id_type,
)
-from codex.util import max_none
+from codex.util import first_none, max_none, min_none
_MINIMAL_KEYS = frozenset(
{"file_type", PAGE_COUNT_KEY, "path"} | COLLECTION_FIELD_NAMES_SET
)
+# How comics of one collection in an import batch combine an attribute.
+# Counts and the end year take the largest, the start year the earliest
+# and text the first comic's.
+_ATTR_MERGE = MappingProxyType(
+ {
+ START_YEAR_FIELD_NAME: min_none,
+ END_YEAR_FIELD_NAME: max_none,
+ STATUS_FIELD_NAME: first_none,
+ SUMMARY_FIELD_NAME: first_none,
+ }
+)
class AggregateForeignKeyMetadataImporter(QueryForeignKeysImporter):
@@ -131,6 +146,41 @@ def _set_simple_fk(self, related_field: Field, value) -> tuple:
return ()
return (value,), None
+ def _merge_collection_attrs(
+ self,
+ model: type[BrowserCollectionModel],
+ key_values: tuple,
+ collection: Mapping,
+ ) -> tuple:
+ """
+ Merge one comic's collection attributes into the batch's so far.
+
+ Every comic proposes its own extra values for its collection. The
+ query phase settles several proposals by set order, so the ones
+ already proposed are rewritten to carry the merged attributes too
+ and all proposals agree.
+ """
+ attr_names = COLLECTION_MODEL_ATTR_FIELDS[model]
+ values = []
+ for attr_name in attr_names:
+ field = model._meta.get_field(attr_name)
+ value = field.get_prep_value(collection.get(attr_name))
+ values.append(None if value == "" else value)
+ proposed = self.metadata[QUERY_MODELS].get(model, {}).get(key_values)
+ if not proposed:
+ return tuple(values)
+ num_attrs = len(attr_names)
+ for extra_values in proposed:
+ old_values = extra_values[-num_attrs:]
+ for index, attr_name in enumerate(attr_names):
+ merge = _ATTR_MERGE.get(attr_name, max_none)
+ values[index] = merge(old_values[index], values[index])
+ merged = tuple(values)
+ self.metadata[QUERY_MODELS][model][key_values] = {
+ (*extra_values[:-num_attrs], *merged) for extra_values in proposed
+ }
+ return merged
+
def _set_collection_tree_node(
self,
model: type[BrowserCollectionModel],
@@ -153,16 +203,12 @@ def _set_collection_tree_node(
else:
identifier_tuple = self.get_identifier_tuple(model, collection)
extra_vals.append(identifier_tuple)
- count_key = COLLECTION_MODEL_COUNT_FIELDS[model]
- if count_key:
- count = collection.get(count_key)
- with suppress(Exception):
- old_count_val = (
- self.metadata[QUERY_MODELS].get(model, {}).get(collection_list)
- )
- count = max_none(old_count_val, count)
- extra_vals.append(count)
- return tuple(collection_list), frozenset((tuple(extra_vals),))
+ key_values = tuple(collection_list)
+ if COLLECTION_MODEL_ATTR_FIELDS[model]:
+ extra_vals.extend(
+ self._merge_collection_attrs(model, key_values, collection)
+ )
+ return key_values, frozenset((tuple(extra_vals),))
def get_fk_metadata(self, md, path) -> None:
"""Aggregate Simple Foreign Keys."""
diff --git a/codex/librarian/scribe/importer/search/prepare.py b/codex/librarian/scribe/importer/search/prepare.py
index 4c69e5502..d92fe1df3 100644
--- a/codex/librarian/scribe/importer/search/prepare.py
+++ b/codex/librarian/scribe/importer/search/prepare.py
@@ -4,7 +4,6 @@
ALTERNATE_SERIES_FTS_FIELD_NAME,
CREDITS_FIELD_NAME,
FTS_CREATE,
- FTS_CREATED_M2MS,
FTS_EXISTING_M2MS,
FTS_FIELD_TARGETS,
FTS_UPDATE,
@@ -99,13 +98,8 @@ def add_links_to_fts(
)
# Alternative might be kicking off an FTS sync
- flat_values = flatten(values)
- extra_values = (
- self.metadata[FTS_CREATED_M2MS].get(field_name, {}).pop(flat_values, ())
- )
- combined_values = flat_values + extra_values
for fts_field_name, target_values in self._iter_fts_targets(
- field_name, combined_values
+ field_name, tuple(flatten(values))
):
fts_values = self._to_fts_tuple(target_values)
self.metadata[key][sub_key][fts_field_name] = fts_values
diff --git a/codex/librarian/scribe/importer/search/sync_m2m.py b/codex/librarian/scribe/importer/search/sync_m2m.py
index d31c6d407..78a4605ff 100644
--- a/codex/librarian/scribe/importer/search/sync_m2m.py
+++ b/codex/librarian/scribe/importer/search/sync_m2m.py
@@ -1,10 +1,11 @@
"""Update fts fields for updated foreign keys with non key search values."""
from itertools import batched
+from types import MappingProxyType
from typing import TYPE_CHECKING
from django.db import transaction
-from django.db.models.expressions import Value
+from django.db.models.expressions import F, Value
from django.db.models.functions.datetime import Now
from django.db.models.functions.text import Concat
from loguru import logger
@@ -12,7 +13,10 @@
if TYPE_CHECKING:
from collections.abc import Collection
-from codex.librarian.scribe.importer.const import FTS_UPDATED_M2MS
+from codex.librarian.scribe.importer.const import (
+ FTS_UPDATED_M2MS,
+ SERIES_SUMMARY_FTS_FIELD_NAME,
+)
from codex.librarian.scribe.importer.finish import FinishImporter
from codex.models.comic import ComicFTS
from codex.models.functions import GroupConcat
@@ -21,6 +25,16 @@
# default. ``ComicFTS`` carries ~28 columns; 500 rows x 28 cols is a
# safe ceiling that still amortizes per-statement overhead.
_FTS_BATCH_SIZE = 500
+# FTS columns that index a plain attribute of an FK, keyed by the column:
+# (filter on the FK's updated pks, the attribute's value). No GROUP_CONCAT.
+_FK_ATTR_FTS_MAP = MappingProxyType(
+ {
+ SERIES_SUMMARY_FTS_FIELD_NAME: (
+ "comic__series__in",
+ F("comic__series__summary"),
+ ),
+ }
+)
class SearchIndexSyncManyToManyImporter(FinishImporter):
@@ -73,8 +87,11 @@ def _gather_m2m_field_values(
for field_name in field_names:
if self.abort_event.is_set():
return per_comic
- rel = f"comic__{field_name}__in"
- fts_value = self._get_fts_m2m_concat(field_name)
+ fk_attr = _FK_ATTR_FTS_MAP.get(field_name)
+ rel, fts_value = fk_attr or (
+ f"comic__{field_name}__in",
+ self._get_fts_m2m_concat(field_name),
+ )
model_pks = self.metadata[FTS_UPDATED_M2MS].pop(field_name)
self.log.debug(
f"Preparing {len(model_pks)} search entries for {field_name} updates."
@@ -86,7 +103,9 @@ def _gather_m2m_field_values(
.values_list("pk", "fts_value")
)
for pk, raw_value in rows:
- value = (raw_value or "").strip(",")
+ value = raw_value or ""
+ if not fk_attr:
+ value = value.strip(",")
per_comic.setdefault(pk, {})[field_name] = value
return per_comic
diff --git a/codex/librarian/scribe/importer/search/update.py b/codex/librarian/scribe/importer/search/update.py
index 60183b26e..7ba65991a 100644
--- a/codex/librarian/scribe/importer/search/update.py
+++ b/codex/librarian/scribe/importer/search/update.py
@@ -1,14 +1,17 @@
"""Search Index update."""
+from collections.abc import Mapping
+from itertools import batched
from time import perf_counter
from humanize import naturaldelta
from codex.librarian.scribe.importer.const import (
FTS_CREATE,
- FTS_CREATED_M2MS,
FTS_EXISTING_M2MS,
FTS_UPDATE,
+ FTS_UPDATED_M2MS,
+ UNIVERSES_FIELD_NAME,
)
from codex.librarian.scribe.importer.search.sync_m2m import (
SearchIndexSyncManyToManyImporter,
@@ -21,20 +24,61 @@
from codex.librarian.scribe.search.prepare import SearchEntryPrepare
from codex.librarian.status import Status
from codex.models.comic import ComicFTS
+from codex.models.named import Universe
+from codex.settings import IMPORTER_LINK_FK_BATCH_SIZE
class SearchIndexCreateUpdateImporter(SearchIndexSyncManyToManyImporter):
"""Search Index update methods."""
+ def _get_universe_designations(self, entries: Mapping[int, dict]) -> dict:
+ """
+ Map the names of the universes these entries index to designations.
+
+ The index holds each universe's designation beside its name, as a
+ full rebuild writes it. The link phase only knows the names, so look
+ up every one this pass touches, new, existing or kept, in batches.
+ """
+ existing = self.metadata.get(FTS_EXISTING_M2MS, {})
+ names = set()
+ for pk, entry in entries.items():
+ names.update(entry.get(UNIVERSES_FIELD_NAME, ()))
+ names.update(existing.get(pk, {}).get(UNIVERSES_FIELD_NAME, ()))
+ designations = {}
+ for batch in batched(sorted(names), IMPORTER_LINK_FK_BATCH_SIZE):
+ designations.update(
+ Universe.objects.filter(name__in=batch)
+ .exclude(designation="")
+ .values_list("name", "designation")
+ )
+ return designations
+
+ @staticmethod
+ def _add_universe_designations(
+ entry: dict, existing_m2m_values: dict | None, designations: Mapping
+ ) -> None:
+ """Index the designations of the entry's new and kept universes."""
+ names = (
+ *entry.get(UNIVERSES_FIELD_NAME, ()),
+ *(existing_m2m_values or {}).get(UNIVERSES_FIELD_NAME, ()),
+ )
+ if found := {designations[name] for name in names if name in designations}:
+ entry[UNIVERSES_FIELD_NAME] = (
+ *entry.get(UNIVERSES_FIELD_NAME, ()),
+ *sorted(found),
+ )
+
def _create_comicfts_entry(
self,
pk: int,
entry: dict,
obj_list: list[ComicFTS],
status: Status,
+ designations: Mapping,
) -> None:
if entry:
existing_m2m_values = self.metadata[FTS_EXISTING_M2MS].get(pk)
+ self._add_universe_designations(entry, existing_m2m_values, designations)
SearchEntryPrepare.prepare_import_fts_entry(
pk, entry, existing_m2m_values, None, obj_list, status, create=True
)
@@ -47,6 +91,7 @@ def _update_comicfts_entry(
comicfts: ComicFTS,
obj_list: list[ComicFTS],
status: Status,
+ designations: Mapping,
) -> None:
comic_id = comicfts.comic_id # pyright:ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute]
# ``codex_comicfts`` is an FTS5 virtual table without a unique
@@ -57,6 +102,7 @@ def _update_comicfts_entry(
# as "empty entry" — both mean "nothing to write for this row".
if entry := self.metadata[FTS_UPDATE].pop(comic_id, None):
existing_m2m_values = self.metadata[FTS_EXISTING_M2MS].get(comic_id)
+ self._add_universe_designations(entry, existing_m2m_values, designations)
SearchEntryPrepare.prepare_import_fts_entry(
comic_id,
entry,
@@ -84,9 +130,10 @@ def _update_search_index_operate_create(
obj_list = []
if self.abort_event.is_set():
return tuple(obj_list), ()
+ designations = self._get_universe_designations(entries)
for pk in pks:
entry = entries.pop(pk)
- self._create_comicfts_entry(pk, entry, obj_list, status)
+ self._create_comicfts_entry(pk, entry, obj_list, status, designations)
return tuple(obj_list), pks
def _update_search_index_operate_update(
@@ -97,8 +144,9 @@ def _update_search_index_operate_update(
comicftss = ComicFTS.objects.filter(comic_id__in=pks)
if self.abort_event.is_set():
return tuple(obj_list), ()
+ designations = self._get_universe_designations(self.metadata[FTS_UPDATE])
for comicfts in comicftss:
- self._update_comicfts_entry(comicfts, obj_list, status)
+ self._update_comicfts_entry(comicfts, obj_list, status, designations)
if self.metadata[FTS_UPDATE]:
# If updates not popped, turn them into creates.
if FTS_CREATE not in self.metadata:
@@ -223,8 +271,10 @@ def import_search_index(self, cleaned_count: int) -> None:
if self.abort_event.is_set():
self.log.info("Search Index update aborted early.")
self.abort_event.clear()
- self.metadata.pop(FTS_EXISTING_M2MS, None)
- self.metadata.pop(FTS_CREATED_M2MS, None)
+ # An operate with no entries returns before it consumes its
+ # inputs; an import that rewrote no comics leaves them empty.
+ for key in (FTS_CREATE, FTS_UPDATE, FTS_UPDATED_M2MS, FTS_EXISTING_M2MS):
+ self.metadata.pop(key, None)
self.status_controller.finish_many(
(ImporterFTSCreateStatus, ImporterFTSUpdateStatus)
)
diff --git a/codex/librarian/scribe/importer/tasks.py b/codex/librarian/scribe/importer/tasks.py
index c8e6a8bc9..b52b8a807 100644
--- a/codex/librarian/scribe/importer/tasks.py
+++ b/codex/librarian/scribe/importer/tasks.py
@@ -2,6 +2,7 @@
from collections.abc import Mapping
from dataclasses import dataclass, field
+from typing import Any
from codex.librarian.scribe.tasks import ScribeTask
@@ -31,6 +32,14 @@ class ImportTask(ScribeTask):
force_import_metadata: bool = False
check_metadata_mtime: bool = True
+ # path -> series attributes laid over the series that file names.
+ #
+ # Set only by the TagWriter's re-read, for values a tag write was
+ # given but no format it writes can hold. Applying them during the
+ # import, rather than to the comic's series row beforehand, lands them
+ # on the row the comic ends up in: a write that names a new publisher
+ # or series moves the comic to a different row.
+ series_attrs: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
# Keep vanished rows instead of deleting them.
#
# Set by the two filesystem scanners, the poller and the watcher,
diff --git a/codex/librarian/scribe/search/const.py b/codex/librarian/scribe/search/const.py
index 812d40e84..1dfbede7a 100644
--- a/codex/librarian/scribe/search/const.py
+++ b/codex/librarian/scribe/search/const.py
@@ -11,6 +11,7 @@
"publisher",
"imprint",
"series",
+ "series_summary",
"age_rating_tagged",
"age_rating_metron",
"country",
diff --git a/codex/librarian/scribe/search/prepare.py b/codex/librarian/scribe/search/prepare.py
index 449007b72..96f4a4259 100644
--- a/codex/librarian/scribe/search/prepare.py
+++ b/codex/librarian/scribe/search/prepare.py
@@ -24,6 +24,7 @@
"fts_publisher",
"fts_imprint",
"fts_series",
+ "fts_series_summary",
"fts_age_rating_tagged",
"fts_age_rating_metron",
"fts_original_format",
diff --git a/codex/librarian/scribe/search/sync.py b/codex/librarian/scribe/search/sync.py
index 559b3c1fd..2a57b3a3d 100644
--- a/codex/librarian/scribe/search/sync.py
+++ b/codex/librarian/scribe/search/sync.py
@@ -55,6 +55,8 @@
# The second traverses the AgeRating.metron FK into AgeRatingMetron.
"fts_age_rating_tagged": F("age_rating__name"),
"fts_age_rating_metron": F("age_rating__metron__name"),
+ # Series.summary is the one non-name FK value in the index.
+ "fts_series_summary": F("series__summary"),
}
)
# Alias → FK-traversal path. The alias becomes the FTS column name
diff --git a/codex/librarian/scribe/tag_writer.py b/codex/librarian/scribe/tag_writer.py
index 3c66a69fe..6b99eaf72 100644
--- a/codex/librarian/scribe/tag_writer.py
+++ b/codex/librarian/scribe/tag_writer.py
@@ -35,6 +35,7 @@
predict_name,
will_convert,
)
+from codex.librarian.scribe.tagwrite_series_attrs import patch_series_attrs
from codex.librarian.scribe.timestamp_update import TimestampUpdater
from codex.librarian.worker import WorkerStatusAbortableBase
from codex.models.comic import Comic
@@ -297,7 +298,7 @@ def write_tags(self, task: BulkTagWriteTask) -> None:
)
self._enqueue_rereads(
- current_paths, written_paths, converted_paths, post_renamed, lib_of
+ task, current_paths, written_paths, converted_paths, post_renamed, lib_of
)
num_renamed = len(renamed_paths) + len(post_renamed)
reason = (
@@ -675,6 +676,7 @@ def _sync_conversions(
def _enqueue_rereads(
self,
+ task: BulkTagWriteTask,
current_paths: dict[int, Path],
written_paths: dict[int, Path],
converted_paths: dict[int, Path],
@@ -690,19 +692,29 @@ def _enqueue_rereads(
eventually re-read from the watcher's own modify event, but only
with the import-metadata flag on, so the re-read is requested
either way.
+
+ Each comic's patch may carry series attributes the written formats
+ can't hold. They ride along for the importer to apply (see
+ ``tagwrite_series_attrs``).
"""
modified: defaultdict[int, set[str]] = defaultdict(set)
created: defaultdict[int, set[str]] = defaultdict(set)
+ series_attrs: defaultdict[int, dict[str, dict]] = defaultdict(dict)
for pk, written_path in written_paths.items():
library_id = lib_of[pk]
if pk in converted_paths:
- modified[library_id].add(str(converted_paths[pk]))
+ path = str(converted_paths[pk])
+ modified[library_id].add(path)
elif written_path != current_paths[pk]:
# A kept original's new CBZ is a file the database has
# never seen.
- created[library_id].add(str(post_renamed.get(pk, written_path)))
+ path = str(post_renamed.get(pk, written_path))
+ created[library_id].add(path)
else:
- modified[library_id].add(str(written_path))
+ path = str(written_path)
+ modified[library_id].add(path)
+ if attrs := patch_series_attrs(self._patch_for(task, pk)):
+ series_attrs[library_id][path] = attrs
# A rename with no write changed no metadata, so nothing to re-read.
for library_id in modified.keys() | created.keys():
import_task = ImportTask(
@@ -711,5 +723,6 @@ def _enqueue_rereads(
files_created=frozenset(created.get(library_id, ())),
force_import_metadata=True,
check_metadata_mtime=False,
+ series_attrs=series_attrs.get(library_id, {}),
)
self.librarian_queue.put(import_task)
diff --git a/codex/librarian/scribe/tagwrite_series_attrs.py b/codex/librarian/scribe/tagwrite_series_attrs.py
new file mode 100644
index 000000000..fbe693f13
--- /dev/null
+++ b/codex/librarian/scribe/tagwrite_series_attrs.py
@@ -0,0 +1,57 @@
+"""
+Series attributes a tag write saves to the database.
+
+An online match brings the series' years, status and summary, but no
+format codex writes holds the end year, the status or the summary, and
+ComicInfo has no start year either. The re-read that follows a write
+only sees the file, so on their own these values would be lost.
+
+The tag writer hands them to that re-read instead, and the importer lays
+them over the series the file names. That puts them on the row the comic
+lands on, which is a different row whenever the write names a new
+publisher or series. The importer's batch rules settle comics of one
+series that disagree: the earliest start year, the latest end year and
+the first comic's status and summary. A later write's values replace a
+stored row's. Empty values are left out, so a write never clears one.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Final
+
+from comicbox.enums.comicbox import SeriesStatusEnum
+from comicbox.formats.comicbox.schema import SERIES_KEY
+
+from codex.librarian.scribe.importer.const import (
+ END_YEAR_FIELD_NAME,
+ START_YEAR_FIELD_NAME,
+ STATUS_FIELD_NAME,
+ SUMMARY_FIELD_NAME,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+_SERIES_ATTR_KEYS: Final = (
+ START_YEAR_FIELD_NAME,
+ END_YEAR_FIELD_NAME,
+ STATUS_FIELD_NAME,
+ SUMMARY_FIELD_NAME,
+)
+
+
+def _status_value(status: Any) -> str | None:
+ """Return a status as ``SeriesStatusChoices`` stores it, or None."""
+ try:
+ return SeriesStatusEnum(status).value
+ except ValueError:
+ return None
+
+
+def patch_series_attrs(patch: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Return the non-empty series attributes a tag write patch carries."""
+ series = (patch or {}).get(SERIES_KEY) or {}
+ attrs = {key: series.get(key) for key in _SERIES_ATTR_KEYS}
+ if attrs[STATUS_FIELD_NAME] is not None:
+ attrs[STATUS_FIELD_NAME] = _status_value(attrs[STATUS_FIELD_NAME])
+ return {key: value for key, value in attrs.items() if value not in (None, "")}
diff --git a/codex/migrations/0058_post_v2_5_5_schema.py b/codex/migrations/0058_post_v2_5_5_schema.py
new file mode 100644
index 000000000..646e3bde8
--- /dev/null
+++ b/codex/migrations/0058_post_v2_5_5_schema.py
@@ -0,0 +1,148 @@
+"""
+Everything the schema gained after v2.5.5, as one migration.
+
+v2.5.5 shipped through 0057, so nothing here has reached a release. The three
+migrations that landed on develop since are folded together: an upgrading
+install applies one migration instead of a chain of three.
+
+In order:
+
+- **Series get a year range, a publication status and a summary.** Comicbox's
+ series carries them (status is its SeriesStatusEnum, from Metron) and the
+ importer merges them per series. The years are nullable and the text columns
+ default empty, so existing rows stay empty until their comics are
+ re-imported.
+- **The browser filters gain series start year, end year and status**, so the
+ per-user filter settings get a column for each.
+- **The search index gains ``series_summary``.** ``ComicFTS`` is unmanaged, so
+ makemigrations never sees it: the column list lives in SQL and has to be
+ dropped and recreated by hand, as 0049 did for ``alternate_series``. The
+ table comes back empty; the librarian's regular ``SearchIndexSyncTask``
+ repopulates it.
+- **Comics scanned with tag import on are marked import-complete.** Before
+ this release only a forced or lazy import stamped
+ ``Comic.metadata_imported_at``. A library scan with the import-tags flag on
+ read every comic but stamped none, so a comic with no embedded metadata file
+ read as un-imported forever: hovering its tag button re-imported it, purged
+ and rebuilt its cover and reloaded the browser. 0043 backfilled the rows
+ that existed then; this backfills the ones scanned since, with the same
+ seed. Only when the import-tags flag is on: a scan with it off read no tags,
+ and its NULL rows are what the lazy importer is for. A missing flag row
+ counts as on, its default.
+
+Every step reverses except the backfill, whose reverse is a no-op.
+"""
+
+from django.db import migrations, models
+from django.db.models.functions import Coalesce
+
+import codex.models.fields
+
+# 0049's column list, for the reverse migration.
+_OLD_FTS_SQL = (
+ "CREATE VIRTUAL TABLE codex_comicfts USING fts5("
+ "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, "
+ "publisher, imprint, series, name, collection_title, "
+ "age_rating_tagged, age_rating_metron, country, language, "
+ "original_format, review, scan_info, summary, tagger, "
+ "alternate_series, characters, "
+ "credits, genres, locations, roles, series_groups, stories, "
+ "story_arcs, tags, teams, universes, sources"
+ ")"
+)
+# 0049's column list plus ``series_summary``.
+_NEW_FTS_SQL = (
+ "CREATE VIRTUAL TABLE codex_comicfts USING fts5("
+ "comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, "
+ "publisher, imprint, series, name, collection_title, "
+ "age_rating_tagged, age_rating_metron, country, language, "
+ "original_format, review, scan_info, summary, series_summary, tagger, "
+ "alternate_series, characters, "
+ "credits, genres, locations, roles, series_groups, stories, "
+ "story_arcs, tags, teams, universes, sources"
+ ")"
+)
+_DROP_FTS_SQL = "DROP TABLE IF EXISTS codex_comicfts;"
+
+# AdminFlagChoices.IMPORT_METADATA, frozen at this migration's point in time.
+_IMPORT_METADATA_KEY = "IM"
+
+
+def _backfill_metadata_imported_at(apps, _schema_editor) -> None:
+ """Stamp un-stamped comics if library scans import tags."""
+ admin_flag_model = apps.get_model("codex", "adminflag")
+ flag = admin_flag_model.objects.filter(key=_IMPORT_METADATA_KEY).first()
+ if flag is not None and not flag.on:
+ return
+ comic_model = apps.get_model("codex", "comic")
+ comic_model.objects.filter(metadata_imported_at__isnull=True).update(
+ metadata_imported_at=Coalesce("metadata_mtime", "updated_at")
+ )
+
+
+class Migration(migrations.Migration):
+ """Series fields and filters, FTS series_summary, imported-at backfill."""
+
+ dependencies = [
+ ("codex", "0057_remove_pinned_settings"),
+ ]
+
+ operations = [
+ # Series year range, status and summary.
+ migrations.AddField(
+ model_name="series",
+ name="end_year",
+ field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True),
+ ),
+ migrations.AddField(
+ model_name="series",
+ name="start_year",
+ field=codex.models.fields.CoercingPositiveSmallIntegerField(null=True),
+ ),
+ migrations.AddField(
+ model_name="series",
+ name="status",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("Ongoing", "Ongoing"),
+ ("Cancelled", "Cancelled"),
+ ("Completed", "Completed"),
+ ("Hiatus", "Hiatus"),
+ ],
+ default="",
+ max_length=9,
+ ),
+ ),
+ migrations.AddField(
+ model_name="series",
+ name="summary",
+ field=codex.models.fields.CleaningTextField(
+ blank=True, db_collation="nocase", default=""
+ ),
+ ),
+ # Series filter columns, then the FTS series_summary column.
+ migrations.AddField(
+ model_name="settingsbrowserfilters",
+ name="series_end_year",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AddField(
+ model_name="settingsbrowserfilters",
+ name="series_start_year",
+ field=models.JSONField(default=list),
+ ),
+ migrations.AddField(
+ model_name="settingsbrowserfilters",
+ name="series_status",
+ field=models.JSONField(default=list),
+ ),
+ # ComicFTS is unmanaged; its Django state tracks only (comic,
+ # created_at, updated_at). The FTS columns are a SQL-level concern —
+ # no state_operations are needed, only a raw DROP + CREATE. Unlike
+ # 0049, the reverse restores the previous table rather than none.
+ migrations.RunSQL(sql=_DROP_FTS_SQL, reverse_sql=_OLD_FTS_SQL),
+ migrations.RunSQL(sql=_NEW_FTS_SQL, reverse_sql=_DROP_FTS_SQL),
+ # The metadata_imported_at backfill.
+ migrations.RunPython(_backfill_metadata_imported_at, migrations.RunPython.noop),
+ ]
diff --git a/codex/models/choices.py b/codex/models/choices.py
index 46557bc91..a4a09a1b0 100644
--- a/codex/models/choices.py
+++ b/codex/models/choices.py
@@ -4,7 +4,12 @@
from enum import Enum, _EnumDict
from typing import cast
-from comicbox.enums.comicbox import FileTypeEnum, MangaEnum, ReadingDirectionEnum
+from comicbox.enums.comicbox import (
+ FileTypeEnum,
+ MangaEnum,
+ ReadingDirectionEnum,
+ SeriesStatusEnum,
+)
from django.db.models import Choices, TextChoices
from django.db.models.enums import ChoicesType
@@ -53,3 +58,4 @@ def max_choices_len(choices: type[Choices]) -> int:
FileTypeChoices = text_choices_from_enum(FileTypeEnum)
MangaChoices = text_choices_from_enum(MangaEnum)
ReadingDirectionChoices = text_choices_from_enum(ReadingDirectionEnum)
+SeriesStatusChoices = text_choices_from_enum(SeriesStatusEnum)
diff --git a/codex/models/collections.py b/codex/models/collections.py
index de87300cb..a9f304f76 100644
--- a/codex/models/collections.py
+++ b/codex/models/collections.py
@@ -2,10 +2,15 @@
from typing import override
-from django.db.models import CASCADE, SET_DEFAULT, ForeignKey, Index, Q
+from django.db.models import CASCADE, SET_DEFAULT, CharField, ForeignKey, Index, Q
from codex.models.base import MAX_NAME_LEN, BaseModel
-from codex.models.fields import CleaningCharField, CoercingPositiveSmallIntegerField
+from codex.models.choices import SeriesStatusChoices, max_choices_len
+from codex.models.fields import (
+ CleaningCharField,
+ CleaningTextField,
+ CoercingPositiveSmallIntegerField,
+)
from codex.models.identifier import Identifier
from codex.models.paths import CustomCover, WatchedPath
from codex.models.util import get_sort_name
@@ -122,6 +127,15 @@ class Series(IdentifiedBrowserCollectionModel):
publisher = ForeignKey(Publisher, on_delete=CASCADE)
imprint = ForeignKey(Imprint, on_delete=CASCADE)
volume_count = CoercingPositiveSmallIntegerField(null=True)
+ start_year = CoercingPositiveSmallIntegerField(null=True)
+ end_year = CoercingPositiveSmallIntegerField(null=True)
+ status = CharField(
+ blank=True,
+ choices=SeriesStatusChoices.choices,
+ default="",
+ max_length=max_choices_len(SeriesStatusChoices),
+ )
+ summary = CleaningTextField(blank=True, default="", db_collation="nocase")
class Meta(IdentifiedBrowserCollectionModel.Meta):
"""Constraints."""
diff --git a/codex/models/comic.py b/codex/models/comic.py
index 5b68b9ee3..c55661b5e 100644
--- a/codex/models/comic.py
+++ b/codex/models/comic.py
@@ -440,6 +440,7 @@ class ComicFTS(BaseModel):
name = CharField(db_collation="nocase", max_length=MAX_NAME_LEN)
review = TextField(db_collation="nocase")
summary = TextField(db_collation="nocase")
+ series_summary = TextField(db_collation="nocase")
# FK collections
publisher = CharField(db_collation="nocase", max_length=MAX_NAME_LEN)
imprint = CharField(db_collation="nocase", max_length=MAX_NAME_LEN)
diff --git a/codex/models/settings.py b/codex/models/settings.py
index 7b776414b..d0f8406f5 100644
--- a/codex/models/settings.py
+++ b/codex/models/settings.py
@@ -238,7 +238,10 @@ class SettingsBrowserFilters(BaseModel):
original_format = JSONField(default=list)
reading_direction = JSONField(default=list)
reprints = JSONField(default=list)
+ series_end_year = JSONField(default=list)
series_groups = JSONField(default=list)
+ series_start_year = JSONField(default=list)
+ series_status = JSONField(default=list)
stories = JSONField(default=list)
story_arcs = JSONField(default=list)
tagger = JSONField(default=list)
@@ -267,7 +270,10 @@ class SettingsBrowserFilters(BaseModel):
"original_format",
"reading_direction",
"reprints",
+ "series_end_year",
"series_groups",
+ "series_start_year",
+ "series_status",
"stories",
"story_arcs",
"tagger",
diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py
index 2af06f1bd..524f2bfae 100644
--- a/codex/serializers/admin/tagging.py
+++ b/codex/serializers/admin/tagging.py
@@ -17,6 +17,7 @@
from codex.librarian.onlinetag.credential_validator import KNOWN_SOURCES
from codex.models import ComicboxTaggingDefaults
+from codex.models.choices import SeriesStatusChoices
from codex.serializers.models.base import BaseModelSerializer
@@ -55,6 +56,27 @@ class TagWriteRequestSerializer(Serializer):
rename = BooleanField(required=False, default=None)
+class SeriesAttrsRequestSerializer(Serializer):
+ """
+ A series' Codex-only attributes, edited from the tag editor.
+
+ No format codex writes holds a series' end year, status or summary, so
+ these go straight to the Series row. Only the keys sent change; null or
+ "" clears one.
+ """
+
+ collection = CharField()
+ pks = ListField(child=CharField())
+ # MetronInfo's StartYear bounds, so the pair stays comparable.
+ end_year = IntegerField(
+ required=False, allow_null=True, min_value=1000, max_value=9999
+ )
+ status = ChoiceField(
+ choices=SeriesStatusChoices.choices, required=False, allow_blank=True
+ )
+ summary = CharField(required=False, allow_blank=True)
+
+
class OnlineTagStartSerializer(Serializer):
"""Serializer for starting an online tagging session."""
diff --git a/codex/serializers/browser/choices.py b/codex/serializers/browser/choices.py
index c7504a739..84d5baf9f 100644
--- a/codex/serializers/browser/choices.py
+++ b/codex/serializers/browser/choices.py
@@ -23,6 +23,7 @@
VuetifyFileTypeChoiceField,
VuetifyReadingDirectionChoiceField,
VuetifyReadOnlyListField,
+ VuetifySeriesStatusChoiceField,
)
from codex.serializers.models.pycountry import CountrySerializer, LanguageSerializer
@@ -46,7 +47,10 @@ class BrowserFilterChoicesSerializer(Serializer):
original_format = BooleanField(read_only=True)
reading_direction = BooleanField(read_only=True)
reprints = BooleanField(read_only=True)
+ series_end_year = BooleanField(read_only=True)
series_groups = BooleanField(read_only=True)
+ series_start_year = BooleanField(read_only=True)
+ series_status = BooleanField(read_only=True)
stories = BooleanField(read_only=True)
story_arcs = BooleanField(read_only=True)
tagger = BooleanField(read_only=True)
@@ -81,7 +85,10 @@ class BrowserSettingsFilterSerializer(Serializer):
child=VuetifyReadingDirectionChoiceField
)
reprints = VuetifyReadOnlyListField()
+ series_end_year = VuetifyReadOnlyListField()
series_groups = VuetifyReadOnlyListField()
+ series_start_year = VuetifyReadOnlyListField()
+ series_status = VuetifyReadOnlyListField(child=VuetifySeriesStatusChoiceField)
stories = VuetifyReadOnlyListField()
story_arcs = VuetifyReadOnlyListField()
tagger = VuetifyReadOnlyListField()
@@ -141,6 +148,12 @@ class BrowserChoicesCharPkSerializer(BrowserChoicesIntegerPkSerializer):
pk = VuetifyCharField(read_only=True)
+class BrowserChoicesSeriesStatusPkSerializer(BrowserChoicesIntegerPkSerializer):
+ """Status strings, but the "None" row keeps the int null code."""
+
+ pk = VuetifySeriesStatusChoiceField(read_only=True)
+
+
class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer):
"""Named Model Serailizer with pk = char hack for languages & countries."""
@@ -157,10 +170,20 @@ class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer):
"file_type": BrowserChoicesCharPkSerializer,
"language": LanguageSerializer,
"reprints": BrowserChoicesReprintPkSerializer,
+ "series_status": BrowserChoicesSeriesStatusPkSerializer,
"universe": BrowserChoicesUniversePkSerializer,
}
)
-_LIST_FIELDS = frozenset({"decade", "monochrome", "reading_direction", "year"})
+_LIST_FIELDS = frozenset(
+ {
+ "decade",
+ "monochrome",
+ "reading_direction",
+ "series_end_year",
+ "series_start_year",
+ "year",
+ }
+)
# ``BrowserSettingsFilterSerializer().get_fields()`` walks DRF's
# metaclass and instantiates a serializer just to look up four field
# representations. The fields are class-level definitions, so resolve
diff --git a/codex/serializers/browser/filters.py b/codex/serializers/browser/filters.py
index 233011d8a..f08ab817e 100644
--- a/codex/serializers/browser/filters.py
+++ b/codex/serializers/browser/filters.py
@@ -12,6 +12,7 @@
VuetifyFileTypeChoiceField,
VuetifyListField,
VuetifyReadingDirectionChoiceField,
+ VuetifySeriesStatusChoiceField,
)
@@ -39,7 +40,10 @@ class BrowserSettingsFilterInputSerializer(Serializer):
original_format = VuetifyListField()
reading_direction = VuetifyListField(child=VuetifyReadingDirectionChoiceField)
reprints = VuetifyListField()
+ series_end_year = VuetifyListField()
series_groups = VuetifyListField()
+ series_start_year = VuetifyListField()
+ series_status = VuetifyListField(child=VuetifySeriesStatusChoiceField)
stories = VuetifyListField()
story_arcs = VuetifyListField()
tagger = VuetifyListField()
diff --git a/codex/serializers/browser/metadata.py b/codex/serializers/browser/metadata.py
index 0f77020e6..dcfc24401 100644
--- a/codex/serializers/browser/metadata.py
+++ b/codex/serializers/browser/metadata.py
@@ -177,6 +177,10 @@ class MetadataSerializer(BrowserAggregateSerializerMixin, ComicSerializer):
file_name = CharField(read_only=True)
parent_folder_id = IntegerField(read_only=True, required=False)
series_volume_count = IntegerField(read_only=True)
+ series_start_year = IntegerField(read_only=True)
+ series_end_year = IntegerField(read_only=True)
+ series_status = CharField(read_only=True)
+ series_summary = CharField(read_only=True)
volume_issue_count = IntegerField(read_only=True)
# cover_pk and cover_custom_pk are annotated on collection queryset by
diff --git a/codex/serializers/browser/page.py b/codex/serializers/browser/page.py
index eb68a6ab9..291ff4c40 100644
--- a/codex/serializers/browser/page.py
+++ b/codex/serializers/browser/page.py
@@ -154,6 +154,8 @@ class BrowserTitleSerializer(Serializer):
collection_name = CharField(read_only=True)
collection_number_to = CharField(read_only=True)
collection_count = IntegerField(read_only=True, allow_null=True)
+ collection_start_year = IntegerField(read_only=True, allow_null=True)
+ collection_end_year = IntegerField(read_only=True, allow_null=True)
def _emit_cover(row, instance) -> None:
diff --git a/codex/serializers/fields/vuetify.py b/codex/serializers/fields/vuetify.py
index 73150460d..3f9be6e35 100644
--- a/codex/serializers/fields/vuetify.py
+++ b/codex/serializers/fields/vuetify.py
@@ -14,7 +14,11 @@
)
from codex.choices.browser import VUETIFY_NULL_CODE
-from codex.models.choices import FileTypeChoices, ReadingDirectionChoices
+from codex.models.choices import (
+ FileTypeChoices,
+ ReadingDirectionChoices,
+ SeriesStatusChoices,
+)
from codex.serializers.fields.base import CodexChoiceField
@@ -47,6 +51,25 @@ class VuetifyReadingDirectionChoiceField(VuetifyNullCodeFieldMixin, CodexChoiceF
class_choices = ReadingDirectionChoices.values
+class VuetifySeriesStatusChoiceField(VuetifyNullCodeFieldMixin, CodexChoiceField): # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override]
+ """
+ Series Status Choice Field.
+
+ Its null code is the "None" choice for a blank status. It converts
+ before the choice check, which would reject it, and serializes back
+ to the int the frontend recognizes.
+ """
+
+ class_choices = SeriesStatusChoices.values
+
+ @override
+ def to_internal_value(self, data) -> str | None: # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override]
+ """Convert the null code to None, otherwise validate the choice."""
+ if data in (VUETIFY_NULL_CODE, str(VUETIFY_NULL_CODE)):
+ return None
+ return super().to_internal_value(data)
+
+
class VuetifyDecimalField(VuetifyNullCodeFieldMixin, DecimalField): # pyright: ignore[reportIncompatibleMethodOverride], # ty: ignore[invalid-method-override]
"""Float Field with null code conversion."""
diff --git a/codex/urls/api/v4/admin.py b/codex/urls/api/v4/admin.py
index c902d33be..017044a9b 100644
--- a/codex/urls/api/v4/admin.py
+++ b/codex/urls/api/v4/admin.py
@@ -55,6 +55,7 @@
from codex.views.admin.tagging_validate import AdminTaggingValidateView
from codex.views.admin.tagwrite import (
AdminParseIdentifierURLView,
+ AdminSeriesAttrsView,
AdminTagWritePreflightView,
AdminTagWriteView,
)
@@ -323,6 +324,11 @@
AdminTagWriteView.as_view(),
name="tag_write",
),
+ path(
+ "series-attrs",
+ AdminSeriesAttrsView.as_view(),
+ name="series_attrs",
+ ),
path(
"folders",
AdminFolderListView.as_view(),
diff --git a/codex/user_data/schema.sql b/codex/user_data/schema.sql
index 52e00a1b7..ff76bcbf7 100644
--- a/codex/user_data/schema.sql
+++ b/codex/user_data/schema.sql
@@ -126,7 +126,10 @@ CREATE TABLE IF NOT EXISTS settings_filters (
original_format TEXT NOT NULL DEFAULT '[]',
reading_direction TEXT NOT NULL DEFAULT '[]',
reprints TEXT NOT NULL DEFAULT '[]',
+ series_end_year TEXT NOT NULL DEFAULT '[]',
series_groups TEXT NOT NULL DEFAULT '[]',
+ series_start_year TEXT NOT NULL DEFAULT '[]',
+ series_status TEXT NOT NULL DEFAULT '[]',
stories TEXT NOT NULL DEFAULT '[]',
story_arcs TEXT NOT NULL DEFAULT '[]',
tagger TEXT NOT NULL DEFAULT '[]',
diff --git a/codex/util.py b/codex/util.py
index f4a0c897a..64b0556d4 100644
--- a/codex/util.py
+++ b/codex/util.py
@@ -53,6 +53,16 @@ def max_none(*args):
return max((x for x in args if x is not None), default=None)
+def min_none(*args):
+ """None aware math.min."""
+ return min((x for x in args if x is not None), default=None)
+
+
+def first_none(*args):
+ """None aware coalesce: the first argument that isn't None."""
+ return next((x for x in args if x is not None), None)
+
+
def mapping_to_dict(data) -> dict | set | frozenset | tuple | list:
"""Convert nested Mapping objects to dicts."""
if isinstance(data, Mapping):
diff --git a/codex/views/admin/tagwrite.py b/codex/views/admin/tagwrite.py
index 188497d57..090087870 100644
--- a/codex/views/admin/tagwrite.py
+++ b/codex/views/admin/tagwrite.py
@@ -4,8 +4,9 @@
from functools import cached_property
from pathlib import Path
from types import MappingProxyType
-from typing import override
+from typing import Final, override
+from django.utils import timezone
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.status import HTTP_202_ACCEPTED
@@ -19,8 +20,12 @@
)
from codex.librarian.scribe.tasks import BulkTagWriteTask
from codex.models.admin import ComicboxTaggingDefaults
-from codex.models.comic import Comic
-from codex.serializers.admin.tagging import TagWriteRequestSerializer
+from codex.models.collections import Series
+from codex.models.comic import Comic, ComicFTS
+from codex.serializers.admin.tagging import (
+ SeriesAttrsRequestSerializer,
+ TagWriteRequestSerializer,
+)
from codex.views.admin.auth import AdminAPIView
from codex.views.admin.identifier_parse import parse_identifier_url
from codex.views.browser.filters.filter import BrowserFilterView
@@ -28,6 +33,8 @@
#: Cap on how many per-comic rename previews the preflight builds — each opens
#: an archive, so a huge multi-selection can't stall the request.
_FILENAME_PREVIEW_LIMIT = 100
+#: Series attributes the tag editor saves to the database only.
+_SERIES_ATTRS: Final = ("end_year", "status", "summary")
class FilteredComicPksView(BrowserFilterView):
@@ -248,3 +255,44 @@ def post(self, request):
},
status=HTTP_202_ACCEPTED,
)
+
+
+class AdminSeriesAttrsView(FilteredComicPksView):
+ """
+ POST a series' end year, status or summary straight to the database.
+
+ No format codex writes can hold them, so unlike a tag write nothing
+ touches the comic files. Re-imports leave the values standing, because
+ the importer ignores a series attribute a file doesn't carry.
+ """
+
+ def post(self, request):
+ """Update the one series the selected comics belong to."""
+ serializer = SeriesAttrsRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ data = serializer.validated_data
+ attrs = {key: data[key] for key in _SERIES_ATTRS if key in data}
+ if not attrs:
+ return Response({"detail": "No series attributes sent."}, status=400)
+
+ comic_pks = self.resolve_comic_pks(data["collection"], data["pks"])
+ if not comic_pks:
+ return Response({"detail": "No comics matched."}, status=400)
+ series_pks = tuple(
+ Series.objects.filter(comic__pk__in=comic_pks)
+ .values_list("pk", flat=True)
+ .distinct()
+ )
+ if len(series_pks) != 1:
+ return Response({"detail": "Edit one series at a time."}, status=400)
+
+ series_pk = series_pks[0]
+ Series.objects.filter(pk=series_pk).update(**attrs, updated_at=timezone.now())
+ if "summary" in attrs:
+ # Search reads the summary from each comic's index row. Rewrite
+ # just that column in place: bumping the comics' updated_at for
+ # a full re-index would churn every comic of the series.
+ ComicFTS.objects.filter(comic__series_id=series_pk).update(
+ series_summary=attrs["summary"]
+ )
+ return Response({"detail": "Series details saved.", "series": series_pk})
diff --git a/codex/views/browser/choices.py b/codex/views/browser/choices.py
index b39158df7..f2bc8abda 100644
--- a/codex/views/browser/choices.py
+++ b/codex/views/browser/choices.py
@@ -5,7 +5,7 @@
from typing import Any, cast, override
from caseconverter import snakecase
-from django.db.models import Exists, F, QuerySet
+from django.db.models import Exists, F, Q, QuerySet
from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
from rest_framework.serializers import BaseSerializer
@@ -73,6 +73,22 @@
IdentifierSource: "identifier__",
}
)
+# Series attributes, reached across the series FK. They aren't Comic fields,
+# so ``get_rel_and_model`` can't look them up on the model.
+_SERIES_ATTR_FIELD_RELS = MappingProxyType(
+ {
+ "series_end_year": "series__end_year",
+ "series_start_year": "series__start_year",
+ "series_status": "series__status",
+ }
+)
+# Plain columns whose "None" is a blank string, never NULL.
+_BLANK_IS_NULL_RELS = frozenset({"series__status"})
+# Plain fields that offer a "None" choice, so they take the m2m
+# availability rule: two distinct values, or a value and a "None".
+_NULL_CHOICE_FIELDS = frozenset({"series_status"})
+# Plain fields whose choices are serialized as {pk, name} rows.
+_NAMED_ROW_CHOICE_FIELDS = frozenset({"community_rating", "file_type", "series_status"})
_NULL_NAMED_ROW = MappingProxyType({"pk": VUETIFY_NULL_CODE, "name": DUMMY_NULL_NAME})
_NULL_NAMED_ROW_ITERABLE = (_NULL_NAMED_ROW,)
# We only need to know whether an m2m relation has two or more distinct
@@ -87,9 +103,25 @@ class BrowserChoicesViewBase(BrowserFilterView):
TARGET: str = "choices"
@staticmethod
- def get_field_choices_query(comic_qs, field_name):
+ def present(comic_qs: QuerySet, rel: str) -> QuerySet:
+ """Comics with a value for the relation."""
+ comic_qs = comic_qs.filter(**{f"{rel}__isnull": False})
+ if rel in _BLANK_IS_NULL_RELS:
+ comic_qs = comic_qs.exclude(**{rel: ""})
+ return comic_qs
+
+ @staticmethod
+ def absent(comic_qs: QuerySet, rel: str) -> QuerySet:
+ """Comics without a value for the relation, the "None" choice."""
+ query = Q(**{f"{rel}__isnull": True})
+ if rel in _BLANK_IS_NULL_RELS:
+ query |= Q(**{rel: ""})
+ return comic_qs.filter(query)
+
+ @classmethod
+ def get_field_choices_query(cls, comic_qs, rel):
"""Get distinct values for the field."""
- return comic_qs.exclude(**{f"{field_name}__isnull": True}).distinct()
+ return cls.present(comic_qs, rel).distinct()
def get_m2m_field_query(self, model, comic_qs: QuerySet):
"""Get distinct m2m value objects for the relation."""
@@ -97,17 +129,19 @@ def get_m2m_field_query(self, model, comic_qs: QuerySet):
m2m_filter = {f"{back_rel}comic__in": comic_qs}
return model.objects.filter(**m2m_filter).distinct()
- @staticmethod
- def does_m2m_null_exist(comic_qs, rel):
- """Get if comics exist in the filter without values exists for an m2m field."""
+ @classmethod
+ def does_null_exist(cls, comic_qs, rel):
+ """Get if comics exist in the filter without values for the field."""
# Detect if there are null choices. Regretably with another query.
- return comic_qs.filter(**{f"{rel}__isnull": True}).exists()
+ return cls.absent(comic_qs, rel).exists()
def get_rel_and_model(self, field_name) -> tuple:
"""Return the relation and model for the field name."""
rel_and_model = _FIELD_TO_REL_MODEL_MAP.get(field_name)
if rel_and_model:
rel, model = rel_and_model
+ elif rel := _SERIES_ATTR_FIELD_RELS.get(field_name):
+ model = None
else:
remote_field = getattr(
Comic._meta.get_field(field_name), "remote_field", None
@@ -135,8 +169,8 @@ class BrowserChoicesAvailableView(BrowserChoicesViewBase):
serializer_class: type[BaseSerializer] | None = BrowserFilterChoicesSerializer
- @staticmethod
- def _has_two_distinct_m2m_values(comic_qs: QuerySet, rel: str) -> bool:
+ @classmethod
+ def _has_two_distinct_m2m_values(cls, comic_qs: QuerySet, rel: str) -> bool:
"""
Return True iff ``rel`` resolves to >= 2 distinct non-null values.
@@ -148,7 +182,7 @@ def _has_two_distinct_m2m_values(comic_qs: QuerySet, rel: str) -> bool:
rows scanned.
"""
first_two = list(
- comic_qs.filter(**{f"{rel}__isnull": False})
+ cls.present(comic_qs, rel)
.values_list(rel, flat=True)
.distinct()[:_M2M_DISTINCT_PROBE_LIMIT]
)
@@ -190,22 +224,21 @@ def _build_field_probes(
early_data[field_name] = True
continue
rel, m2m_model = self.get_rel_and_model(field_name)
- if m2m_model is None:
+ if m2m_model is None and field_name not in _NULL_CHOICE_FIELDS:
# FK on Comic: legacy semantic is "any non-null value exists".
- aggregates[field_name] = Exists(
- qs.filter(**{f"{field_name}__isnull": False}).values("pk")[:1]
- )
+ aggregates[field_name] = Exists(self.present(qs, rel).values("pk")[:1])
else:
- # m2m / m2m-through: legacy semantic is "(distinct rels >= 2)
+ # m2m / m2m-through and plain fields with a "None" choice:
+ # legacy semantic is "(distinct rels >= 2)
# OR (>= 1 rel AND a null sibling)". Decompose into two
# cheap EXISTS booleans; the rarer "1 rel, 0 null" case
# falls back to a per-field distinct-count probe below.
m2m_specs[field_name] = rel
aggregates[f"_has_{field_name}"] = Exists(
- qs.filter(**{f"{rel}__isnull": False}).values("pk")[:1]
+ self.present(qs, rel).values("pk")[:1]
)
aggregates[f"_null_{field_name}"] = Exists(
- qs.filter(**{f"{rel}__isnull": True}).values("pk")[:1]
+ self.absent(qs, rel).values("pk")[:1]
)
return early_data, aggregates, m2m_specs
@@ -287,7 +320,7 @@ def _get_m2m_field_choices(self, model, comic_qs, rel):
qs = qs.values(*values)
# Add null if it exists
- if self.does_m2m_null_exist(comic_qs, rel):
+ if self.does_null_exist(comic_qs, rel):
iterables.append(_NULL_NAMED_ROW_ITERABLE)
iterables.append(qs)
@@ -309,11 +342,13 @@ def get_object(self) -> dict[str, Any]: # pyright: ignore[reportIncompatibleMet
if m2m_model:
choices = self._get_m2m_field_choices(m2m_model, qs, rel)
else:
- choices = self.get_field_choices_query(qs, field_name)
- choices = choices.values_list(field_name, flat=True)
+ choices = self.get_field_choices_query(qs, rel)
+ choices = choices.values_list(rel, flat=True)
- if field_name in ("community_rating", "file_type"):
+ if field_name in _NAMED_ROW_CHOICE_FIELDS:
choices = tuple({"pk": choice, "name": choice} for choice in choices)
+ if field_name in _NULL_CHOICE_FIELDS and self.does_null_exist(qs, rel):
+ choices = (_NULL_NAMED_ROW, *choices)
return {
"field_name": field_name,
diff --git a/codex/views/browser/const.py b/codex/views/browser/const.py
index 29f64ab5d..a9933c077 100644
--- a/codex/views/browser/const.py
+++ b/codex/views/browser/const.py
@@ -17,7 +17,10 @@
"original_format",
"reading_direction",
"reprints",
+ "series_end_year",
"series_groups",
+ "series_start_year",
+ "series_status",
"stories",
"story_arcs",
"tagger",
diff --git a/codex/views/browser/filters/field.py b/codex/views/browser/filters/field.py
index 8a3b88867..ffc120a03 100644
--- a/codex/views/browser/filters/field.py
+++ b/codex/views/browser/filters/field.py
@@ -24,9 +24,15 @@
# via the ``age_rating__metron`` chain.
"age_rating_tagged": "age_rating",
"age_rating_metron": "age_rating__metron",
+ # Series attributes filter every comic of the series.
+ "series_end_year": "series__end_year",
+ "series_start_year": "series__start_year",
+ "series_status": "series__status",
}
)
_FILTER_ATTRIBUTES: frozenset[str] = frozenset(BROWSER_FILTER_KEYS)
+# Filters whose "None" choice is a blank string column, never NULL.
+_FILTER_BLANK_NULL_FIELDS: frozenset[str] = frozenset({"series_status"})
class ComicFieldFilterView(CollectionFilterView):
@@ -45,6 +51,14 @@ def _is_multivalued(comic_rel: str) -> bool:
break
return False
+ @staticmethod
+ def _null_q(field: str, rel: str) -> Q:
+ """Match the "None" choice for a filter field."""
+ query = Q(**{f"{rel}__isnull": True})
+ if field in _FILTER_BLANK_NULL_FIELDS:
+ query |= Q(**{rel: ""})
+ return query
+
@classmethod
def _filter_by_comic_field(cls, field, rel_prefix, filter_list) -> Q:
"""Filter by a comic any2many attribute."""
@@ -74,14 +88,14 @@ def _filter_by_comic_field(cls, field, rel_prefix, filter_list) -> Q:
# publishers browse over 18k comics: 52ms -> 32ms).
inner = Q()
if match_null:
- inner |= Q(**{f"{comic_rel}__isnull": True})
+ inner |= cls._null_q(field, comic_rel)
if non_null_filter_list:
inner |= Q(**{f"{comic_rel}__in": non_null_filter_list})
comic_pks = Comic.objects.filter(inner).values("pk")
return Q(**{f"{rel_prefix}pk__in": comic_pks})
if match_null:
- filter_query |= Q(**{f"{rel}__isnull": True})
+ filter_query |= cls._null_q(field, rel)
if non_null_filter_list:
filter_query |= Q(**{f"{rel}__in": non_null_filter_list})
return filter_query
diff --git a/codex/views/browser/filters/filter.py b/codex/views/browser/filters/filter.py
index b9bea4194..0b565ee6d 100644
--- a/codex/views/browser/filters/filter.py
+++ b/codex/views/browser/filters/filter.py
@@ -16,7 +16,8 @@
# on Comic. Field-list mirrors ``codex.views.browser.const.BROWSER_FILTER_KEYS``
# minus the local-column / FK keys (year, decade, country, language,
# community_rating, monochrome, original_format, file_type, reading_direction,
-# tagger, age_rating_metron, age_rating_tagged).
+# tagger, age_rating_metron, age_rating_tagged, series_end_year,
+# series_start_year, series_status).
_M2M_FILTER_KEYS: Final[frozenset[str]] = frozenset(
{
"characters",
diff --git a/codex/views/browser/filters/search/field/column.py b/codex/views/browser/filters/search/field/column.py
index 7f8b47489..21a4c255a 100644
--- a/codex/views/browser/filters/search/field/column.py
+++ b/codex/views/browser/filters/search/field/column.py
@@ -5,8 +5,11 @@
from django.db.models import CharField
from django.db.models.fields.related import ForeignKey, ManyToManyField
+from codex.models.collections import Series
from codex.models.comic import Comic
+# Series attributes searched on every comic of the series.
+_SERIES_ATTR_FIELDS = ("end_year", "start_year", "status", "summary")
_FIELD_TO_REL_SPAN_MAP = MappingProxyType(
{
"alternate_series": "reprints__series_name",
@@ -15,6 +18,7 @@
"identifiers": "identifiers__key",
"sources": "identifiers__source__name",
"story_arcs": "story_arc_number__story_arc__name",
+ **{f"series_{name}": f"series__{name}" for name in _SERIES_ATTR_FIELDS},
}
)
_FIELD_TYPE_MAP = MappingProxyType(
@@ -24,6 +28,10 @@
for field in Comic._meta.get_fields()
if field.concrete # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute]
},
+ **{
+ f"series_{name}": Series._meta.get_field(name).__class__
+ for name in _SERIES_ATTR_FIELDS
+ },
"role": ManyToManyField,
"issue": CharField,
}
diff --git a/codex/views/browser/filters/search/parse.py b/codex/views/browser/filters/search/parse.py
index 0318914e2..51a2c4562 100644
--- a/codex/views/browser/filters/search/parse.py
+++ b/codex/views/browser/filters/search/parse.py
@@ -48,6 +48,9 @@
"path",
"page_count",
"reading_direction",
+ "series_end_year",
+ "series_start_year",
+ "series_status",
"size",
"updated_at",
"volume",
diff --git a/codex/views/browser/metadata/annotate.py b/codex/views/browser/metadata/annotate.py
index 95f5e9fec..9b6d5baf8 100644
--- a/codex/views/browser/metadata/annotate.py
+++ b/codex/views/browser/metadata/annotate.py
@@ -1,6 +1,9 @@
"""Metadata Annotations."""
-from django.db.models import Count, IntegerField, Sum, Value
+from typing import cast
+
+from django.db.models import Count, IntegerField, Model, Sum, Value
+from django.db.models.constants import LOOKUP_SEP
from django.db.models.query import QuerySet
from codex.models import Comic
@@ -85,7 +88,12 @@ def _fetch_intersecting_values(
def _value_output_field(field: str):
if field.endswith("count"):
return IntegerField()
- return Comic._meta.get_field(field)
+ # Related values like series__start_year live on the related model.
+ model: type[Model] = Comic
+ *relations, field_name = field.split(LOOKUP_SEP)
+ for relation in relations:
+ model = cast("type[Model]", model._meta.get_field(relation).related_model)
+ return model._meta.get_field(field_name)
def _annotate_value_constants(
self, qs: QuerySet, single_value_specs, row
diff --git a/codex/views/browser/metadata/const.py b/codex/views/browser/metadata/const.py
index 3296189ba..8a71eccf0 100644
--- a/codex/views/browser/metadata/const.py
+++ b/codex/views/browser/metadata/const.py
@@ -20,11 +20,14 @@
############
# Annotate #
############
+# Comic value fields that collection models carry too (Series.summary is the
+# series' own), so collection panes annotate them under a prefix.
COMIC_VALUE_FIELDS_CONFLICTING = frozenset(
{
"created_at",
"name",
"path",
+ "summary",
"updated_at",
}
)
@@ -40,7 +43,16 @@
for field in Comic._meta.get_fields()
if not field.is_relation and field.name not in _DISABLED_VALUE_FIELD_NAMES
)
-COMIC_RELATED_VALUE_FIELDS = frozenset({"series__volume_count", "volume__issue_count"})
+COMIC_RELATED_VALUE_FIELDS = frozenset(
+ {
+ "series__volume_count",
+ "series__start_year",
+ "series__end_year",
+ "series__status",
+ "series__summary",
+ "volume__issue_count",
+ }
+)
SUM_FIELDS = frozenset({"page_count", "size"})
#########
diff --git a/codex/views/browser/title.py b/codex/views/browser/title.py
index dade93f82..647fe564a 100644
--- a/codex/views/browser/title.py
+++ b/codex/views/browser/title.py
@@ -1,10 +1,22 @@
"""Browser title."""
from collections.abc import Mapping
+from types import MappingProxyType
+from typing import Final
-from codex.models import Comic, Volume
+from codex.models import Comic, Series, Volume
from codex.views.browser.breadcrumbs import BrowserBreadcrumbsView
+_EMPTY_TITLE: Final = MappingProxyType(
+ {
+ "collection_name": "",
+ "collection_number_to": None,
+ "collection_count": 0,
+ "collection_start_year": None,
+ "collection_end_year": None,
+ }
+)
+
class BrowserTitleView(BrowserBreadcrumbsView):
"""Browser title methods."""
@@ -19,7 +31,7 @@ def _get_root_collection_name(self) -> tuple:
raise ValueError(reason)
return plural.capitalize(), 0
- def _get_collection_name(self) -> tuple:
+ def _get_collection_name(self) -> Mapping:
"""
Name the collection the route points at.
@@ -29,32 +41,27 @@ def _get_collection_name(self) -> tuple:
collection this user cannot see, so the empty defaults below are
now only the root-listing case (no pks).
"""
- collection_number_to = None
- collection_count = 0
- collection_name = ""
+ title = dict(_EMPTY_TITLE)
if gi := self.collection_instance:
- collection_name = gi.name
- if isinstance(gi, Volume):
- collection_number_to = gi.number_to
- collection_count = gi.series.volume_count
+ title["collection_name"] = gi.name
+ if isinstance(gi, Series):
+ title["collection_start_year"] = gi.start_year
+ title["collection_end_year"] = gi.end_year
+ elif isinstance(gi, Volume):
+ title["collection_number_to"] = gi.number_to
+ title["collection_count"] = gi.series.volume_count
elif isinstance(gi, Comic):
- collection_number_to = gi.volume.number_to
- collection_count = gi.volume.issue_count
- return collection_name, collection_number_to, collection_count
+ title["collection_number_to"] = gi.volume.number_to
+ title["collection_count"] = gi.volume.issue_count
+ return title
def get_browser_page_title(self) -> Mapping:
"""Get the proper title for this browse level."""
- pks = self.kwargs.get("pks")
- if not pks:
- collection_name, collection_count = self._get_root_collection_name()
- collection_number_to = None
- else:
- collection_name, collection_number_to, collection_count = (
- self._get_collection_name()
- )
-
+ if self.kwargs.get("pks"):
+ return self._get_collection_name()
+ collection_name, collection_count = self._get_root_collection_name()
return {
+ **_EMPTY_TITLE,
"collection_name": collection_name,
- "collection_number_to": collection_number_to,
"collection_count": collection_count,
}
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 42ff510bf..b75c87d88 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -15,7 +15,7 @@
"vue": "^3.5.44",
"vue-pdf-embed": "^2.1.7",
"vue-router": "^5.4.0",
- "vuetify": "^4.2.6",
+ "vuetify": "^4.3.0",
"xior": "^0.8.5",
},
"devDependencies": {
@@ -26,12 +26,12 @@
"@vitejs/plugin-vue": "^6.0.10",
"@vue/test-utils": "^2.5.1",
"@vue/typescript-plugin": "^3.3.13",
- "happy-dom": "^21.0.3",
+ "happy-dom": "^21.0.4",
"rollup-plugin-visualizer": "^7.1.1",
- "sass": "^1.105.2",
+ "sass": "^1.106.1",
"sass-loader": "^17.0.1",
"toml": "^5.0.1",
- "vite": "^8.3.4",
+ "vite": "^8.3.5",
"vite-plugin-checker": "^0.14.5",
"vite-plugin-dynamic-base": "^1.4.1",
"vite-plugin-run": "^0.9.1",
@@ -104,35 +104,35 @@
"@mdi/js": ["@mdi/js@7.4.47", "", {}, "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="],
- "@napi-rs/canvas": ["@napi-rs/canvas@1.0.11", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.11", "@napi-rs/canvas-darwin-arm64": "1.0.11", "@napi-rs/canvas-darwin-x64": "1.0.11", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11", "@napi-rs/canvas-linux-arm64-gnu": "1.0.11", "@napi-rs/canvas-linux-arm64-musl": "1.0.11", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.11", "@napi-rs/canvas-linux-x64-gnu": "1.0.11", "@napi-rs/canvas-linux-x64-musl": "1.0.11", "@napi-rs/canvas-win32-arm64-msvc": "1.0.11", "@napi-rs/canvas-win32-x64-msvc": "1.0.11" } }, "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w=="],
+ "@napi-rs/canvas": ["@napi-rs/canvas@1.0.13", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.13", "@napi-rs/canvas-darwin-arm64": "1.0.13", "@napi-rs/canvas-darwin-x64": "1.0.13", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.13", "@napi-rs/canvas-linux-arm64-gnu": "1.0.13", "@napi-rs/canvas-linux-arm64-musl": "1.0.13", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-gnu": "1.0.13", "@napi-rs/canvas-linux-x64-musl": "1.0.13", "@napi-rs/canvas-win32-arm64-msvc": "1.0.13", "@napi-rs/canvas-win32-x64-msvc": "1.0.13" } }, "sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw=="],
- "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.11", "", { "os": "android", "cpu": "arm64" }, "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA=="],
+ "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.13", "", { "os": "android", "cpu": "arm64" }, "sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ=="],
- "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw=="],
+ "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg=="],
- "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ=="],
+ "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw=="],
- "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.11", "", { "os": "linux", "cpu": "arm" }, "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA=="],
+ "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.13", "", { "os": "linux", "cpu": "arm" }, "sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg=="],
- "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ=="],
+ "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg=="],
- "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg=="],
+ "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA=="],
- "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.11", "", { "os": "linux", "cpu": "none" }, "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ=="],
+ "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.13", "", { "os": "linux", "cpu": "none" }, "sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A=="],
- "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.11", "", { "os": "linux", "cpu": "x64" }, "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg=="],
+ "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w=="],
- "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.11", "", { "os": "linux", "cpu": "x64" }, "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ=="],
+ "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.13", "", { "os": "linux", "cpu": "x64" }, "sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA=="],
- "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ=="],
+ "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA=="],
- "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.11", "", { "os": "win32", "cpu": "x64" }, "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw=="],
+ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.13", "", { "os": "win32", "cpu": "x64" }, "sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="],
"@one-ini/wasm": ["@one-ini/wasm@0.2.1", "", {}, "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg=="],
- "@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="],
+ "@oxc-project/types": ["@oxc-project/types@0.155.0", "", {}, "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="],
@@ -200,35 +200,35 @@
"@pinia/testing": ["@pinia/testing@2.0.2", "", { "dependencies": { "nostics": "^1.2.0" }, "peerDependencies": { "pinia": ">=4.1.0" } }, "sha512-Dfbng8WYNTeS2ufcg+cL/prWES95RVx8VIY5JITFl8c19vs4Fx0Gd/ufkBRDLhWlQXugSkZ1kwKSXJg1c+eTkw=="],
- "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="],
+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.15", "", { "os": "android", "cpu": "arm" }, "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw=="],
- "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.15", "", { "os": "android", "cpu": "arm64" }, "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg=="],
- "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="],
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA=="],
- "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="],
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A=="],
- "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="],
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ=="],
- "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="],
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.15", "", { "os": "linux", "cpu": "arm" }, "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ=="],
- "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="],
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw=="],
- "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="],
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ=="],
- "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="],
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw=="],
- "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="],
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA=="],
- "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="],
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw=="],
- "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="],
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ=="],
- "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="],
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.15", "", { "os": "none", "cpu": "arm64" }, "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w=="],
- "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="],
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg=="],
- "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="],
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -332,7 +332,7 @@
"@vuetify/loader-shared": ["@vuetify/loader-shared@2.1.2", "", { "dependencies": { "upath": "^2.0.1" }, "peerDependencies": { "vue": "^3.0.0", "vuetify": ">=3" } }, "sha512-X+1jBLmXHkpQEnC0vyOb4rtX2QSkBiFhaFXz8yhQqN2A4vQ6k2nChxN4Ol7VAY5KoqMdFoRMnmNdp/1qYXDQig=="],
- "@vuetify/v0": ["@vuetify/v0@1.2.3", "", { "peerDependencies": { "@adobe/leonardo-contrast-colors": ">=1.0.0", "@ant-design/colors": ">=7.0.0", "@flagsmith/flagsmith": ">=11.0.0", "@floating-ui/dom": ">=1.8.0", "@js-temporal/polyfill": ">=0.5.0", "@material/material-color-utilities": ">=0.3.0", "launchdarkly-js-client-sdk": "^3.0.0", "posthog-js": "^1.0.0", "vue": ">=3.5.0 || >=3.6.0-0", "vue-i18n": ">=10.0.0" }, "optionalPeers": ["@adobe/leonardo-contrast-colors", "@ant-design/colors", "@flagsmith/flagsmith", "@floating-ui/dom", "@js-temporal/polyfill", "@material/material-color-utilities", "launchdarkly-js-client-sdk", "posthog-js", "vue-i18n"] }, "sha512-8qpYa88KxKrvi4jQcz+Zg4WH04un48Lt7Tj0Qq+LqvbUvHi+H177vWqBn9tQWgpxBVs1RXrTz5zw0Cy6txJHlw=="],
+ "@vuetify/v0": ["@vuetify/v0@1.3.0", "", { "peerDependencies": { "@adobe/leonardo-contrast-colors": ">=1.0.0", "@ant-design/colors": ">=7.0.0", "@flagsmith/flagsmith": ">=11.0.0", "@floating-ui/dom": ">=1.8.0", "@js-temporal/polyfill": ">=0.5.0", "@material/material-color-utilities": ">=0.3.0", "@vue/devtools-api": ">=7.0.0", "launchdarkly-js-client-sdk": "^3.0.0", "posthog-js": "^1.0.0", "vue": ">=3.5.0 || >=3.6.0-0", "vue-i18n": ">=10.0.0" }, "optionalPeers": ["@adobe/leonardo-contrast-colors", "@ant-design/colors", "@flagsmith/flagsmith", "@floating-ui/dom", "@js-temporal/polyfill", "@material/material-color-utilities", "@vue/devtools-api", "launchdarkly-js-client-sdk", "posthog-js", "vue-i18n"] }, "sha512-Tvknc64ciwaOdKzifg3maMOiczbEenWxYINoZIotfKQ8VWBs/yeHbvHffW4w1j+xYnNyYi0S9LK5UCzyQrYg9A=="],
"@vueuse/core": ["@vueuse/core@15.0.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "15.0.0", "@vueuse/shared": "15.0.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng=="],
@@ -482,7 +482,7 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
- "happy-dom": ["happy-dom@21.0.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-PesNgu5BnIEmAwzKGewB3K1ORFeznFZJ4JrRz89ea1wkHnxskdpzX9sN/+zb4KO6k8oSMhyJSj/cwXAFrYBBNA=="],
+ "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="],
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
@@ -622,7 +622,7 @@
"pinia": ["pinia@4.1.0", "", { "dependencies": { "nostics": "^1.2.0" }, "peerDependencies": { "@vue/devtools-api": "^8.2.1", "typescript": ">=5.6.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-+5/HMuVjYQfKlCD5xQ7zfXn1xUzYFEa7pd0DQK5SaMfZCaFRTHlaH/c02N1FoGOdlzSiwf5EOPgJx//1QDw6gw=="],
- "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="],
+ "pkg-types": ["pkg-types@2.4.0", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.3", "pathe": "^2.0.3" } }, "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA=="],
"postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
@@ -646,13 +646,13 @@
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
- "rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="],
+ "rolldown": ["rolldown@1.2.15", "", { "dependencies": { "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.15", "@rolldown/binding-android-arm64": "1.2.15", "@rolldown/binding-darwin-arm64": "1.2.15", "@rolldown/binding-darwin-x64": "1.2.15", "@rolldown/binding-freebsd-x64": "1.2.15", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", "@rolldown/binding-linux-arm64-gnu": "1.2.15", "@rolldown/binding-linux-arm64-musl": "1.2.15", "@rolldown/binding-linux-ppc64-gnu": "1.2.15", "@rolldown/binding-linux-s390x-gnu": "1.2.15", "@rolldown/binding-linux-x64-gnu": "1.2.15", "@rolldown/binding-linux-x64-musl": "1.2.15", "@rolldown/binding-openharmony-arm64": "1.2.15", "@rolldown/binding-win32-arm64-msvc": "1.2.15", "@rolldown/binding-win32-x64-msvc": "1.2.15" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g=="],
"rollup-plugin-visualizer": ["rollup-plugin-visualizer@7.1.1", "", { "dependencies": { "open": "^11.0.0", "picomatch": "^4.0.5", "source-map": "^0.8.0", "yargs": "^18.1.0" }, "peerDependencies": { "rolldown": "1.x || ^1.0.0-beta || ^1.0.0-rc", "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rolldown", "rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-ThaGiHTU8XW02OkK80TrTHATraJmM9OAduU4otal+7gyXLpYEtmGBLfx5kW+EHvvLwn03YGW2NnwKUIqsYlJAA=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
- "sass": ["sass@1.105.2", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-KxW6ZKZiBKWK3ziQ4YZ3cpqo8/1TlHXE8t9nBNvGDZFoBFbk2MD7RacxYFs8zy8hzeFwpFYkVyAhboi47uYPEQ=="],
+ "sass": ["sass@1.106.1", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kD+Gi66vycZcbJt+42NC4z4PXNMcCajVrJPhqoKKXy5cOBMmJQt052CxIBcRjwYXeImHYOpu/ydWWGZQIenl7A=="],
"sass-loader": ["sass-loader@17.0.1", "", { "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "sass": "^1.3.0", "sass-embedded": "*", "webpack": "^5.0.0" }, "optionalPeers": ["@rspack/core", "sass", "sass-embedded", "webpack"] }, "sha512-pgJMwCuLjVTSIWsv/2luVRXKlCeaViUGcgSe8dx95zMG4hUwqIMFmjbhq29ypFLflJU0GyiJZeLM9iI1n3KYAA=="],
@@ -752,7 +752,7 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
- "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
+ "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
"vite-plugin-checker": ["vite-plugin-checker@0.14.5", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "chokidar": "^5.0.0", "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "proper-lockfile": "^4.1.2", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@biomejs/biome": ">=2.4.12", "eslint": ">=9.39.4", "meow": "^13.2.0 || ^14.0.0", "optionator": "^0.9.4", "oxlint": ">=1", "stylelint": ">=16.26.1", "typescript": "*", "vite": ">=5.4.21", "vue-tsc": "~2.2.10 || ^3.0.0" }, "optionalPeers": ["@biomejs/biome", "eslint", "meow", "optionator", "oxlint", "stylelint", "typescript", "vue-tsc"] }, "sha512-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA=="],
@@ -778,7 +778,7 @@
"vue-router": ["vue-router@5.4.0", "", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="],
- "vuetify": ["vuetify@4.2.6", "", { "dependencies": { "@vuetify/v0": "^1.2.1" }, "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0 || ^3.6.0-0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-W91vPxyJT7Br2wc3Iu2fUzC1YRZCTGxtrvywxnf7vGZF6Cfz2QvTvKvXmwMreie8oCHW6kA9yw7auCgQuwBUjQ=="],
+ "vuetify": ["vuetify@4.3.0", "", { "dependencies": { "@vuetify/v0": "^1.2.3" }, "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0 || ^3.6.0-0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-tQpJ/2oKCNeZQb7p3PpwGVca4B9IFUp4LuTpE/bOZ/lIC4U74EJxtriMdB8xj0wtZx40PFGbHPrdfJzZg1FX0A=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
diff --git a/frontend/package.json b/frontend/package.json
index 192b667df..c4b925d83 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "codex",
- "version": "2.5.5",
+ "version": "2.5.6",
"private": true,
"description": "ui for codex api",
"type": "module",
@@ -27,7 +27,7 @@
"vue": "^3.5.44",
"vue-pdf-embed": "^2.1.7",
"vue-router": "^5.4.0",
- "vuetify": "^4.2.6",
+ "vuetify": "^4.3.0",
"xior": "^0.8.5"
},
"devDependencies": {
@@ -38,12 +38,12 @@
"@vitejs/plugin-vue": "^6.0.10",
"@vue/test-utils": "^2.5.1",
"@vue/typescript-plugin": "^3.3.13",
- "happy-dom": "^21.0.3",
+ "happy-dom": "^21.0.4",
"rollup-plugin-visualizer": "^7.1.1",
- "sass": "^1.105.2",
+ "sass": "^1.106.1",
"sass-loader": "^17.0.1",
"toml": "^5.0.1",
- "vite": "^8.3.4",
+ "vite": "^8.3.5",
"vite-plugin-checker": "^0.14.5",
"vite-plugin-dynamic-base": "^1.4.1",
"vite-plugin-run": "^0.9.1",
diff --git a/frontend/src/comic-name.js b/frontend/src/comic-name.js
index d82c206a9..7fd5776ac 100644
--- a/frontend/src/comic-name.js
+++ b/frontend/src/comic-name.js
@@ -77,8 +77,17 @@ export const getFullComicName = function (
return [seriesName, fvn, issueName].filter(Boolean).join(" ");
};
+export const formattedYearRange = function (start, end) {
+ // A series run: "1963–1996", open-ended "1963–", "–1996", or one year.
+ if (!start && !end) {
+ return "";
+ }
+ return start === end ? String(start) : `${start || ""}–${end || ""}`;
+};
+
export default {
getFullComicName,
formattedVolumeName,
+ formattedYearRange,
getIssueName,
};
diff --git a/frontend/src/components/browser/toolbars/browser-toolbar-title.vue b/frontend/src/components/browser/toolbars/browser-toolbar-title.vue
index aec57b91e..aaee8b895 100644
--- a/frontend/src/components/browser/toolbars/browser-toolbar-title.vue
+++ b/frontend/src/components/browser/toolbars/browser-toolbar-title.vue
@@ -3,6 +3,14 @@
{{ title }}
+
+
+
{{ subtitle }}
@@ -15,13 +23,17 @@
import { mdiReload } from "@mdi/js";
import { mapState } from "pinia";
-import { formattedVolumeName } from "@/comic-name";
+import { formattedVolumeName, formattedYearRange } from "@/comic-name";
+import MetadataDialog from "@/components/metadata/metadata-dialog.vue";
import { collectionForRoute } from "@/route";
import { useAuthStore } from "@/stores/auth";
import { useBrowserStore } from "@/stores/browser";
export default {
name: "BrowserHeader",
+ components: {
+ MetadataDialog,
+ },
data() {
return {
mdiReload,
@@ -44,11 +56,23 @@ export default {
modelCollection: (state) => state.page.modelCollection,
}),
...mapState(useAuthStore, ["isUserAdmin"]),
- routeCollection() {
+ routeTarget() {
return collectionForRoute({
collection: this.$route.params.collection,
parentIds: this.$route.params.parentIds,
- }).collection;
+ });
+ },
+ routeCollection() {
+ return this.routeTarget.collection;
+ },
+ titleBook() {
+ // The collection being browsed, as a card-shaped book for its Tags button.
+ const { collection, pks } = this.routeTarget;
+ if (collection === "root" || pks.length === 0) {
+ return null;
+ }
+ const ids = pks.map(Number);
+ return { collection, ids, pk: ids[0], childCount: 0 };
},
title() {
let title;
@@ -56,8 +80,13 @@ export default {
title = "All";
} else if (this.browserTitle) {
let names = [];
- const { collectionName, collectionNumberTo, collectionCount } =
- this.browserTitle;
+ const {
+ collectionName,
+ collectionNumberTo,
+ collectionCount,
+ collectionStartYear,
+ collectionEndYear,
+ } = this.browserTitle;
const collection = this.routeCollection;
const formattedCollectionName =
collection === "volumes"
@@ -66,6 +95,13 @@ export default {
if (formattedCollectionName) {
names.push(formattedCollectionName);
}
+ const years = formattedYearRange(
+ collectionStartYear,
+ collectionEndYear,
+ );
+ if (years) {
+ names.push(years);
+ }
if (collectionCount) {
const formattedCollectionCount = `of ${collectionCount}`;
names.push(formattedCollectionCount);
@@ -90,6 +126,16 @@ export default {
padding-bottom: 6px;
}
+/* Out of the flex flow so the title stays centred on the whole bar. */
+.titleTagButton {
+ position: absolute;
+ top: 0px;
+ right: 0px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+}
+
.browserTitle {
margin: 0px;
text-align: center;
@@ -98,6 +144,8 @@ export default {
}
#browserTitle {
+ /* Symmetric room for the Tags button keeps a long title centred. */
+ padding-inline: 24px;
font-size: clamp(12px, 2vw, 20px);
}
diff --git a/frontend/src/components/browser/toolbars/top/filter-choice-list.vue b/frontend/src/components/browser/toolbars/top/filter-choice-list.vue
index 403a5d8b2..f2d3f6c18 100644
--- a/frontend/src/components/browser/toolbars/top/filter-choice-list.vue
+++ b/frontend/src/components/browser/toolbars/top/filter-choice-list.vue
@@ -41,7 +41,12 @@ import { mapActions, mapState } from "pinia";
import { toVuetifyItems } from "@/vuetify-items";
import { useBrowserStore } from "@/stores/browser";
-const NUMERIC_FILTERS = new Set(["decade", "year"]);
+const NUMERIC_FILTERS = new Set([
+ "decade",
+ "seriesEndYear",
+ "seriesStartYear",
+ "year",
+]);
export default {
name: "BrowserFilterChoiceList",
diff --git a/frontend/src/components/metadata/edit-mode/edit-panel.vue b/frontend/src/components/metadata/edit-mode/edit-panel.vue
index ceae18111..7a777422a 100644
--- a/frontend/src/components/metadata/edit-mode/edit-panel.vue
+++ b/frontend/src/components/metadata/edit-mode/edit-panel.vue
@@ -259,6 +259,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0 ||
+ deleteKeys.length > 0 ||
+ this.renameFile
+ );
+ },
buildPatch() {
// Returns { patch, deleteKeys }. A merge write can only add or replace
// values — comicbox prunes empty patch values on schema load — so
@@ -2278,13 +2441,21 @@ export default {
if (cleared.has(key) || !this.patch[key]) deleteKeys.push(key);
else cbPatch[key] = { name: this.patch[key] };
}
- if (changed.has("series") || changed.has("volume_count")) {
+ if (
+ changed.has("series") ||
+ changed.has("volume_count") ||
+ changed.has("start_year")
+ ) {
const series = {};
if (this.patch.series) series.name = this.patch.series;
if (this.patch.volume_count) {
const vc = parseInt(this.patch.volume_count, 10);
if (!isNaN(vc)) series.volume_count = vc;
}
+ if (this.patch.start_year) {
+ const sy = parseInt(this.patch.start_year, 10);
+ if (!isNaN(sy)) series.start_year = sy;
+ }
if (Object.keys(series).length) cbPatch.series = series;
else deleteKeys.push("series");
}
@@ -2540,6 +2711,11 @@ export default {
const { patch, deleteKeys } = this.hasChanges
? this.buildPatch()
: { patch: {}, deleteKeys: [] };
+ if (!this.writesFiles(patch, deleteKeys)) {
+ // Only database-only series attributes changed: nothing to preflight.
+ await this.doSave();
+ return;
+ }
try {
const response = await HTTP.post("/admin/tag-write/preflight", {
collection: this.book.collection,
@@ -2580,27 +2756,40 @@ export default {
const { patch, deleteKeys } = this.hasChanges
? this.buildPatch()
: { patch: {}, deleteKeys: [] };
+ const seriesAttrs = this.hasChanges ? this.buildSeriesAttrs() : {};
+ const target = { collection: this.book.collection, pks: pks.map(String) };
try {
- const payload = {
- collection: this.book.collection,
- pks: pks.map(String),
- patch: JSON.stringify(patch),
- deleteKeys,
- mode: "update",
- formats,
- rename: this.renameFile,
- };
- if (this.confirmInfo.needConversion > 0) {
- payload.deleteOriginal = this.confirmDeleteOriginal;
+ const messages = [];
+ if (Object.keys(seriesAttrs).length > 0) {
+ await HTTP.post("/admin/series-attrs", { ...target, ...seriesAttrs });
+ messages.push("Series details saved.");
+ if (this.$route?.name === "browser") {
+ // The title shows the series years; no librarian event follows.
+ useBrowserStore().loadBrowserPage(getTimestamp());
+ }
}
- const response = await HTTP.post("/admin/tag-write", payload);
- const skipped = response.data?.skipped || 0;
- let message = "Tag write queued.";
- if (skipped > 0) {
- message += ` ${skipped} comic${skipped === 1 ? "" : "s"} in read-only libraries skipped.`;
+ if (this.writesFiles(patch, deleteKeys)) {
+ const payload = {
+ ...target,
+ patch: JSON.stringify(patch),
+ deleteKeys,
+ mode: "update",
+ formats,
+ rename: this.renameFile,
+ };
+ if (this.confirmInfo.needConversion > 0) {
+ payload.deleteOriginal = this.confirmDeleteOriginal;
+ }
+ const response = await HTTP.post("/admin/tag-write", payload);
+ const skipped = response.data?.skipped || 0;
+ let message = "Tag write queued.";
+ if (skipped > 0) {
+ message += ` ${skipped} comic${skipped === 1 ? "" : "s"} in read-only libraries skipped.`;
+ }
+ messages.push(message);
}
- useCommonStore().setSuccess(message);
+ useCommonStore().setSuccess(messages.join(" "));
this.$emit("saved");
} catch (error) {
useCommonStore().setErrors(error);
diff --git a/frontend/src/components/metadata/metadata-activator.vue b/frontend/src/components/metadata/metadata-activator.vue
index 9e1959b18..567f10d6f 100644
--- a/frontend/src/components/metadata/metadata-activator.vue
+++ b/frontend/src/components/metadata/metadata-activator.vue
@@ -4,6 +4,8 @@
class="tagButton cardControlButton"
:variant="variant"
:icon="mdiTagOutline"
+ :size="size"
+ :density="density"
title="Tags"
@mouseenter="onMouseEnter"
@click.prevent
@@ -21,6 +23,8 @@ export default {
props: {
book: { type: Object, required: true },
toolbar: { type: Boolean, require: true },
+ size: { type: String, default: undefined },
+ density: { type: String, default: undefined },
},
data() {
return {
diff --git a/frontend/src/components/metadata/metadata-body.vue b/frontend/src/components/metadata/metadata-body.vue
index 4552247cc..a62c6fafb 100644
--- a/frontend/src/components/metadata/metadata-body.vue
+++ b/frontend/src/components/metadata/metadata-body.vue
@@ -2,6 +2,11 @@
diff --git a/frontend/src/components/metadata/metadata-dialog.vue b/frontend/src/components/metadata/metadata-dialog.vue
index 251b573f0..2ccc7b247 100644
--- a/frontend/src/components/metadata/metadata-dialog.vue
+++ b/frontend/src/components/metadata/metadata-dialog.vue
@@ -5,6 +5,8 @@
v-bind="activatorProps"
:book="book"
:toolbar="toolbar"
+ :size="size"
+ :density="density"
/>
@@ -79,6 +81,14 @@ export default {
type: Boolean,
default: false,
},
+ size: {
+ type: String,
+ default: undefined,
+ },
+ density: {
+ type: String,
+ default: undefined,
+ },
},
emits: ["close"],
data() {
diff --git a/frontend/src/components/metadata/metadata-header.vue b/frontend/src/components/metadata/metadata-header.vue
index 9632226f2..00d3b4efb 100644
--- a/frontend/src/components/metadata/metadata-header.vue
+++ b/frontend/src/components/metadata/metadata-header.vue
@@ -41,6 +41,9 @@
/>
+
+ {{ seriesFacts }}
+
{{ collectionTitle }} {{ md.name }}
@@ -123,7 +126,7 @@
import { mapState } from "pinia";
import prettyBytes from "pretty-bytes";
-import { formattedIssue } from "@/comic-name";
+import { formattedIssue, formattedYearRange } from "@/comic-name";
import MetadataControls from "@/components/metadata/metadata-controls.vue";
import MetadataBookCover from "@/components/metadata/metadata-cover.vue";
import MetadataTags from "@/components/metadata/metadata-tags.vue";
@@ -239,6 +242,14 @@ export default {
fileType() {
return this?.md?.fileType || "Unknown";
},
+ seriesYears() {
+ return formattedYearRange(this.md.seriesStartYear, this.md.seriesEndYear);
+ },
+ seriesFacts() {
+ return [this.seriesYears, this.md.seriesStatus]
+ .filter(Boolean)
+ .join(" · ");
+ },
seriesVolumeCount() {
const count = this.md.seriesVolumeCount;
if (!count) return "";
@@ -280,6 +291,7 @@ export default {
}
#seriesRow {
+ flex-wrap: wrap;
margin-top: -10px;
font-size: xx-large;
}
@@ -294,7 +306,8 @@ export default {
padding-top: 0px;
}
- #titleRow {
+ #titleRow,
+ #seriesFactsRow {
font-size: large;
}
@@ -321,7 +334,8 @@ export default {
font-size: large;
}
- #titleRow {
+ #titleRow,
+ #seriesFactsRow {
font-size: x-small;
}
}
diff --git a/frontend/src/components/online-tag/candidate-row.vue b/frontend/src/components/online-tag/candidate-row.vue
index 2936b7c62..e060c0e81 100644
--- a/frontend/src/components/online-tag/candidate-row.vue
+++ b/frontend/src/components/online-tag/candidate-row.vue
@@ -6,6 +6,16 @@
a link to the source's own page, and what the blended score is made
of (#854).
+ Covers are often near-identical too: a reprint volume, a foreign or a
+ collected edition. So two subdued lines under the title carry what the
+ source knows about the series (publisher, format, start year, issue
+ count) and about the issue (its title and one-line deck). Comic Vine
+ supplies most of it from rows comicbox already fetched. Metron always
+ has the start year, but its format, publisher and issue count cost
+ comicbox a request, so they arrive only when titles would otherwise
+ collide. Any of it may be absent, and a prompt cached before comicbox
+ carried it shows neither line.
+
The thumbnail loads straight from the source CDN. An
is exempt
from CORS, and codex's own img-src allows the two upload paths (see
_ONLINE_TAG_COVER_SECURE_CSP), so there is nothing to proxy. A blocked
@@ -44,9 +54,6 @@
({{ candidate.summary.year }})
-
- — {{ candidate.summary.publisher }}
-
{{ sourceLabel(candidate.source) }}
{{ mdiOpenInNew }}
+
+ {{ seriesDetail }}
+
+
+
+ {{ issueDetail }}
+
@@ -67,10 +81,19 @@
{{ scoreDetail }}
- {{ scorePercent }}%
-
- Pick
-
+
+
+ {{ scorePercent }}%
+
+ Pick
+
+
@@ -107,6 +130,31 @@ export default {
scorePercent() {
return Math.round(this.candidate.score * PERCENT);
},
+ seriesDetail() {
+ const {
+ seriesPublisher,
+ publisher,
+ seriesType,
+ seriesStartYear,
+ seriesIssueCount,
+ } = this.candidate.summary;
+ return [
+ seriesPublisher || publisher,
+ seriesType,
+ seriesStartYear ? `Series began ${seriesStartYear}` : "",
+ seriesIssueCount
+ ? `${seriesIssueCount} ${seriesIssueCount === 1 ? "issue" : "issues"}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ },
+ issueDetail() {
+ const { issueTitle, issueSummary } = this.candidate.summary;
+ return [issueTitle ? `“${issueTitle}”` : "", issueSummary]
+ .filter(Boolean)
+ .join(" — ");
+ },
scoreDetail() {
// Prompts cached before these fields existed carry neither, and
// the headline percentage alone is still meaningful.
@@ -138,6 +186,9 @@ export default {
diff --git a/frontend/tests/unit/browser-toolbar-title.test.js b/frontend/tests/unit/browser-toolbar-title.test.js
new file mode 100644
index 000000000..2051fc8c4
--- /dev/null
+++ b/frontend/tests/unit/browser-toolbar-title.test.js
@@ -0,0 +1,111 @@
+/*
+ * Tests for ``browser-toolbar-title.vue`` — the centred collection title.
+ *
+ * A series title carries the series' year range, and every collection
+ * page but the root listing gets a Tags button that opens the browsed
+ * collection's own metadata, built like a browser card's book.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { mount } from "@vue/test-utils";
+import { describe, expect, test } from "vitest";
+
+import BrowserToolbarTitle from "@/components/browser/toolbars/browser-toolbar-title.vue";
+import MetadataDialog from "@/components/metadata/metadata-dialog.vue";
+import vuetify from "@/plugins/vuetify";
+
+function mountTitle({ params, title = {}, modelCollection = "comics" }) {
+ const pinia = createTestingPinia({
+ initialState: {
+ browser: { page: { title, modelCollection } },
+ },
+ });
+ return mount(BrowserToolbarTitle, {
+ global: {
+ plugins: [pinia, vuetify],
+ mocks: { $route: { params } },
+ stubs: { MetadataDialog: true },
+ },
+ });
+}
+
+const titleText = (wrapper) => wrapper.find("#browserTitle").text();
+
+describe("BrowserToolbarTitle — title", () => {
+ test("names the root listing All", () => {
+ const wrapper = mountTitle({
+ params: { collection: "publishers" },
+ title: { collectionName: "Publishers", collectionCount: 0 },
+ modelCollection: "publishers",
+ });
+ expect(titleText(wrapper)).toBe("All");
+ });
+
+ test("follows a series name with its years", () => {
+ const wrapper = mountTitle({
+ params: { collection: "series", parentIds: "5" },
+ title: {
+ collectionName: "Amazing Spider-Man",
+ collectionCount: 0,
+ collectionStartYear: 1963,
+ collectionEndYear: 1996,
+ },
+ });
+ expect(titleText(wrapper)).toBe("Amazing Spider-Man 1963–1996");
+ });
+
+ test("shows a series without years by name alone", () => {
+ const wrapper = mountTitle({
+ params: { collection: "series", parentIds: "5" },
+ title: {
+ collectionName: "Amazing Spider-Man",
+ collectionCount: 0,
+ collectionStartYear: null,
+ collectionEndYear: null,
+ },
+ });
+ expect(titleText(wrapper)).toBe("Amazing Spider-Man");
+ });
+
+ test("keeps a volume's own name and count", () => {
+ const wrapper = mountTitle({
+ params: { collection: "volumes", parentIds: "7" },
+ title: {
+ collectionName: "1963",
+ collectionNumberTo: null,
+ collectionCount: 3,
+ collectionStartYear: null,
+ collectionEndYear: null,
+ },
+ });
+ expect(titleText(wrapper)).toBe("(1963) of 3");
+ });
+});
+
+describe("BrowserToolbarTitle — Tags button", () => {
+ test("is absent on the root listing", () => {
+ const wrapper = mountTitle({
+ params: { collection: "publishers" },
+ title: { collectionName: "Publishers", collectionCount: 0 },
+ modelCollection: "publishers",
+ });
+ expect(wrapper.findComponent(MetadataDialog).exists()).toBe(false);
+ });
+
+ test("opens the browsed series' own metadata", () => {
+ const wrapper = mountTitle({
+ params: { collection: "series", parentIds: "5" },
+ title: { collectionName: "Amazing Spider-Man", collectionCount: 0 },
+ });
+ const dialog = wrapper.findComponent(MetadataDialog);
+ expect(dialog.exists()).toBe(true);
+ expect(dialog.props("book")).toStrictEqual({
+ collection: "series",
+ ids: [5],
+ pk: 5,
+ childCount: 0,
+ });
+ expect(dialog.props("toolbar")).toBe(true);
+ expect(dialog.props("size")).toBe("small");
+ expect(dialog.props("density")).toBe("compact");
+ });
+});
diff --git a/frontend/tests/unit/candidate-row.test.js b/frontend/tests/unit/candidate-row.test.js
index a3313f9c4..79c10f467 100644
--- a/frontend/tests/unit/candidate-row.test.js
+++ b/frontend/tests/unit/candidate-row.test.js
@@ -214,6 +214,23 @@ describe("CandidateRow details", () => {
expect(mountRow().find(".candidateVolume").exists()).toBe(false);
});
+ /*
+ * On phones the score and Pick wrap below the text as one unit, so the
+ * text gets the row's width. happy-dom applies no media queries; this
+ * pins the grouping the phone styles rely on.
+ */
+ test("groups the score and Pick apart from the text", () => {
+ const wrapper = mountRow();
+ const actions = wrapper.find(".candidateRow > .candidateActions");
+
+ expect(actions.exists()).toBe(true);
+ expect(actions.find(".v-chip").text()).toBe("92%");
+ expect(actions.find("button").text()).toBe("Pick");
+ expect(wrapper.find(".candidateInfo .candidateActions").exists()).toBe(
+ false,
+ );
+ });
+
test("emits pick", async () => {
const wrapper = mountRow();
@@ -222,3 +239,74 @@ describe("CandidateRow details", () => {
expect(wrapper.emitted("pick")).toHaveLength(1);
});
});
+
+/*
+ * Near-identical covers with identical titles are common: a reprint
+ * volume, a foreign or a collected edition. Two subdued lines under the
+ * title carry what tells them apart.
+ */
+describe("CandidateRow series and issue detail", () => {
+ const DETAIL = {
+ seriesPublisher: "DC Comics",
+ seriesType: "Ongoing Series",
+ seriesStartYear: 2016,
+ seriesIssueCount: 85,
+ issueTitle: "I Am Gotham, Part One",
+ issueSummary: "A new hero arrives.",
+ };
+
+ test("joins what the source knows about the series", () => {
+ const line = mountRow({ summary: DETAIL }).find(".candidateSeriesDetail");
+
+ expect(line.text()).toBe(
+ "DC Comics · Ongoing Series · Series began 2016 · 85 issues",
+ );
+ });
+
+ test("a one-issue series says issue, not issues", () => {
+ const line = mountRow({ summary: { seriesIssueCount: 1 } }).find(
+ ".candidateSeriesDetail",
+ );
+
+ expect(line.text()).toBe("Dark Horse · 1 issue");
+ });
+
+ test("the series publisher wins over the scored one", () => {
+ const line = mountRow({ summary: { seriesPublisher: "Panini" } }).find(
+ ".candidateSeriesDetail",
+ );
+
+ expect(line.text()).toBe("Panini");
+ });
+
+ test("the publisher has left the title line", () => {
+ const wrapper = mountRow();
+
+ expect(wrapper.find(".candidatePublisher").exists()).toBe(false);
+ expect(wrapper.find(".candidateSeriesDetail").text()).toBe("Dark Horse");
+ });
+
+ test("quotes the issue title before its deck, the whole as a tooltip", () => {
+ const line = mountRow({ summary: DETAIL }).find(".candidateIssueDetail");
+ const text = "“I Am Gotham, Part One” — A new hero arrives.";
+
+ expect(line.text()).toBe(text);
+ expect(line.attributes("title")).toBe(text);
+ });
+
+ test.each([
+ [{ issueTitle: "Title" }, "“Title”"],
+ [{ issueSummary: "Deck" }, "Deck"],
+ ])("shows either half of the issue line alone", (summary, expected) => {
+ const line = mountRow({ summary }).find(".candidateIssueDetail");
+
+ expect(line.text()).toBe(expected);
+ });
+
+ test("a prompt cached before comicbox carried these shows neither line", () => {
+ const wrapper = mountRow({ summary: { publisher: null } });
+
+ expect(wrapper.find(".candidateSeriesDetail").exists()).toBe(false);
+ expect(wrapper.find(".candidateIssueDetail").exists()).toBe(false);
+ });
+});
diff --git a/frontend/tests/unit/edit-panel-metron-fields.test.js b/frontend/tests/unit/edit-panel-metron-fields.test.js
index 699f69227..729f36f1d 100644
--- a/frontend/tests/unit/edit-panel-metron-fields.test.js
+++ b/frontend/tests/unit/edit-panel-metron-fields.test.js
@@ -7,8 +7,9 @@
* - Collection title is a plain string, cleared through a delete key.
* - Country rides the technical select path alongside language, storing
* the two-letter code the Country model names.
- * - Per-format enablement: the alternative issue and collection title are
- * METRON_INFO-only; country is COMIC_INFO-only.
+ * - The series start year rides the series object with the volume count.
+ * - Per-format enablement: the alternative issue, collection title and
+ * series start year are METRON_INFO-only; country is COMIC_INFO-only.
*/
import { createTestingPinia } from "@pinia/testing";
import { flushPromises, mount } from "@vue/test-utils";
@@ -198,3 +199,40 @@ describe("EditPanel — country", () => {
expect(metron.vm.isFieldDisabled("country")).toBe(true);
});
});
+
+describe("EditPanel — series start year", () => {
+ test("it seeds from the series and renders its own input", async () => {
+ const wrapper = await mountPanel({ md: { seriesStartYear: 1963 } });
+ expect(wrapper.vm.patch.start_year).toBe(1963);
+ const labels = wrapper.findAll("label").map((el) => el.text());
+ expect(labels).toContain("Start Year");
+ });
+
+ test("it rides the series object with the volume count", async () => {
+ const wrapper = await mountPanel({
+ md: { seriesList: [{ name: "S", ids: [1] }], seriesVolumeCount: 3 },
+ });
+ await setField(wrapper, "start_year", "1963");
+ expect(wrapper.vm.buildPatch().patch.series).toEqual({
+ name: "S",
+ volume_count: 3,
+ start_year: 1963,
+ });
+ });
+
+ test("clearing it drops it from the series object", async () => {
+ const wrapper = await mountPanel({
+ md: { seriesList: [{ name: "S", ids: [1] }], seriesStartYear: 1963 },
+ });
+ wrapper.vm.toggleClear("start_year");
+ await flushPromises();
+ expect(wrapper.vm.buildPatch().patch.series).toEqual({ name: "S" });
+ });
+
+ test("it is METRON_INFO-only", async () => {
+ const comicInfo = await mountPanel({ formats: ["COMIC_INFO"] });
+ const metron = await mountPanel({ formats: ["METRON_INFO"] });
+ expect(comicInfo.vm.isFieldDisabled("start_year")).toBe(true);
+ expect(metron.vm.isFieldDisabled("start_year")).toBe(false);
+ });
+});
diff --git a/frontend/tests/unit/edit-panel-series-attrs.test.js b/frontend/tests/unit/edit-panel-series-attrs.test.js
new file mode 100644
index 000000000..810ab3b4b
--- /dev/null
+++ b/frontend/tests/unit/edit-panel-series-attrs.test.js
@@ -0,0 +1,175 @@
+/*
+ * Tests for the tag editor's database-only series attributes.
+ *
+ * No format codex writes can hold a series' end year, status or summary,
+ * so the editor saves them straight to the database:
+ * - They seed from the series, under any write format.
+ * - They lock, with a visible reason, unless exactly one series is being
+ * edited and its name is unchanged.
+ * - A visible hint warns they are not written to the comic files.
+ * - Only changed ones are sent, to /admin/series-attrs, and never ride
+ * the comicbox patch; a save with nothing else skips the tag write.
+ */
+import { createTestingPinia } from "@pinia/testing";
+import { flushPromises, mount } from "@vue/test-utils";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+vi.mock("@/api/v4/base", () => ({
+ HTTP: { get: vi.fn(), post: vi.fn(), delete: vi.fn() },
+}));
+
+import { HTTP } from "@/api/v4/base";
+import EditPanel from "@/components/metadata/edit-mode/edit-panel.vue";
+import vuetify from "@/plugins/vuetify";
+import { useBrowserStore } from "@/stores/browser";
+
+const ONE_SERIES = Object.freeze([{ name: "S", ids: [1] }]);
+const SERIES_MD = Object.freeze({
+ seriesList: ONE_SERIES,
+ seriesStartYear: 1963,
+ seriesEndYear: 1996,
+ seriesStatus: "Completed",
+ seriesSummary: "A run.",
+});
+const WARNING =
+ "End year, status and series summary are saved in Codex only, " +
+ "not written to the comic files.";
+
+async function mountPanel({
+ formats = ["COMIC_INFO"],
+ md = SERIES_MD,
+ routeName = "reader",
+} = {}) {
+ const pinia = createTestingPinia({
+ initialState: {
+ metadata: { md: { ...md } },
+ admin: { taggingDefaults: { defaultFormats: formats } },
+ browser: { settings: { twentyFourHourTime: false } },
+ },
+ });
+ const wrapper = mount(EditPanel, {
+ props: { book: { pk: 1, collection: "series", ids: [1] } },
+ global: {
+ plugins: [pinia, vuetify],
+ mocks: { $route: { name: routeName } },
+ },
+ });
+ await flushPromises();
+ return wrapper;
+}
+
+async function setField(wrapper, field, value) {
+ wrapper.vm.patch[field] = value;
+ wrapper.vm.onFieldInput(field);
+ await flushPromises();
+}
+
+const postedUrls = () => vi.mocked(HTTP.post).mock.calls.map(([url]) => url);
+
+beforeEach(() => {
+ vi.mocked(HTTP.post).mockReset();
+ vi.mocked(HTTP.post).mockResolvedValue({ data: {} });
+});
+
+describe("EditPanel — series attributes seed and render", () => {
+ test("they seed from the series and render their own inputs", async () => {
+ const wrapper = await mountPanel();
+ expect(wrapper.vm.patch.end_year).toBe(1996);
+ expect(wrapper.vm.patch.series_status).toBe("Completed");
+ expect(wrapper.vm.patch.series_summary).toBe("A run.");
+ const labels = wrapper.findAll("label").map((el) => el.text());
+ expect(labels).toEqual(
+ expect.arrayContaining(["End Year", "Status", "Series Summary"]),
+ );
+ });
+
+ test("no write format gates them", async () => {
+ const wrapper = await mountPanel({ formats: ["COMIC_INFO"] });
+ for (const field of ["end_year", "series_status", "series_summary"]) {
+ expect(wrapper.vm.isFieldDisabled(field)).toBe(false);
+ }
+ });
+
+ test("the hint warns they stay out of the comic files", async () => {
+ const wrapper = await mountPanel();
+ expect(wrapper.vm.seriesAttrsHint).toBe(WARNING);
+ expect(wrapper.text()).toContain(WARNING);
+ });
+});
+
+describe("EditPanel — series attributes lock", () => {
+ test("two series lock them and say why", async () => {
+ const wrapper = await mountPanel({
+ md: {
+ seriesList: [
+ { name: "S", ids: [1] },
+ { name: "T", ids: [2] },
+ ],
+ },
+ });
+ expect(wrapper.vm.isFieldDisabled("end_year")).toBe(true);
+ expect(wrapper.vm.seriesAttrsHint).toBe(
+ `Edit one series at a time. ${WARNING}`,
+ );
+ });
+
+ test("renaming the series locks them until it is saved", async () => {
+ const wrapper = await mountPanel();
+ await setField(wrapper, "series", "Renamed");
+ expect(wrapper.vm.isFieldDisabled("series_status")).toBe(true);
+ expect(wrapper.vm.seriesAttrsLockReason).toBe(
+ "Save the series change first.",
+ );
+ });
+});
+
+describe("EditPanel — series attributes save", () => {
+ test("only changed attributes are sent, never in the patch", async () => {
+ const wrapper = await mountPanel();
+ await setField(wrapper, "end_year", "1998");
+ expect(wrapper.vm.buildSeriesAttrs()).toEqual({ endYear: 1998 });
+ const { patch, deleteKeys } = wrapper.vm.buildPatch();
+ expect(patch).toEqual({});
+ expect(deleteKeys).toEqual([]);
+ });
+
+ test("clears send empty values", async () => {
+ const wrapper = await mountPanel();
+ for (const field of ["end_year", "series_status", "series_summary"]) {
+ wrapper.vm.toggleClear(field);
+ }
+ await flushPromises();
+ expect(wrapper.vm.buildSeriesAttrs()).toEqual({
+ endYear: null,
+ status: "",
+ summary: "",
+ });
+ });
+
+ test("a database-only save skips the preflight and the tag write", async () => {
+ const wrapper = await mountPanel();
+ await setField(wrapper, "series_status", "Ongoing");
+ await wrapper.vm.preSave();
+ expect(postedUrls()).toEqual(["/admin/series-attrs"]);
+ expect(HTTP.post).toHaveBeenCalledWith("/admin/series-attrs", {
+ collection: "series",
+ pks: ["1"],
+ status: "Ongoing",
+ });
+ });
+
+ test("a save with tag edits too posts both", async () => {
+ const wrapper = await mountPanel({ md: { ...SERIES_MD, summary: "x" } });
+ await setField(wrapper, "series_summary", "A longer run.");
+ await setField(wrapper, "summary", "y");
+ await wrapper.vm.doSave();
+ expect(postedUrls()).toEqual(["/admin/series-attrs", "/admin/tag-write"]);
+ });
+
+ test("the browser page reloads so the title years refresh", async () => {
+ const wrapper = await mountPanel({ routeName: "browser" });
+ await setField(wrapper, "end_year", "1998");
+ await wrapper.vm.doSave();
+ expect(useBrowserStore().loadBrowserPage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/tests/unit/metadata-header.test.js b/frontend/tests/unit/metadata-header.test.js
index 7221d95f6..0e6e61f99 100644
--- a/frontend/tests/unit/metadata-header.test.js
+++ b/frontend/tests/unit/metadata-header.test.js
@@ -18,7 +18,7 @@ import MetadataHeader from "@/components/metadata/metadata-header.vue";
import vuetify from "@/plugins/vuetify";
import { useMetadataStore } from "@/stores/metadata";
-function mountHeader({ multiSelect = false, editing = false } = {}) {
+function mountHeader({ multiSelect = false, editing = false, md = {} } = {}) {
const pinia = createTestingPinia({
initialState: { browser: { settings: { search: "" } } },
});
@@ -28,6 +28,7 @@ function mountHeader({ multiSelect = false, editing = false } = {}) {
collection: "comics",
ids: [1, 2, 3],
pageCount: 3,
+ ...md,
};
return mount(MetadataHeader, {
global: {
@@ -65,4 +66,72 @@ describe("MetadataHeader — controls toolbar gate", () => {
});
});
+const seriesYears = (md) => mountHeader({ md }).vm.seriesYears;
+
+describe("MetadataHeader — series years", () => {
+ test("shows a closed range", () => {
+ expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1996 })).toBe(
+ "1963–1996",
+ );
+ });
+
+ test("leaves an ongoing range open", () => {
+ expect(seriesYears({ seriesStartYear: 1963 })).toBe("1963–");
+ });
+
+ test("collapses a single-year run", () => {
+ expect(seriesYears({ seriesStartYear: 1963, seriesEndYear: 1963 })).toBe(
+ "1963",
+ );
+ });
+
+ test("shows an end year with no known start", () => {
+ expect(seriesYears({ seriesEndYear: 1996 })).toBe("–1996");
+ });
+
+ test("shows nothing when neither year is known", () => {
+ expect(seriesYears({})).toBe("");
+ });
+});
+
export default {};
+
+const seriesFacts = (md) => mountHeader({ md }).vm.seriesFacts;
+const ONE_SERIES = Object.freeze([{ ids: [1], name: "Amazing Spider-Man" }]);
+
+describe("MetadataHeader — series facts", () => {
+ test("joins the years and the status", () => {
+ expect(
+ seriesFacts({
+ seriesStartYear: 1963,
+ seriesEndYear: 1996,
+ seriesStatus: "Completed",
+ }),
+ ).toBe("1963–1996 · Completed");
+ });
+
+ test("shows the status alone when no year is known", () => {
+ expect(seriesFacts({ seriesStatus: "Ongoing" })).toBe("Ongoing");
+ });
+
+ test("is empty when nothing is known", () => {
+ expect(seriesFacts({})).toBe("");
+ });
+
+ test("renders its own line under the series row", () => {
+ const wrapper = mountHeader({
+ md: {
+ seriesList: ONE_SERIES,
+ seriesStartYear: 1963,
+ seriesStatus: "Ongoing",
+ },
+ });
+ expect(wrapper.find("#seriesFactsRow").text()).toBe("1963– · Ongoing");
+ });
+
+ test("renders no line when there are no facts", () => {
+ const wrapper = mountHeader({ md: { seriesList: ONE_SERIES } });
+ expect(wrapper.find("#seriesRow").exists()).toBe(true);
+ expect(wrapper.find("#seriesFactsRow").exists()).toBe(false);
+ });
+});
diff --git a/package.json b/package.json
index ea567fdbb..471dd91db 100644
--- a/package.json
+++ b/package.json
@@ -71,7 +71,7 @@
"eslint-plugin-no-secrets": "^2.3.3",
"eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-no-use-extend-native": "^0.7.3",
- "eslint-plugin-perfectionist": "^5.12.1",
+ "eslint-plugin-perfectionist": "^5.13.0",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-regexp": "^3.3.1",
@@ -86,7 +86,7 @@
"prettier": "^3.9.10",
"prettier-plugin-nginx": "^2.0.0",
"prettier-plugin-packagejson": "^3.0.2",
- "prettier-plugin-toml": "^4.0.0",
+ "prettier-plugin-toml": "^4.1.0",
"svgo": "^4.1.0"
}
}
diff --git a/pyproject.toml b/pyproject.toml
index 459602177..1756bea03 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dependencies = [
"asgiref~=3.12",
"bidict~=0.23",
"channels~=4.2",
- "comicbox[pdf]~=5.3.2",
+ "comicbox[pdf]~=5.4.0",
"cryptography>=48.0.0",
"dateparser~=1.2",
"django-allauth[socialaccount]~=65.13",
@@ -55,7 +55,7 @@ readme = "README.md"
requires-python = ">=3.12"
license = "GPL-3.0-only"
name = "codex"
-version = "2.5.5"
+version = "2.5.6"
[[project.authors]]
name = "AJ Slater"
diff --git a/tests/importer/test_basic.py b/tests/importer/test_basic.py
index acbafd697..065e85e47 100644
--- a/tests/importer/test_basic.py
+++ b/tests/importer/test_basic.py
@@ -27,7 +27,6 @@
DELETE_M2MS,
FIS,
FTS_CREATE,
- FTS_CREATED_M2MS,
FTS_EXISTING_M2MS,
FTS_UPDATE,
FTS_UPDATED_M2MS,
@@ -143,7 +142,9 @@
"TestImprint",
"Captain Science",
): {
- (None, None),
+ # identifier, volume_count, start_year, end_year, status,
+ # summary
+ (None, None, None, None, None, None),
}
},
SeriesGroup: {("science comics",): set()},
@@ -418,6 +419,10 @@
"Captain Science",
None,
None,
+ None,
+ None,
+ None,
+ None,
)
},
Volume: {
@@ -584,7 +589,6 @@
DELETE_M2MS: {},
FTS_EXISTING_M2MS: {},
FTS_UPDATED_M2MS: {},
- FTS_CREATED_M2MS: {"universes": {("Young Adult Silly Universe",): ("4242",)}},
}
)
CREATED_COMICS = MappingProxyType(
@@ -631,6 +635,7 @@
"review": ("It wasn't all bad.",),
"scan_info": ("Photocopied",),
"series": ("Captain Science",),
+ "series_summary": ("",),
"summary": ("Captain Science's many scientific adventures",),
"age_rating_tagged": ("Everyone",),
"tagger": ("comicbox dev",),
@@ -638,7 +643,6 @@
},
FTS_UPDATED_M2MS: {},
FTS_EXISTING_M2MS: {},
- FTS_CREATED_M2MS: {"universes": {("Young Adult Silly Universe",): ("4242",)}},
}
)
LINKED_COMICS = MappingProxyType(
@@ -662,6 +666,7 @@
"review": ("It wasn't all bad.",),
"scan_info": ("Photocopied",),
"series": ("Captain Science",),
+ "series_summary": ("",),
"series_groups": ("science comics",),
"sources": ("comicvine", "metron"),
"stories": ("The Beginning",),
@@ -671,14 +676,10 @@
"tagger": ("comicbox dev",),
"tags": ("a", "b", "c"),
"teams": ("Team Scientific Method",),
- "universes": (
- "4242",
- "Young Adult Silly Universe",
- ),
+ "universes": ("Young Adult Silly Universe",),
},
},
FTS_EXISTING_M2MS: {},
- FTS_CREATED_M2MS: {"universes": {}},
FTS_UPDATED_M2MS: {},
}
)
@@ -703,6 +704,7 @@
"review": ("It wasn't all bad.",),
"scan_info": ("Photocopied",),
"series": ("Captain Science",),
+ "series_summary": ("",),
"series_groups": ("science comics",),
"sources": ("comicvine", "metron"),
"stories": ("The Beginning",),
@@ -712,15 +714,11 @@
"tagger": ("comicbox dev",),
"tags": ("a", "b", "c"),
"teams": ("Team Scientific Method",),
- "universes": (
- "4242",
- "Young Adult Silly Universe",
- ),
+ "universes": ("Young Adult Silly Universe",),
},
},
FTS_EXISTING_M2MS: {},
FTS_UPDATED_M2MS: {},
- FTS_CREATED_M2MS: {"universes": {}},
}
)
DELETED_COMICS = MappingProxyType(
@@ -743,6 +741,7 @@
"review": ("It wasn't all bad.",),
"scan_info": ("Photocopied",),
"series": ("Captain Science",),
+ "series_summary": ("",),
"series_groups": ("science comics",),
"sources": ("comicvine", "metron"),
"stories": ("The Beginning",),
@@ -752,15 +751,11 @@
"tagger": ("comicbox dev",),
"tags": ("a", "b", "c"),
"teams": ("Team Scientific Method",),
- "universes": (
- "4242",
- "Young Adult Silly Universe",
- ),
+ "universes": ("Young Adult Silly Universe",),
},
},
FTS_EXISTING_M2MS: {},
FTS_UPDATED_M2MS: {},
- FTS_CREATED_M2MS: {"universes": {}},
}
)
FTSED = MappingProxyType({})
@@ -789,6 +784,8 @@ def create_fts_strings(md, pk):
"country": "US,United States",
"language": "en,English",
"sources": "Comic Vine,Metron,comicvine,metron",
+ # Designations join the names when the search entry is written.
+ "universes": "4242,Young Adult Silly Universe",
}
)
diff --git a/tests/importer/test_fts_chunked_resync.py b/tests/importer/test_fts_chunked_resync.py
new file mode 100644
index 000000000..6adbc9c28
--- /dev/null
+++ b/tests/importer/test_fts_chunked_resync.py
@@ -0,0 +1,123 @@
+"""
+An import's foreign-key search re-sync covers every per-comic chunk.
+
+``read → query → create_and_update → link`` runs once per chunk of paths,
+but the search index is synced once afterwards. The tags each chunk
+updated must accumulate until then, or only the last chunk's comics would
+be re-indexed.
+"""
+
+import shutil
+import zipfile
+from pathlib import Path
+from threading import Event, Lock
+from typing import Final, override
+from unittest.mock import patch
+
+from django.core.cache import cache
+from django.test import TestCase
+from loguru import logger
+
+from codex.librarian.mp_queue import LIBRARIAN_QUEUE
+from codex.librarian.scribe.importer.importer import ComicImporter
+from codex.librarian.scribe.importer.tasks import ImportTask
+from codex.models.comic import ComicFTS
+from codex.models.library import Library
+from codex.startup import codex_init
+from tests.importer.test_basic import run_full_import
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR: Final = tmp_dir(Path(__file__).stem)
+
+
+def _yaml(universe: str, designation: str) -> str:
+ return f"""comicbox:
+ publisher:
+ name: P
+ series:
+ name: S
+ universes:
+ {universe}:
+ designation: '{designation}'
+"""
+
+
+class TestFTSChunkedResync(TestCase):
+ """Two chunks each update a universe; both universes' comics re-index."""
+
+ @override
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cache.clear()
+ codex_init()
+
+ @override
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ finally:
+ super().tearDownClass()
+
+ @override
+ def setUp(self):
+ cache.clear()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable]
+
+ @override
+ def tearDown(self):
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ cache.clear()
+
+ @staticmethod
+ def _write(name: str, yaml: str) -> str:
+ path = TMP_DIR / name
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("comicbox.yaml", yaml)
+ zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg")
+ return str(path)
+
+ def _importer(self, paths) -> ComicImporter:
+ # A same-size rewrite within the same second looks unchanged to the
+ # mtime prefilter, so always re-read.
+ task = ImportTask(
+ library_id=self.library.pk,
+ files_modified=frozenset(paths),
+ check_metadata_mtime=False,
+ )
+ return ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event())
+
+ @staticmethod
+ def _universes_column(path: str) -> str:
+ return ComicFTS.objects.get(comic__path=path).universes
+
+ def test_every_chunk_reindexes_its_universes(self) -> None:
+ """Siblings outside the import pick up both chunks' designations."""
+ paths = {
+ name: self._write(f"{name}.cbz", _yaml(universe, designation))
+ for name, universe, designation in (
+ ("a1", "Alpha", "1"),
+ ("a2", "Alpha", "1"),
+ ("b1", "Beta", "2"),
+ ("b2", "Beta", "2"),
+ )
+ }
+ run_full_import(self._importer(paths.values()))
+
+ # Re-import one comic per universe with a new designation, one
+ # comic per chunk. a2 and b2 are re-indexed only by the re-sync.
+ self._write("a1.cbz", _yaml("Alpha", "11"))
+ self._write("b1.cbz", _yaml("Beta", "22"))
+ importer = self._importer((paths["a1"], paths["b1"]))
+ with patch.object(
+ ComicImporter, "_compute_chunk_size", staticmethod(lambda: 1)
+ ):
+ assert importer._run_per_comic_phases_chunked() # noqa: SLF001
+ importer.fail_imports()
+ importer.delete()
+ importer.full_text_search()
+
+ assert self._universes_column(paths["a2"]) == "11,Alpha"
+ assert self._universes_column(paths["b2"]) == "22,Beta"
diff --git a/tests/importer/test_noop_update.py b/tests/importer/test_noop_update.py
new file mode 100644
index 000000000..634e56846
--- /dev/null
+++ b/tests/importer/test_noop_update.py
@@ -0,0 +1,198 @@
+"""
+Re-importing an unchanged comic writes nothing and reloads no browser.
+
+Issue 992: a lazy import of a comic with no embedded tags re-read the
+archive, found nothing new, and still rewrote the row, purged and rebuilt
+its cover and broadcast LIBRARY_CHANGED. Every open browser reloaded. The
+update phase now skips a comic whose tags, links, derived values and file
+stat are all current, while Force Update still repairs a wrong link.
+"""
+
+import shutil
+import zipfile
+from pathlib import Path
+from threading import Event, Lock
+from typing import Final, override
+from unittest.mock import MagicMock
+
+from django.core.cache import cache
+from django.test import TestCase
+from loguru import logger
+
+from codex.librarian.covers.tasks import CoverCreateTask
+from codex.librarian.notifier.tasks import LIBRARY_CHANGED_TASK
+from codex.librarian.scribe.importer.importer import ComicImporter
+from codex.librarian.scribe.importer.tasks import ImportTask
+from codex.models import Comic, Genre, Library, Publisher, Universe
+from codex.startup import codex_init
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR: Final = tmp_dir(Path(__file__).stem)
+_PAGE: Final = b"\xff\xd8\xff\xe0not-a-real-jpeg"
+_TAGS: Final = """comicbox:
+ publisher:
+ name: P
+ series:
+ name: S
+ genres:
+ Drama: {}
+ universes:
+ Alpha:
+ designation: '1'
+"""
+
+
+class TestNoopUpdate(TestCase):
+ """An unchanged comic skips the update; a changed one does not."""
+
+ @override
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cache.clear()
+ codex_init()
+
+ @override
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ finally:
+ super().tearDownClass()
+
+ @override
+ def setUp(self):
+ cache.clear()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable]
+
+ @override
+ def tearDown(self):
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ cache.clear()
+
+ @staticmethod
+ def _write(name: str, tags: str | None, pages: int = 1) -> str:
+ path = TMP_DIR / name
+ with zipfile.ZipFile(path, "w") as zf:
+ if tags is not None:
+ zf.writestr("comicbox.yaml", tags)
+ for page in range(pages):
+ zf.writestr(f"page{page}.jpg", _PAGE)
+ return str(path)
+
+ def _import(self, task: ImportTask) -> MagicMock:
+ """Run every import phase and finish; return the librarian queue."""
+ queue = MagicMock()
+ importer = ComicImporter(task, logger, queue, Lock(), Event())
+ assert importer._run_per_comic_phases_chunked() # noqa: SLF001
+ importer.fail_imports()
+ importer.delete()
+ importer.full_text_search()
+ importer.finish()
+ return queue
+
+ def _scan(self, path: str) -> None:
+ self._import(
+ ImportTask(library_id=self.library.pk, files_created=frozenset({path}))
+ )
+
+ def _force(self, path: str, *, check_metadata_mtime: bool = False) -> MagicMock:
+ """Import the way Force Update (and, with the mtime check, Lazy) does."""
+ return self._import(
+ ImportTask(
+ library_id=self.library.pk,
+ files_modified=frozenset({path}),
+ force_import_metadata=True,
+ check_metadata_mtime=check_metadata_mtime,
+ )
+ )
+
+ @staticmethod
+ def _queued(queue: MagicMock) -> list:
+ return [call.args[0] for call in queue.put.call_args_list]
+
+ def _assert_untouched(self, path: str, queue: MagicMock, updated_at) -> None:
+ queued = self._queued(queue)
+ assert LIBRARY_CHANGED_TASK not in queued, queued
+ assert not any(isinstance(task, CoverCreateTask) for task in queued), queued
+ assert Comic.objects.get(path=path).updated_at == updated_at
+
+ def test_lazy_import_of_tagless_comic_is_silent(self) -> None:
+ """The reporter's case: hovering a comic with no tags file."""
+ path = self._write("tagless.cbz", None)
+ self._scan(path)
+ updated_at = Comic.objects.get(path=path).updated_at
+
+ queue = self._force(path, check_metadata_mtime=True)
+
+ self._assert_untouched(path, queue, updated_at)
+
+ def test_force_update_of_unchanged_comic_is_silent(self) -> None:
+ """Force Update re-reads tags that turn out the same."""
+ path = self._write("tagged.cbz", _TAGS)
+ self._scan(path)
+ updated_at = Comic.objects.get(path=path).updated_at
+
+ queue = self._force(path)
+
+ self._assert_untouched(path, queue, updated_at)
+
+ def test_changed_file_still_updates(self) -> None:
+ """New pages with the same tags still rewrite the row and the cover."""
+ path = self._write("tagless.cbz", None)
+ self._scan(path)
+ updated_at = Comic.objects.get(path=path).updated_at
+ self._write("tagless.cbz", None, pages=2)
+
+ queue = self._force(path)
+
+ queued = self._queued(queue)
+ assert LIBRARY_CHANGED_TASK in queued
+ assert any(isinstance(task, CoverCreateTask) for task in queued), queued
+ comic = Comic.objects.get(path=path)
+ assert comic.updated_at > updated_at
+ assert comic.page_count == 2 # noqa: PLR2004
+
+ def test_force_update_repairs_a_wrong_fk_link(self) -> None:
+ """Force Update still relinks a comic whose file is unchanged."""
+ path = self._write("tagged.cbz", _TAGS)
+ self._scan(path)
+ wrong = Publisher.objects.create(name="Wrong")
+ Comic.objects.filter(path=path).update(publisher=wrong)
+ updated_at = Comic.objects.get(path=path).updated_at
+
+ queue = self._force(path)
+
+ assert LIBRARY_CHANGED_TASK in self._queued(queue)
+ comic = Comic.objects.get(path=path)
+ assert comic.publisher.name == "P"
+ assert comic.updated_at > updated_at
+
+ def test_force_update_repairs_a_stray_m2m_link(self) -> None:
+ """Force Update still unlinks a genre the file does not name."""
+ path = self._write("tagged.cbz", _TAGS)
+ self._scan(path)
+ comic = Comic.objects.get(path=path)
+ comic.genres.add(Genre.objects.create(name="Stray"))
+
+ queue = self._force(path)
+
+ assert LIBRARY_CHANGED_TASK in self._queued(queue)
+ names = set(comic.genres.values_list("name", flat=True))
+ assert names == {"Drama"}, names
+ # The row write is what re-syncs its search entry and timestamp.
+ assert Comic.objects.get(path=path).updated_at > comic.updated_at
+
+ def test_force_update_of_a_tag_row_keeps_the_comic(self) -> None:
+ """A corrected tag row reaches the browser through its comic's rewrite."""
+ path = self._write("tagged.cbz", _TAGS)
+ self._scan(path)
+ Universe.objects.filter(name="Alpha").update(designation="9")
+ updated_at = Comic.objects.get(path=path).updated_at
+
+ queue = self._force(path)
+
+ assert LIBRARY_CHANGED_TASK in self._queued(queue)
+ assert Universe.objects.get(name="Alpha").designation == "1"
+ assert Comic.objects.get(path=path).updated_at > updated_at
diff --git a/tests/importer/test_series_attrs.py b/tests/importer/test_series_attrs.py
new file mode 100644
index 000000000..465c0a1e4
--- /dev/null
+++ b/tests/importer/test_series_attrs.py
@@ -0,0 +1,203 @@
+"""
+Comics of one series combine their series attributes in an import batch.
+
+Every comic proposes its own values for its series row. Within a batch
+they merge per attribute: the earliest start year, the latest end year,
+the largest count and the first comic's status and text. Against the
+stored row a
+later batch's values win and missing ones leave it alone.
+"""
+
+import shutil
+import zipfile
+from pathlib import Path
+from threading import Event, Lock
+from typing import Final, override
+
+from django.core.cache import cache
+from django.test import TestCase
+from loguru import logger
+
+from codex.librarian.mp_queue import LIBRARIAN_QUEUE
+from codex.librarian.scribe.importer.const import (
+ CREATE_FKS,
+ EXTRACTED,
+ FIS,
+ UPDATE_FKS,
+)
+from codex.librarian.scribe.importer.importer import ComicImporter
+from codex.librarian.scribe.importer.tasks import ImportTask
+from codex.models import Series, Volume
+from codex.models.library import Library
+from codex.startup import codex_init
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR: Final = tmp_dir(Path(__file__).stem)
+_VOLUME_NUMBER: Final = 1963
+_EARLY: Final = 1963
+_MIDDLE: Final = 1970
+_LATE: Final = 1996
+_FEW: Final = 3
+_MANY: Final = 7
+_SUMMARY_B: Final = "The second comic's series summary."
+_SUMMARY_C: Final = "The third comic's series summary."
+_ONGOING: Final = "Ongoing"
+_COMPLETED: Final = "Completed"
+
+
+_ARCHIVE_YAML: Final = f"""comicbox:
+ publisher:
+ name: P
+ series:
+ name: S
+ start_year: {_EARLY}
+ end_year: {_LATE}
+ status: completed
+ summary: {_SUMMARY_B}
+"""
+
+
+def _md(series: dict | None = None, volume: dict | None = None) -> dict:
+ """Comicbox metadata for one comic of series S."""
+ return {
+ "publisher": {"name": "P"},
+ "series": {"name": "S", **(series or {})},
+ "volume": {"number": _VOLUME_NUMBER, **(volume or {})},
+ }
+
+
+class TestSeriesAttrs(TestCase):
+ """Series attributes merge in a batch and update the stored row."""
+
+ @override
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cache.clear()
+ codex_init()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+
+ @override
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ finally:
+ super().tearDownClass()
+
+ @override
+ def setUp(self):
+ cache.clear()
+ self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable]
+
+ def _importer(self, paths) -> ComicImporter:
+ task = ImportTask(library_id=self.library.pk, files_modified=frozenset(paths))
+ return ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event())
+
+ @staticmethod
+ def _create_collections(importer: ComicImporter) -> None:
+ importer.metadata[CREATE_FKS] = {}
+ importer.metadata[UPDATE_FKS] = {}
+ importer.query_all_missing_models()
+ importer.create_all_fks()
+ importer.update_all_fks()
+
+ def _import(self, *mds: dict) -> None:
+ """Aggregate, query and create the collections for one batch."""
+ paths = {str(TMP_DIR / f"c{index}.cbz"): md for index, md in enumerate(mds)}
+ importer = self._importer(paths)
+ importer.metadata[FIS] = {}
+ importer.metadata[EXTRACTED] = paths
+ importer.aggregate_metadata()
+ self._create_collections(importer)
+
+ def test_archive_series_attrs(self) -> None:
+ """The attributes come through from an archive's comicbox metadata."""
+ path = TMP_DIR / "archive.cbz"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("comicbox.yaml", _ARCHIVE_YAML)
+ zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg")
+ importer = self._importer({str(path)})
+ importer.read()
+ self._create_collections(importer)
+
+ series = Series.objects.get(name="S")
+ assert series.start_year == _EARLY
+ assert series.end_year == _LATE
+ assert series.status == _COMPLETED
+ assert series.summary == _SUMMARY_B
+
+ def test_batch_merges_series_attrs(self) -> None:
+ """Earliest start, latest end, largest count, first status and text."""
+ self._import(
+ _md({"start_year": _MIDDLE, "volume_count": _MANY}),
+ _md(
+ {
+ "start_year": _EARLY,
+ "end_year": _LATE,
+ "status": _ONGOING,
+ "summary": _SUMMARY_B,
+ },
+ {"issue_count": _MANY},
+ ),
+ _md(
+ {
+ "end_year": _MIDDLE,
+ "volume_count": _FEW,
+ "status": _COMPLETED,
+ "summary": _SUMMARY_C,
+ },
+ {"issue_count": _FEW},
+ ),
+ )
+
+ series = Series.objects.get(name="S")
+ assert series.start_year == _EARLY
+ assert series.end_year == _LATE
+ assert series.volume_count == _MANY
+ assert series.status == _ONGOING
+ assert series.summary == _SUMMARY_B
+ assert Volume.objects.get(series=series).issue_count == _MANY
+
+ def test_missing_attrs_create_empty(self) -> None:
+ """A series with no attributes gets nulls and empty text."""
+ self._import(_md())
+
+ series = Series.objects.get(name="S")
+ assert series.start_year is None
+ assert series.end_year is None
+ assert series.status == ""
+ assert series.summary == ""
+
+ def test_later_batch_updates_stored_series(self) -> None:
+ """A later batch's values replace stored ones; missing ones don't."""
+ self._import(
+ _md(
+ {
+ "start_year": _EARLY,
+ "end_year": _MIDDLE,
+ "status": _ONGOING,
+ "summary": _SUMMARY_B,
+ }
+ )
+ )
+ self._import(_md({"end_year": _LATE, "status": _COMPLETED}))
+
+ series = Series.objects.get(name="S")
+ assert series.start_year == _EARLY
+ assert series.end_year == _LATE
+ assert series.status == _COMPLETED
+ assert series.summary == _SUMMARY_B
+
+ def test_reimport_keeps_database_only_attrs(self) -> None:
+ """Values the tag editor saved to the database survive a re-import."""
+ self._import(_md({"start_year": _EARLY}))
+ Series.objects.filter(name="S").update(
+ end_year=_LATE, status=_COMPLETED, summary=_SUMMARY_C
+ )
+ self._import(_md({"start_year": _EARLY}))
+
+ series = Series.objects.get(name="S")
+ assert series.end_year == _LATE
+ assert series.status == _COMPLETED
+ assert series.summary == _SUMMARY_C
diff --git a/tests/importer/test_universe_fts_designations.py b/tests/importer/test_universe_fts_designations.py
new file mode 100644
index 000000000..9f4982ad9
--- /dev/null
+++ b/tests/importer/test_universe_fts_designations.py
@@ -0,0 +1,144 @@
+"""
+Every comic's search row carries its universes' designations.
+
+A full ``SearchIndexerSync`` rebuild indexes each universe as its
+designation and its name. The importer writes the same row inline, so a
+comic must get the designation whether its universe was created in this
+import, already existed, was kept from an earlier import, or was linked in
+a later chunk of a chunked import.
+"""
+
+import shutil
+import zipfile
+from pathlib import Path
+from threading import Event, Lock
+from typing import Final, override
+from unittest.mock import patch
+
+from django.core.cache import cache
+from django.test import TestCase
+from loguru import logger
+
+from codex.librarian.mp_queue import LIBRARIAN_QUEUE
+from codex.librarian.scribe.importer.importer import ComicImporter
+from codex.librarian.scribe.importer.tasks import ImportTask
+from codex.models.comic import ComicFTS
+from codex.models.library import Library
+from codex.startup import codex_init
+from tests.importer.test_basic import run_full_import
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR: Final = tmp_dir(Path(__file__).stem)
+_ALPHA: Final = ("Alpha", "1")
+_BETA: Final = ("Beta", "2")
+
+
+def _yaml(*universes: tuple[str, str]) -> str:
+ lines = [
+ "comicbox:",
+ " publisher:",
+ " name: P",
+ " series:",
+ " name: S",
+ " universes:",
+ ]
+ for name, designation in universes:
+ lines += [f" {name}:", f" designation: '{designation}'"]
+ return "\n".join(lines) + "\n"
+
+
+class TestUniverseFTSDesignations(TestCase):
+ """The importer indexes universe designations on every linked comic."""
+
+ @override
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cache.clear()
+ codex_init()
+
+ @override
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ finally:
+ super().tearDownClass()
+
+ @override
+ def setUp(self):
+ cache.clear()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable]
+
+ @override
+ def tearDown(self):
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ cache.clear()
+
+ @staticmethod
+ def _write(name: str, *universes: tuple[str, str]) -> str:
+ path = TMP_DIR / f"{name}.cbz"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("comicbox.yaml", _yaml(*universes))
+ zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg")
+ return str(path)
+
+ def _importer(self, *paths: str) -> ComicImporter:
+ # A same-size rewrite within the same second looks unchanged to the
+ # mtime prefilter, so always re-read.
+ task = ImportTask(
+ library_id=self.library.pk,
+ files_modified=frozenset(paths),
+ check_metadata_mtime=False,
+ )
+ return ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event())
+
+ def _import(self, *paths: str) -> None:
+ run_full_import(self._importer(*paths))
+
+ @staticmethod
+ def _universes_column(path: str) -> str:
+ return ComicFTS.objects.get(comic__path=path).universes
+
+ def test_new_universe_shared_in_one_batch(self) -> None:
+ """Every comic linking a newly created universe gets its designation."""
+ paths = (self._write("a1", _ALPHA), self._write("a2", _ALPHA))
+ self._import(*paths)
+ for path in paths:
+ assert self._universes_column(path) == "1,Alpha", path
+
+ def test_several_universes_on_one_comic(self) -> None:
+ """A comic with two new universes gets both designations."""
+ path = self._write("ab", _ALPHA, _BETA)
+ self._import(path)
+ assert self._universes_column(path) == "1,2,Alpha,Beta"
+
+ def test_new_link_to_an_existing_universe(self) -> None:
+ """A comic linking a universe an earlier import created gets it too."""
+ self._import(self._write("a1", _ALPHA))
+ path = self._write("a2", _ALPHA)
+ self._import(path)
+ assert self._universes_column(path) == "1,Alpha"
+
+ def test_kept_universe_keeps_its_designation(self) -> None:
+ """Adding a universe to a comic keeps the old one's designation."""
+ path = self._write("a1", _ALPHA)
+ self._import(path)
+ self._write("a1", _ALPHA, _BETA)
+ self._import(path)
+ assert self._universes_column(path) == "1,2,Alpha,Beta"
+
+ def test_later_chunk_links_an_earlier_chunks_universe(self) -> None:
+ """A comic in a later chunk gets a universe an earlier chunk created."""
+ paths = (self._write("a1", _ALPHA), self._write("a2", _ALPHA))
+ importer = self._importer(*paths)
+ with patch.object(
+ ComicImporter, "_compute_chunk_size", staticmethod(lambda: 1)
+ ):
+ assert importer._run_per_comic_phases_chunked() # noqa: SLF001
+ importer.fail_imports()
+ importer.delete()
+ importer.full_text_search()
+ for path in paths:
+ assert self._universes_column(path) == "1,Alpha", path
diff --git a/tests/importer/test_update_all.py b/tests/importer/test_update_all.py
index 22c3f1731..99dc6f998 100644
--- a/tests/importer/test_update_all.py
+++ b/tests/importer/test_update_all.py
@@ -12,7 +12,6 @@
CREATE_FKS,
DELETE_M2MS,
FIS,
- FTS_CREATED_M2MS,
FTS_EXISTING_M2MS,
FTS_UPDATE,
FTS_UPDATED_M2MS,
@@ -225,6 +224,10 @@
(
("comicvine", "series", "333"),
None,
+ None,
+ None,
+ None,
+ None,
),
}
},
@@ -498,6 +501,11 @@
"Captain Science",
("comicvine", "series", "333"),
None,
+ None,
+ None,
+ # The stored status and summary stand.
+ "",
+ "",
)
},
Character: {("Captain Science", ("metron", "character", "123"))},
@@ -612,7 +620,6 @@
}
},
FTS_UPDATED_M2MS: {"universes": {1}},
- FTS_CREATED_M2MS: {},
}
)
CREATED_COMICS_UPDATE_ALL = MappingProxyType(
@@ -673,7 +680,6 @@
}
},
FTS_UPDATED_M2MS: {"universes": {1}},
- FTS_CREATED_M2MS: {},
}
)
LINKED_COMICS_UPDATE_ALL = MappingProxyType(
@@ -715,12 +721,10 @@
}
},
FTS_UPDATED_M2MS: {"universes": {1}},
- FTS_CREATED_M2MS: {},
}
)
FAILED_IMPORTS_UPDATE_ALL = MappingProxyType(
{
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {
1: {
"alternate_series": ("Capitan Sciencia",),
@@ -761,7 +765,6 @@
)
DELETED_COMICS_UPDATE_ALL = MappingProxyType(
{
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {
1: {
"alternate_series": ("Capitan Sciencia",),
diff --git a/tests/importer/test_update_none.py b/tests/importer/test_update_none.py
index f919681d2..69df491fe 100644
--- a/tests/importer/test_update_none.py
+++ b/tests/importer/test_update_none.py
@@ -14,7 +14,6 @@
CREATE_FKS,
DELETE_M2MS,
FIS,
- FTS_CREATED_M2MS,
FTS_EXISTING_M2MS,
FTS_UPDATE,
FTS_UPDATED_M2MS,
@@ -58,7 +57,6 @@
CREATE_COMICS: {},
DELETE_M2MS: {},
FIS: {},
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {},
FTS_UPDATED_M2MS: {},
LINK_FKS: {},
@@ -70,9 +68,8 @@
{
DELETE_M2MS: {},
FIS: {},
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {},
- FTS_UPDATE: {1: {}},
+ FTS_UPDATE: {},
FTS_UPDATED_M2MS: {},
LINK_M2MS: {},
}
@@ -80,25 +77,22 @@
LINKED_COMICS_UPDATE_NONE = MappingProxyType(
{
FIS: {},
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {},
- FTS_UPDATE: {1: {}},
+ FTS_UPDATE: {},
FTS_UPDATED_M2MS: {},
}
)
FAILED_IMPORTS_UPDATE_NONE = MappingProxyType(
{
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {},
- FTS_UPDATE: {1: {}},
+ FTS_UPDATE: {},
FTS_UPDATED_M2MS: {},
}
)
DELETED_COMICS_UPDATE_NONE = MappingProxyType(
{
- FTS_CREATED_M2MS: {},
FTS_EXISTING_M2MS: {},
- FTS_UPDATE: {1: {}},
+ FTS_UPDATE: {},
FTS_UPDATED_M2MS: {},
}
)
diff --git a/tests/test_admin_series_attrs.py b/tests/test_admin_series_attrs.py
new file mode 100644
index 000000000..065f35636
--- /dev/null
+++ b/tests/test_admin_series_attrs.py
@@ -0,0 +1,173 @@
+"""The tag editor saves a series' end year, status and summary to the database."""
+
+import shutil
+from typing import Final, override
+
+from django.contrib.auth.models import User
+from django.test import Client, TestCase
+
+from codex.models import Comic, Imprint, Library, Publisher, Series, Volume
+from codex.models.comic import ComicFTS
+from codex.startup import init_admin_flags
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR = tmp_dir("codex.tests.admin_series_attrs")
+_URL: Final = "/api/v4/admin/series-attrs"
+_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105
+_HTTP_OK: Final = 200
+_HTTP_BAD_REQUEST: Final = 400
+_HTTP_FORBIDDEN: Final = 403
+_START_YEAR: Final = 1963
+_END_YEAR: Final = 1996
+_NEW_END_YEAR: Final = 1998
+_SUMMARY: Final = "A friendly neighborhood series."
+
+
+class AdminSeriesAttrsTestCase(TestCase):
+ """POST /admin/series-attrs updates the one series the comics share."""
+
+ @override
+ def setUp(self) -> None:
+ """Seed two series with one comic each."""
+ init_admin_flags()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.library = Library.objects.create(path=str(TMP_DIR)) # pyright: ignore[reportUninitializedInstanceVariable]
+ self.publisher = Publisher.objects.create(name="Pub") # pyright: ignore[reportUninitializedInstanceVariable]
+ self.imprint = Imprint.objects.create(name="Imp", publisher=self.publisher) # pyright: ignore[reportUninitializedInstanceVariable]
+ self.series = self._series("Ser", start_year=_START_YEAR, end_year=_END_YEAR) # pyright: ignore[reportUninitializedInstanceVariable]
+ self.other = self._series("Other") # pyright: ignore[reportUninitializedInstanceVariable]
+ self.comic = self._comic(self.series, "c1.cbz") # pyright: ignore[reportUninitializedInstanceVariable]
+ self.other_comic = self._comic(self.other, "c2.cbz") # pyright: ignore[reportUninitializedInstanceVariable]
+ admin = User.objects.create_user(
+ username="series_admin", password=_TEST_PASSWORD, is_staff=True
+ )
+ self.client = Client()
+ self.client.force_login(admin)
+
+ @override
+ def tearDown(self) -> None:
+ """Remove the temp comic tree."""
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+
+ def _series(self, name: str, **attrs) -> Series:
+ return Series.objects.create(
+ name=name, imprint=self.imprint, publisher=self.publisher, **attrs
+ )
+
+ def _comic(self, series: Series, filename: str) -> Comic:
+ volume = Volume.objects.create(
+ name="1963", series=series, imprint=self.imprint, publisher=self.publisher
+ )
+ path = TMP_DIR / filename
+ path.touch()
+ return Comic.objects.create(
+ library=self.library,
+ path=path,
+ issue_number=1,
+ name=filename,
+ publisher=self.publisher,
+ imprint=self.imprint,
+ series=series,
+ volume=volume,
+ size=42,
+ )
+
+ def _post(self, collection: str, pks, **attrs):
+ data = {"collection": collection, "pks": [str(pk) for pk in pks], **attrs}
+ return self.client.post(_URL, data, content_type="application/json")
+
+ def test_sets_attrs_on_a_comics_series(self) -> None:
+ before = Series.objects.get(pk=self.series.pk).updated_at
+ response = self._post(
+ "comics",
+ [self.comic.pk],
+ endYear=_NEW_END_YEAR,
+ status="Completed",
+ summary=_SUMMARY,
+ )
+ assert response.status_code == _HTTP_OK, response.content
+ series = Series.objects.get(pk=self.series.pk)
+ assert series.end_year == _NEW_END_YEAR
+ assert series.status == "Completed"
+ assert series.summary == _SUMMARY
+ assert series.start_year == _START_YEAR
+ assert series.updated_at > before
+ assert Series.objects.get(pk=self.other.pk).status == ""
+
+ def test_series_collection_targets_its_own_row(self) -> None:
+ response = self._post("series", [self.series.pk], status="Hiatus")
+ assert response.status_code == _HTTP_OK, response.content
+ assert Series.objects.get(pk=self.series.pk).status == "Hiatus"
+
+ def test_only_sent_attrs_change(self) -> None:
+ Series.objects.filter(pk=self.series.pk).update(
+ status="Ongoing", summary=_SUMMARY
+ )
+ response = self._post("comics", [self.comic.pk], status="Completed")
+ assert response.status_code == _HTTP_OK, response.content
+ series = Series.objects.get(pk=self.series.pk)
+ assert series.status == "Completed"
+ assert series.summary == _SUMMARY
+ assert series.end_year == _END_YEAR
+
+ def test_empty_values_clear(self) -> None:
+ Series.objects.filter(pk=self.series.pk).update(
+ status="Ongoing", summary=_SUMMARY
+ )
+ response = self._post(
+ "comics", [self.comic.pk], endYear=None, status="", summary=""
+ )
+ assert response.status_code == _HTTP_OK, response.content
+ series = Series.objects.get(pk=self.series.pk)
+ assert series.end_year is None
+ assert series.status == ""
+ assert series.summary == ""
+
+ def test_two_series_are_refused(self) -> None:
+ response = self._post(
+ "comics", [self.comic.pk, self.other_comic.pk], status="Completed"
+ )
+ assert response.status_code == _HTTP_BAD_REQUEST
+ assert Series.objects.get(pk=self.series.pk).status == ""
+
+ def test_nothing_to_save_is_refused(self) -> None:
+ response = self._post("comics", [self.comic.pk])
+ assert response.status_code == _HTTP_BAD_REQUEST
+
+ def test_unknown_status_is_refused(self) -> None:
+ response = self._post("comics", [self.comic.pk], status="Finished")
+ assert response.status_code == _HTTP_BAD_REQUEST
+ assert Series.objects.get(pk=self.series.pk).status == ""
+
+ def test_out_of_range_year_is_refused(self) -> None:
+ response = self._post("comics", [self.comic.pk], endYear=99)
+ assert response.status_code == _HTTP_BAD_REQUEST
+
+ def test_non_admin_is_forbidden(self) -> None:
+ user = User.objects.create_user(username="reader", password=_TEST_PASSWORD)
+ self.client.force_login(user)
+ response = self._post("comics", [self.comic.pk], status="Completed")
+ assert response.status_code == _HTTP_FORBIDDEN
+ assert Series.objects.get(pk=self.series.pk).status == ""
+
+ def _series_summary_column(self, comic: Comic) -> str:
+ return ComicFTS.objects.get(comic=comic).series_summary
+
+ def _index_comics(self) -> None:
+ for comic in (self.comic, self.other_comic):
+ ComicFTS.objects.create(comic=comic, series_summary="old")
+
+ def test_summary_updates_the_search_index(self) -> None:
+ """The series' comics are searchable by the new summary at once."""
+ self._index_comics()
+ response = self._post("comics", [self.comic.pk], summary=_SUMMARY)
+ assert response.status_code == _HTTP_OK, response.content
+ assert self._series_summary_column(self.comic) == _SUMMARY
+ assert self._series_summary_column(self.other_comic) == "old"
+
+ def test_other_attrs_leave_the_search_index(self) -> None:
+ """Only a summary edit touches the index."""
+ self._index_comics()
+ response = self._post("comics", [self.comic.pk], status="Completed")
+ assert response.status_code == _HTTP_OK, response.content
+ assert self._series_summary_column(self.comic) == "old"
diff --git a/tests/test_browser_series_filters.py b/tests/test_browser_series_filters.py
new file mode 100644
index 000000000..552b3b3b5
--- /dev/null
+++ b/tests/test_browser_series_filters.py
@@ -0,0 +1,207 @@
+"""
+The series start year, end year and status browser filters.
+
+They filter across the ``Comic.series`` FK rather than a Comic column, and
+a blank status is ``""`` rather than NULL, so the "None" status choice has
+its own path through availability, choices and filtering.
+"""
+
+import json
+import shutil
+from typing import Final, override
+
+from django.contrib.auth.models import User
+from django.core.cache import cache
+from django.test import Client, TestCase
+
+from codex.choices.browser import DUMMY_NULL_NAME, VUETIFY_NULL_CODE
+from codex.models import Comic, Imprint, Library, Publisher, Series, Volume
+from codex.startup import init_admin_flags
+from tests.tmp_dirs import tmp_dir
+
+_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105
+_HTTP_OK: Final = 200
+_HTTP_BAD_REQUEST: Final = 400
+TMP_DIR = tmp_dir("codex.tests.browser_series_filters")
+_SETTINGS_URL: Final = "/api/v4/browse/publishers/settings"
+_STATUS: Final = "Completed"
+
+
+def _v4(response):
+ """Unwrap the v4 ``{data, meta, errors}`` envelope and return ``data``."""
+ body = response.json()
+ if isinstance(body, dict) and "data" in body and "meta" in body:
+ return body["data"]
+ return body
+
+
+class _SeriesFiltersFixtureTestCase(TestCase):
+ """Two series under one publisher: one with every attribute, one bare."""
+
+ @override
+ def setUp(self) -> None:
+ cache.clear()
+ init_admin_flags()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.library = Library.objects.create(path=str(TMP_DIR)) # pyright: ignore[reportUninitializedInstanceVariable]
+ self.publisher = Publisher.objects.create(name="Pub") # pyright: ignore[reportUninitializedInstanceVariable]
+ self.imprint = Imprint.objects.create(name="Imp", publisher=self.publisher) # pyright: ignore[reportUninitializedInstanceVariable]
+ self.series = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable]
+ name="Ser",
+ imprint=self.imprint,
+ publisher=self.publisher,
+ start_year=1963,
+ end_year=1996,
+ status=_STATUS,
+ summary="A friendly neighborhood series.",
+ )
+ self.other = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable]
+ name="Other",
+ imprint=self.imprint,
+ publisher=self.publisher,
+ start_year=2001,
+ )
+ self._create_comic("C1", self.series)
+ self._create_comic("C2", self.other)
+ user = User.objects.create_user(
+ username="series_filters_test", password=_TEST_PASSWORD
+ )
+ self.client = Client()
+ self.client.force_login(user)
+
+ @override
+ def tearDown(self) -> None:
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+
+ def _create_comic(self, name: str, series: Series) -> Comic:
+ volume, _ = Volume.objects.get_or_create(
+ name=2024, series=series, imprint=self.imprint, publisher=self.publisher
+ )
+ path = TMP_DIR / f"{name.lower()}.cbz"
+ path.touch()
+ return Comic.objects.create(
+ library=self.library,
+ path=path,
+ issue_number=1,
+ name=name,
+ publisher=self.publisher,
+ imprint=self.imprint,
+ series=series,
+ volume=volume,
+ size=42,
+ year=2024,
+ page_count=20,
+ )
+
+ def _browse(self, url: str) -> dict:
+ response = self.client.get(url)
+ assert response.status_code == _HTTP_OK, response.content
+ return _v4(response)
+
+ def _patch_filters(self, filters: dict):
+ response = self.client.patch(
+ _SETTINGS_URL,
+ data=json.dumps({"filters": filters}),
+ content_type="application/json",
+ )
+ cache.clear()
+ return response
+
+ def _filter(self, filters: dict) -> None:
+ response = self._patch_filters(filters)
+ assert response.status_code == _HTTP_OK, response.content
+
+ def _availability(self) -> dict:
+ cache.clear()
+ return self._browse(f"/api/v4/browse/publishers/{self.publisher.pk}/choices")
+
+ def _choices(self, field_name: str) -> list:
+ url = f"/api/v4/browse/publishers/{self.publisher.pk}/choices/{field_name}"
+ return self._browse(url)["choices"]
+
+ def _series_row_names(self) -> list[str]:
+ body = self._browse(f"/api/v4/browse/publishers/{self.publisher.pk}?page=1")
+ return sorted(card["name"] for card in body["collections"])
+
+
+class BrowserSeriesFilterChoicesTestCase(_SeriesFiltersFixtureTestCase):
+ """Which filter sub-menus show, and what they offer."""
+
+ def test_available_with_values(self) -> None:
+ """Years need one value; status a value plus a blank."""
+ body = self._availability()
+ assert body["seriesStartYear"] is True, body
+ assert body["seriesEndYear"] is True, body
+ assert body["seriesStatus"] is True, body
+
+ def test_unavailable_without_values(self) -> None:
+ """Blank series offer none of the three."""
+ Series.objects.update(start_year=None, end_year=None, status="")
+ body = self._availability()
+ assert body["seriesStartYear"] is False, body
+ assert body["seriesEndYear"] is False, body
+ assert body["seriesStatus"] is False, body
+
+ def test_status_with_one_value_and_no_blank(self) -> None:
+ """One status and no blank leaves nothing to choose between."""
+ Series.objects.update(status=_STATUS)
+ body = self._availability()
+ assert body["seriesStatus"] is False, body
+
+ def test_year_choices(self) -> None:
+ """Year choices are bare values with no null row."""
+ assert sorted(self._choices("seriesStartYear")) == [1963, 2001]
+ assert self._choices("seriesEndYear") == [1996]
+
+ def test_status_choices(self) -> None:
+ """The blank status becomes the "None" row with the int null code."""
+ choices = self._choices("seriesStatus")
+ assert choices == [
+ {"pk": VUETIFY_NULL_CODE, "name": DUMMY_NULL_NAME},
+ {"pk": _STATUS, "name": _STATUS},
+ ], choices
+
+
+class BrowserSeriesFilterTestCase(_SeriesFiltersFixtureTestCase):
+ """The filters narrow the browse."""
+
+ def test_status_filter(self) -> None:
+ """A status selects its series."""
+ self._filter({"seriesStatus": [_STATUS]})
+ assert self._series_row_names() == ["Ser"]
+
+ def test_status_none_filter(self) -> None:
+ """The "None" status selects series with a blank status."""
+ self._filter({"seriesStatus": [VUETIFY_NULL_CODE]})
+ assert self._series_row_names() == ["Other"]
+
+ def test_start_year_filter(self) -> None:
+ """Year filters match any selected year."""
+ self._filter({"seriesStartYear": [2001]})
+ assert self._series_row_names() == ["Other"]
+ self._filter({"seriesStartYear": [1963, 2001]})
+ assert self._series_row_names() == ["Other", "Ser"]
+
+ def test_end_year_filter(self) -> None:
+ """The end year filter skips series without one."""
+ self._filter({"seriesEndYear": [1996]})
+ assert self._series_row_names() == ["Ser"]
+
+ def test_filter_on_series_browse(self) -> None:
+ """Inside a series the filter applies to its comics."""
+ url = f"/api/v4/browse/series/{self.series.pk}?page=1"
+ self._filter({"seriesStartYear": [1963]})
+ assert [book["name"] for book in self._browse(url)["books"]] == ["C1"]
+ self._filter({"seriesStartYear": [2001]})
+ assert self._browse(url)["books"] == []
+
+ def test_invalid_status_refused(self) -> None:
+ """Only real statuses and the null code validate."""
+ response = self._patch_filters({"seriesStatus": ["Finished"]})
+ assert response.status_code == _HTTP_BAD_REQUEST, response.content
+
+ def test_status_settings_round_trip(self) -> None:
+ """The null code and a status survive saving and reloading."""
+ self._filter({"seriesStatus": [VUETIFY_NULL_CODE, _STATUS]})
+ body = self._browse(_SETTINGS_URL)
+ assert body["filters"]["seriesStatus"] == [VUETIFY_NULL_CODE, _STATUS], body
diff --git a/tests/test_lazy_import_marker.py b/tests/test_lazy_import_marker.py
index 25cf7b68f..51b29a1b3 100644
--- a/tests/test_lazy_import_marker.py
+++ b/tests/test_lazy_import_marker.py
@@ -4,35 +4,51 @@
``Comic.metadata_mtime`` is comicbox's *embedded* metadata mtime, which stays
NULL forever for an archive with no metadata file. The tag-icon hover gate and
the lazy-import worker used to key off ``metadata_mtime``, so a no-metadata comic
-re-fired the importer endlessly. ``metadata_imported_at`` records that a forced
-import pass actually ran, independent of embedded metadata:
+re-fired the importer endlessly. ``metadata_imported_at`` records that a
+tag-reading import pass actually ran, independent of embedded metadata:
-* ``FinishImporter.finish`` stamps every comic a *forced* import touched (by
+* ``FinishImporter.finish`` stamps every comic a tag-reading import touched (by
path, so it covers the SKIPPED no-metadata comics that never reach the
- per-comic write path); a non-forced watcher import leaves the column alone.
+ per-comic write path). That is any forced import, and any library scan with
+ the import-tags flag on; a scan with the flag off reads no tags and leaves the
+ column NULL for the lazy importer.
* ``LazyImporter.lazy_import`` gates on ``metadata_imported_at__isnull=True``, so
a comic is imported once and then excluded from further hover-triggered passes.
+* Migration 0058 stamps the rows scans left NULL before scans stamped them.
"""
+import importlib
import shutil
+import zipfile
from pathlib import Path
from threading import Event, Lock
from typing import Final, override
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
+from django.apps import apps
from django.core.cache import cache
from django.test import TestCase
from loguru import logger
+from codex.choices.admin import AdminFlagChoices
from codex.librarian.scribe.importer.importer import ComicImporter
from codex.librarian.scribe.importer.tasks import ImportTask
from codex.librarian.scribe.lazy_importer import LazyImporter
from codex.librarian.scribe.tasks import LazyImportComicsTask
from codex.models import Comic, Imprint, Library, Publisher, Series, Volume
+from codex.models.admin import AdminFlag
from tests.tmp_dirs import tmp_dir
+_MIGRATION = importlib.import_module("codex.migrations.0058_post_v2_5_5_schema")
+_backfill = _MIGRATION._backfill_metadata_imported_at # noqa: SLF001
+
_LIB_PATH: Final = tmp_dir(Path(__file__).stem)
_COMIC_PATH: Final = str(_LIB_PATH / "bare.cbz")
+_SCAN_LIB_PATH: Final = tmp_dir(f"{Path(__file__).stem}-scan")
+
+
+def _set_import_metadata_flag(*, on: bool) -> None:
+ AdminFlag.objects.filter(key=AdminFlagChoices.IMPORT_METADATA.value).update(on=on)
class LazyImportMarkerTestCase(TestCase):
@@ -98,12 +114,72 @@ def test_forced_finish_stamps_marker(self) -> None:
self.comic.refresh_from_db()
assert self.comic.metadata_imported_at is not None
- def test_non_forced_finish_leaves_marker_null(self) -> None:
- """An ordinary watcher import must not touch the marker."""
+ def test_forced_finish_stamps_marker_with_import_tags_off(self) -> None:
+ """A forced pass reads tags whatever the scan flag says."""
+ _set_import_metadata_flag(on=False)
+ self._make_importer(force=True).finish()
+ self.comic.refresh_from_db()
+ assert self.comic.metadata_imported_at is not None
+
+ def test_scan_finish_stamps_marker_with_import_tags_on(self) -> None:
+ """A scan that read tags stamps the comic, so hovers never re-import it."""
+ self._make_importer(force=False).finish()
+ self.comic.refresh_from_db()
+ assert self.comic.metadata_imported_at is not None
+
+ def test_stamp_covers_every_batch(self) -> None:
+ """A path set larger than one batch still stamps every comic."""
+ other_path = str(_LIB_PATH / "other.cbz")
+ Path(other_path).write_bytes(b"")
+ other = Comic.objects.create(
+ library=self.library,
+ path=other_path,
+ issue_number=2,
+ name="other",
+ publisher=self.comic.publisher,
+ imprint=self.comic.imprint,
+ series=self.comic.series,
+ volume=self.comic.volume,
+ size=1,
+ page_count=1,
+ )
+ importer = self._make_importer(force=False)
+ importer.metadata_import_paths = frozenset({_COMIC_PATH, other_path})
+ with patch(
+ "codex.librarian.scribe.importer.finish.IMPORTER_LINK_FK_BATCH_SIZE", 1
+ ):
+ importer.finish()
+ for comic in (self.comic, other):
+ comic.refresh_from_db()
+ assert comic.metadata_imported_at is not None
+
+ def test_scan_finish_leaves_marker_null_with_import_tags_off(self) -> None:
+ """A scan that read no tags leaves the comic for the lazy importer."""
+ _set_import_metadata_flag(on=False)
self._make_importer(force=False).finish()
self.comic.refresh_from_db()
assert self.comic.metadata_imported_at is None
+ def test_backfill_stamps_with_import_tags_on(self) -> None:
+ """0058 stamps the rows a tag-reading scan left NULL."""
+ _backfill(apps, None)
+ self.comic.refresh_from_db()
+ assert self.comic.metadata_imported_at == self.comic.updated_at
+
+ def test_backfill_stamps_with_no_flag_row(self) -> None:
+ """A missing flag row counts as its default, on."""
+ AdminFlag.objects.filter(key=AdminFlagChoices.IMPORT_METADATA.value).delete()
+ _backfill(apps, None)
+ self.comic.refresh_from_db()
+ assert self.comic.metadata_imported_at is not None
+
+ def test_backfill_skips_with_import_tags_off(self) -> None:
+ """0058 leaves the rows of a scan that read no tags to the lazy importer."""
+ _set_import_metadata_flag(on=False)
+ _backfill(apps, None)
+ self.comic.refresh_from_db()
+ assert self.comic.metadata_imported_at is None
+
def test_stamp_is_idempotent(self) -> None:
"""A second forced pass does not move an already-set marker."""
self._make_importer(force=True).finish()
@@ -136,3 +212,38 @@ def test_lazy_import_skips_already_marked(self) -> None:
LazyImportComicsTask(collection="comics", pks=frozenset({self.comic.pk}))
)
assert not self._enqueued_import_tasks(queue)
+
+
+class ScanStampsTaglessComicTestCase(TestCase):
+ """Issue 992: a scanned comic with no tags file must not read as un-imported."""
+
+ @override
+ def setUp(self) -> None:
+ """Seed admin flags, a library and a CBZ with pages but no tags."""
+ from codex.startup import init_admin_flags
+
+ cache.clear()
+ init_admin_flags()
+ _SCAN_LIB_PATH.mkdir(exist_ok=True, parents=True)
+ self.path = str(_SCAN_LIB_PATH / "tagless.cbz") # pyright: ignore[reportUninitializedInstanceVariable]
+ with zipfile.ZipFile(self.path, "w") as zf:
+ zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg")
+ self.library = Library.objects.create(path=str(_SCAN_LIB_PATH)) # pyright: ignore[reportUninitializedInstanceVariable]
+
+ @override
+ def tearDown(self) -> None:
+ shutil.rmtree(_SCAN_LIB_PATH, ignore_errors=True)
+ cache.clear()
+
+ def test_scan_stamps_tagless_comic(self) -> None:
+ """An ordinary scan creates the comic already stamped."""
+ task = ImportTask(
+ library_id=self.library.pk, files_created=frozenset({self.path})
+ )
+ importer = ComicImporter(task, logger, MagicMock(), Lock(), Event())
+ assert importer._run_per_comic_phases_chunked() # noqa: SLF001
+ importer.finish()
+
+ comic = Comic.objects.get(path=self.path)
+ assert comic.metadata_mtime is None
+ assert comic.metadata_imported_at is not None
diff --git a/tests/test_metadata_series_fields.py b/tests/test_metadata_series_fields.py
new file mode 100644
index 000000000..054615103
--- /dev/null
+++ b/tests/test_metadata_series_fields.py
@@ -0,0 +1,137 @@
+"""The metadata pane and browser title carry the series' attributes."""
+
+import shutil
+from typing import Final, override
+
+from django.contrib.auth.models import User
+from django.test import Client, TestCase
+
+from codex.models import Comic, Imprint, Library, Publisher, Series, Volume
+from codex.startup import init_admin_flags
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR = tmp_dir("codex.tests.metadata_series_fields")
+_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105
+_HTTP_OK: Final = 200
+_START_YEAR: Final = 1963
+_END_YEAR: Final = 1996
+_STATUS: Final = "Completed"
+_SUMMARY: Final = "A friendly neighborhood series."
+_COMIC_SUMMARY: Final = "One issue of it."
+_SHOW_ALL_SETTINGS: Final = (
+ '{"topCollection": "publishers", "show": {"publishers": true,'
+ ' "imprints": true, "series": true, "volumes": true}}'
+)
+
+
+def _v4(response):
+ """Unwrap the v4 ``{data, meta, errors}`` envelope and return ``data``."""
+ body = response.json()
+ if isinstance(body, dict) and "data" in body and "meta" in body:
+ return body["data"]
+ return body
+
+
+class MetadataSeriesFieldsTestCase(TestCase):
+ """The series attributes reach the metadata payload."""
+
+ @override
+ def setUp(self) -> None:
+ """Seed one comic in a series with every attribute set."""
+ init_admin_flags()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ library = Library.objects.create(path=str(TMP_DIR))
+ publisher = Publisher.objects.create(name="Pub")
+ imprint = Imprint.objects.create(name="Imp", publisher=publisher)
+ self.series = Series.objects.create( # pyright: ignore[reportUninitializedInstanceVariable]
+ name="Ser",
+ imprint=imprint,
+ publisher=publisher,
+ start_year=_START_YEAR,
+ end_year=_END_YEAR,
+ status=_STATUS,
+ summary=_SUMMARY,
+ )
+ self.volume = Volume.objects.create( # pyright: ignore[reportUninitializedInstanceVariable]
+ name="1963", series=self.series, imprint=imprint, publisher=publisher
+ )
+ path = TMP_DIR / "c1.cbz"
+ path.touch()
+ self.comic = Comic.objects.create( # pyright: ignore[reportUninitializedInstanceVariable]
+ library=library,
+ path=path,
+ issue_number=1,
+ name="C1",
+ publisher=publisher,
+ imprint=imprint,
+ series=self.series,
+ volume=self.volume,
+ size=42,
+ summary=_COMIC_SUMMARY,
+ )
+ user = User.objects.create_user(
+ username="series_reader", password=_TEST_PASSWORD, is_staff=True
+ )
+ self.client = Client()
+ self.client.force_login(user)
+
+ @override
+ def tearDown(self) -> None:
+ """Remove the temp comic tree."""
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+
+ def _get_metadata(self, collection: str, pk: int) -> dict:
+ response = self.client.get(f"/api/v4/browse/{collection}/{pk}/metadata")
+ assert response.status_code == _HTTP_OK, response.content
+ return _v4(response)
+
+ def test_comic_metadata_has_series_attrs(self) -> None:
+ data = self._get_metadata("comics", self.comic.pk)
+ assert data["seriesStartYear"] == _START_YEAR
+ assert data["seriesEndYear"] == _END_YEAR
+ assert data["seriesStatus"] == _STATUS
+ assert data["seriesSummary"] == _SUMMARY
+
+ def test_series_metadata_has_series_attrs(self) -> None:
+ """
+ A collection's pane reads the attributes through its comics.
+
+ The comics' own summary keeps its key there too, though Series now
+ has a summary column of its own.
+ """
+ data = self._get_metadata("series", self.series.pk)
+ assert data["seriesStartYear"] == _START_YEAR
+ assert data["seriesEndYear"] == _END_YEAR
+ assert data["seriesStatus"] == _STATUS
+ assert data["seriesSummary"] == _SUMMARY
+ assert data["summary"] == _COMIC_SUMMARY
+
+ def test_unknown_years_are_omitted(self) -> None:
+ Series.objects.filter(pk=self.series.pk).update(start_year=None, end_year=None)
+ data = self._get_metadata("comics", self.comic.pk)
+ assert "seriesStartYear" not in data
+ assert "seriesEndYear" not in data
+
+ def _get_title(self, collection: str, pk: int) -> dict:
+ """Browse one collection with every level shown and return its title."""
+ response = self.client.patch(
+ "/api/v4/browse/publishers/settings",
+ data=_SHOW_ALL_SETTINGS,
+ content_type="application/json",
+ )
+ assert response.status_code == _HTTP_OK, response.content
+ response = self.client.get(f"/api/v4/browse/{collection}/{pk}?page=1")
+ assert response.status_code == _HTTP_OK, response.content
+ return _v4(response)["title"]
+
+ def test_series_title_has_years(self) -> None:
+ title = self._get_title("series", self.series.pk)
+ assert title["collectionName"] == "Ser"
+ assert title["collectionStartYear"] == _START_YEAR
+ assert title["collectionEndYear"] == _END_YEAR
+
+ def test_volume_title_has_no_years(self) -> None:
+ title = self._get_title("volumes", self.volume.pk)
+ assert title["collectionName"] == "1963"
+ assert title["collectionStartYear"] is None
+ assert title["collectionEndYear"] is None
diff --git a/tests/test_onlinetag_serialize_candidate.py b/tests/test_onlinetag_serialize_candidate.py
index 5759964c4..e3c017b84 100644
--- a/tests/test_onlinetag_serialize_candidate.py
+++ b/tests/test_onlinetag_serialize_candidate.py
@@ -11,6 +11,7 @@
the serializer against real comicbox objects.
"""
+from types import MappingProxyType, SimpleNamespace
from typing import Final
from comicbox.formats.base.online.profile import Candidate, CandidateSummary
@@ -25,6 +26,16 @@
_METADATA_SCORE: Final = 1.0
_COVER_SCORE: Final = 0.6
_VOLUME: Final = 2
+_DETAIL: Final = MappingProxyType(
+ {
+ "series_start_year": 2016,
+ "series_publisher": "DC Comics",
+ "series_issue_count": 85,
+ "series_type": "Ongoing Series",
+ "issue_title": "I Am Gotham, Part One",
+ "issue_summary": "A new hero arrives.",
+ }
+)
def _summary(
@@ -169,6 +180,25 @@ class _Bare:
assert data["cover_score"] is None
assert data["cover_hash_attempted"] is False
assert data["volume_id"] is None
+ for key in _DETAIL:
+ assert data["summary"][key] is None, key
+
+ def test_what_tells_identical_titles_apart_is_carried(self) -> None:
+ """
+ Near-identical covers and titles differ in these.
+
+ The summary is a stand-in because the pinned comicbox predates
+ the fields; the serializer reads them by name either way.
+ """
+ summary = SimpleNamespace(series="Batman", issue="1", year=2016, **_DETAIL)
+ candidate = SimpleNamespace(
+ source="comicvine", issue_id=1, score=0.5, summary=summary
+ )
+
+ data = serialize_candidate(candidate)
+
+ for key, value in _DETAIL.items():
+ assert data["summary"][key] == value, key
def test_display_fields_do_not_bump_the_prompt_version(self) -> None:
"""
diff --git a/tests/test_onlinetag_series_attrs.py b/tests/test_onlinetag_series_attrs.py
new file mode 100644
index 000000000..222c6dd96
--- /dev/null
+++ b/tests/test_onlinetag_series_attrs.py
@@ -0,0 +1,231 @@
+"""
+Online tagging keeps the series attributes no written format can hold.
+
+A Metron match brings the series' end year, status and summary, and
+ComicInfo has no start year either. The tag write can't put them in the
+file, so they ride on the re-read and the importer applies them to the
+series the comic lands on. Here the match also names a new publisher and
+series, which moves the comic off its filename-derived series row: the
+values have to reach the new row, not the one the comic left.
+"""
+
+import shutil
+import threading
+import zipfile
+from pathlib import Path
+from threading import Event, Lock
+from types import SimpleNamespace
+from typing import Any, Final, override
+from unittest.mock import MagicMock
+
+from comicbox.enums.comicbox import SeriesStatusEnum
+from django.core.cache import cache
+from django.test import SimpleTestCase, TestCase
+from loguru import logger
+
+from codex.librarian.mp_queue import LIBRARIAN_QUEUE
+from codex.librarian.onlinetag.tag_pass_runner import TagPassRunner
+from codex.librarian.scribe.importer.importer import ComicImporter
+from codex.librarian.scribe.importer.tasks import ImportTask
+from codex.librarian.scribe.tag_writer import TagWriter
+from codex.librarian.scribe.tagwrite_series_attrs import patch_series_attrs
+from codex.librarian.scribe.tasks import BulkTagWriteTask
+from codex.models import Comic, Library, Series
+from codex.startup import codex_init
+from tests.importer.test_basic import run_full_import
+from tests.onlinetag_session_fakes import FakeQueue, double
+from tests.tmp_dirs import tmp_dir
+
+_TMP_DIR: Final = tmp_dir("codex.tests.onlinetag.series_attrs")
+_FILENAME_SERIES: Final = "Spidey"
+_SERIES: Final = "The Amazing Spider-Man"
+_START_YEAR: Final = 1963
+_END_YEAR: Final = 1998
+_STATUS: Final = "Completed"
+_SUMMARY: Final = "The first Spider-Man series."
+#: The tags a Metron match hands back, trimmed to what this test reads.
+_MATCH_TAGS: Final = {
+ "publisher": {"name": "Marvel"},
+ "series": {
+ "name": _SERIES,
+ "start_year": _START_YEAR,
+ "end_year": _END_YEAR,
+ "status": _STATUS,
+ "summary": _SUMMARY,
+ },
+ "issue": {"number": "1"},
+}
+
+
+class _NoopStatusController:
+ def start(self, _status, **_kwargs) -> None:
+ pass
+
+ def update(self, _status, **_kwargs) -> None:
+ pass
+
+ def finish(self, _status, **_kwargs) -> None:
+ pass
+
+
+class _MatchedSession:
+ """Matches every comic to ``_MATCH_TAGS``."""
+
+ def tag_many(self, paths):
+ for path in paths:
+ yield SimpleNamespace(
+ path=path, matched=True, tags=_MATCH_TAGS, error=None, cancelled=False
+ )
+
+
+def _untagged_cbz(path: Path) -> None:
+ """Build an archive with no metadata, so its series comes from its name."""
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg")
+
+
+def _make_writer(queue: FakeQueue) -> TagWriter:
+ """Build a TagWriter without the threading machinery."""
+ writer = TagWriter.__new__(TagWriter)
+ writer.log = double(logger)
+ writer.librarian_queue = double(queue)
+ writer.status_controller = double(MagicMock())
+ writer.abort_event = threading.Event()
+ return writer
+
+
+def _queued(queue: FakeQueue, task_type: type) -> list[Any]:
+ return [item for item in queue.items if isinstance(item, task_type)]
+
+
+class PatchSeriesAttrsTestCase(SimpleTestCase):
+ """Only non-empty attributes leave the patch, status as its choice value."""
+
+ def test_picks_the_attrs(self) -> None:
+ assert patch_series_attrs(_MATCH_TAGS) == {
+ "start_year": _START_YEAR,
+ "end_year": _END_YEAR,
+ "status": _STATUS,
+ "summary": _SUMMARY,
+ }
+
+ def test_status_enum_member_stores_its_value(self) -> None:
+ patch = {"series": {"status": SeriesStatusEnum.HIATUS}}
+ assert patch_series_attrs(patch) == {"status": "Hiatus"}
+
+ def test_drops_empty_and_unknown_values(self) -> None:
+ patch = {"series": {"end_year": None, "status": "Paused", "summary": ""}}
+ assert patch_series_attrs(patch) == {}
+
+ def test_no_series(self) -> None:
+ assert patch_series_attrs({"issue": {"number": "1"}}) == {}
+ assert patch_series_attrs(None) == {}
+
+
+class OnlineTagSeriesAttrsTestCase(TestCase):
+ """An online match's series attributes survive its write and re-read."""
+
+ @override
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cache.clear()
+ codex_init()
+
+ @override
+ def setUp(self) -> None:
+ cache.clear()
+ _TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.path = _TMP_DIR / f"{_FILENAME_SERIES} 001.cbz" # pyright: ignore[reportUninitializedInstanceVariable]
+ _untagged_cbz(self.path)
+ self.library = Library.objects.create(path=str(_TMP_DIR), events=False) # pyright: ignore[reportUninitializedInstanceVariable]
+ self._import(
+ ImportTask(self.library.pk, files_created=frozenset({str(self.path)}))
+ )
+ self.comic = Comic.objects.select_related("series").get(path=str(self.path)) # pyright: ignore[reportUninitializedInstanceVariable]
+
+ @override
+ def tearDown(self) -> None:
+ shutil.rmtree(_TMP_DIR, ignore_errors=True)
+
+ @staticmethod
+ def _import(task: ImportTask) -> None:
+ run_full_import(ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()))
+
+ def _online_tag(self) -> BulkTagWriteTask:
+ """Run a pass over the comic and return the write it flushed."""
+ queue = FakeQueue()
+ runner = TagPassRunner(
+ double(logger),
+ double(queue),
+ double(_NoopStatusController()),
+ lambda _state: None,
+ lambda _state: None,
+ )
+ state = double(
+ SimpleNamespace(
+ session=_MatchedSession(),
+ cancelled=False,
+ pending_paths=[],
+ total_comics=0,
+ completed_comics=0,
+ path_to_pk={self.path: self.comic.pk},
+ collected_tags={},
+ effort="balanced",
+ sources=("metron",),
+ merge_all_sources=False,
+ mode="update",
+ formats=("COMIC_INFO",),
+ delete_original=False,
+ rename=False,
+ )
+ )
+ runner.collect_results(state, [self.path], flush_writes=True)
+ (write_task,) = _queued(queue, BulkTagWriteTask)
+ return write_task
+
+ def _write_and_reread(self, write_task: BulkTagWriteTask) -> ImportTask:
+ """Write the tags and run the re-read the writer queued."""
+ queue = FakeQueue()
+ _make_writer(queue).write_tags(write_task)
+ (import_task,) = _queued(queue, ImportTask)
+ self._import(import_task)
+ return import_task
+
+ def test_series_attrs_land_on_the_matched_series(self) -> None:
+ assert self.comic.series.name == _FILENAME_SERIES
+ old_series_pk = self.comic.series.pk
+
+ import_task = self._write_and_reread(self._online_tag())
+
+ assert import_task.series_attrs == {
+ str(self.path): patch_series_attrs(_MATCH_TAGS)
+ }
+ self.comic.refresh_from_db()
+ series = self.comic.series
+ assert series.pk != old_series_pk
+ assert series.name == _SERIES
+ assert series.publisher.name == "Marvel"
+ # ComicInfo holds none of them, the start year included.
+ assert series.start_year == _START_YEAR
+ assert series.end_year == _END_YEAR
+ assert series.status == _STATUS
+ assert series.summary == _SUMMARY
+
+ def test_a_file_only_reimport_keeps_them(self) -> None:
+ self._write_and_reread(self._online_tag())
+
+ # The watcher's re-read of the same file carries no attributes.
+ self._import(
+ ImportTask(
+ self.library.pk,
+ files_modified=frozenset({str(self.path)}),
+ force_import_metadata=True,
+ check_metadata_mtime=False,
+ )
+ )
+
+ series = Series.objects.get(name=_SERIES)
+ assert series.end_year == _END_YEAR
+ assert series.status == _STATUS
+ assert series.summary == _SUMMARY
diff --git a/tests/test_search_series_fields.py b/tests/test_search_series_fields.py
new file mode 100644
index 000000000..d81c9ff69
--- /dev/null
+++ b/tests/test_search_series_fields.py
@@ -0,0 +1,230 @@
+"""
+Series start year, end year, status and summary are searchable.
+
+The years and status are ORM columns reached across the series FK, like
+``year:``. The summary is indexed into its own ``ComicFTS.series_summary``
+column so bare search terms find it.
+
+Three code paths write that column: the importer builds FTS entries inline
+for the comics it links, its foreign-key re-sync rewrites every comic of a
+series whose summary changed (the inline path skips comics whose series
+link didn't change), and :class:`SearchIndexerSync` rebuilds it from the
+database. Each is asserted here.
+"""
+
+import shutil
+import zipfile
+from datetime import UTC, datetime
+from http import HTTPStatus
+from pathlib import Path
+from threading import Event, Lock
+from typing import Final, override
+from urllib.parse import urlencode
+
+from django.contrib.auth.models import User
+from django.core.cache import cache
+from django.test import Client, TestCase
+from loguru import logger
+
+from codex.librarian.mp_queue import LIBRARIAN_QUEUE
+from codex.librarian.scribe.importer.importer import ComicImporter
+from codex.librarian.scribe.importer.tasks import ImportTask
+from codex.librarian.scribe.search.sync import SearchIndexerSync
+from codex.models import Comic, Publisher, Series
+from codex.models.comic import ComicFTS
+from codex.models.library import Library
+from codex.startup import codex_init
+from tests.importer.test_basic import run_full_import
+from tests.tmp_dirs import tmp_dir
+
+TMP_DIR: Final = tmp_dir(Path(__file__).stem)
+_TEST_PASSWORD: Final = "test-pw-hush-S106" # noqa: S105
+_ALPHA_SUMMARY: Final = "A rodeo of heroes."
+# Comfortably past any index watermark the run just wrote.
+_FUTURE: Final = datetime(2100, 1, 1, tzinfo=UTC)
+
+
+def _yaml(series: str, extra: str = "") -> str:
+ return f"""comicbox:
+ publisher:
+ name: P
+ series:
+ name: {series}
+{extra}"""
+
+
+_ALPHA_YAML: Final = _yaml(
+ "Alpha",
+ f""" start_year: 1963
+ end_year: 1996
+ status: completed
+ summary: {_ALPHA_SUMMARY}
+""",
+)
+_BETA_YAML: Final = _yaml("Beta", " start_year: 2001\n")
+
+
+class _SeriesSearchTestCase(TestCase):
+ """Import comics from archives carrying series attributes."""
+
+ @override
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+ cache.clear()
+ codex_init()
+
+ @override
+ @classmethod
+ def tearDownClass(cls):
+ try:
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ finally:
+ super().tearDownClass()
+
+ @override
+ def setUp(self):
+ cache.clear()
+ TMP_DIR.mkdir(exist_ok=True, parents=True)
+ self.library = Library.objects.create(path=TMP_DIR) # pyright: ignore[reportUninitializedInstanceVariable]
+ user = User.objects.create_user(
+ username="series_search_test", password=_TEST_PASSWORD
+ )
+ self.client = Client()
+ self.client.force_login(user)
+
+ @override
+ def tearDown(self):
+ shutil.rmtree(TMP_DIR, ignore_errors=True)
+ cache.clear()
+
+ @staticmethod
+ def _write(name: str, yaml: str) -> str:
+ path = TMP_DIR / name
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("comicbox.yaml", yaml)
+ zf.writestr("page1.jpg", b"\xff\xd8\xff\xe0not-a-real-jpeg")
+ return str(path)
+
+ def _import(self, *paths: str) -> None:
+ # A same-size rewrite within the same second looks unchanged to the
+ # mtime prefilter, so always re-read.
+ task = ImportTask(
+ library_id=self.library.pk,
+ files_modified=frozenset(paths),
+ check_metadata_mtime=False,
+ )
+ run_full_import(ComicImporter(task, logger, LIBRARIAN_QUEUE, Lock(), Event()))
+
+ @staticmethod
+ def _series_summary_column(path: str) -> str:
+ return ComicFTS.objects.get(comic__path=path).series_summary
+
+ def _browse(self, url: str, query: str) -> dict:
+ params = urlencode({"page": 1, "q": query})
+ response = self.client.get(f"{url}?{params}")
+ assert response.status_code == HTTPStatus.OK, response.content
+ return response.json()["data"]
+
+
+class SeriesFieldsSearchTestCase(_SeriesSearchTestCase):
+ """Two series, one with every attribute set and one with only a start."""
+
+ @override
+ def setUp(self):
+ super().setUp()
+ self.alpha_path = self._write("alpha.cbz", _ALPHA_YAML) # pyright: ignore[reportUninitializedInstanceVariable]
+ self.beta_path = self._write("beta.cbz", _BETA_YAML) # pyright: ignore[reportUninitializedInstanceVariable]
+ self._import(self.alpha_path, self.beta_path)
+ self.publisher = Publisher.objects.get(name="P") # pyright: ignore[reportUninitializedInstanceVariable]
+
+ def _series_names(self, query: str) -> list[str]:
+ """Search the publisher's series cards: the ``series__`` strip."""
+ body = self._browse(f"/api/v4/browse/publishers/{self.publisher.pk}", query)
+ return sorted(card["name"] for card in body["collections"])
+
+ def _publisher_names(self, query: str) -> list[str]:
+ """Search the publisher cards: the ``comic__`` prefix."""
+ body = self._browse("/api/v4/browse/publishers", query)
+ return [card["name"] for card in body["collections"]]
+
+ def _book_pks(self, path: str, query: str) -> list[int]:
+ """Search the comic's series' comics: no prefix."""
+ comic = Comic.objects.get(path=path)
+ body = self._browse(f"/api/v4/browse/series/{comic.series_id}", query) # pyright: ignore[reportAttributeAccessIssue], # ty: ignore[unresolved-attribute]
+ return [book["pk"] for book in body["books"]]
+
+ def test_import_indexes_series_summary(self) -> None:
+ """The inline path writes each comic's series summary, blank or not."""
+ assert self._series_summary_column(self.alpha_path) == _ALPHA_SUMMARY
+ assert self._series_summary_column(self.beta_path) == ""
+
+ def test_bare_term_finds_series_summary(self) -> None:
+ """A bare token matches the series summary column."""
+ assert self._series_names("rodeo") == ["Alpha"]
+ assert self._series_names("Zzyzx") == []
+
+ def test_field_aliases_find_series_summary(self) -> None:
+ """Every alias resolves to the series summary, not the comic's."""
+ for alias in ("series_summary", "series_desc", "series_description"):
+ assert self._series_names(f"{alias}:rodeo") == ["Alpha"], alias
+ assert self._series_names("summary:rodeo") == []
+
+ def test_series_columns(self) -> None:
+ """Years and status search like ``year:`` across the series FK."""
+ hits = (
+ "start_year:1963",
+ "start_year:1960..1970",
+ "end_year:<2000",
+ "status:completed",
+ "status:comp*",
+ )
+ for query in hits:
+ assert self._series_names(query) == ["Alpha"], query
+ assert self._series_names("start_year:>1990") == ["Beta"]
+ assert self._series_names("status:ongoing") == []
+
+ def test_series_columns_on_publishers(self) -> None:
+ """Collection browses above the series reach it through its comics."""
+ assert self._publisher_names("start_year:1963") == ["P"]
+ assert self._publisher_names("status:completed") == ["P"]
+ assert self._publisher_names("start_year:1900") == []
+
+ def test_series_columns_on_comics(self) -> None:
+ """A series' comics match on their own series' attributes."""
+ alpha_pk = Comic.objects.get(path=self.alpha_path).pk
+ beta_pk = Comic.objects.get(path=self.beta_path).pk
+ assert self._book_pks(self.alpha_path, "status:completed") == [alpha_pk]
+ assert self._book_pks(self.beta_path, "status:completed") == []
+ assert self._book_pks(self.beta_path, "start_year:2001") == [beta_pk]
+
+ def test_search_index_sync_populates_series_summary(self) -> None:
+ """A full rebuild fills the column; an incremental sync updates it."""
+ indexer = SearchIndexerSync(logger, LIBRARIAN_QUEUE, Lock(), Event())
+ indexer.update_search_index(rebuild=True)
+ assert self._series_summary_column(self.alpha_path) == _ALPHA_SUMMARY
+ assert self._series_names("rodeo") == ["Alpha"]
+
+ Series.objects.filter(name="Alpha").update(summary="A lasso.")
+ # ``updated_at`` is auto_now, so ``.update()`` is the only way to
+ # push the comic past the index watermark without a save().
+ Comic.objects.filter(path=self.alpha_path).update(updated_at=_FUTURE)
+ indexer.update_search_index(rebuild=False)
+ assert self._series_summary_column(self.alpha_path) == "A lasso."
+
+
+class SeriesSummaryResyncTestCase(_SeriesSearchTestCase):
+ """A summary change re-indexes comics the import didn't touch."""
+
+ def test_summary_change_reindexes_series_comics(self) -> None:
+ """Re-importing one comic updates its sibling's index row too."""
+ a_path = self._write("a.cbz", _yaml("Shared", " summary: one\n"))
+ b_path = self._write("b.cbz", _yaml("Shared"))
+ self._import(a_path, b_path)
+ assert self._series_summary_column(b_path) == "one"
+
+ self._write("a.cbz", _yaml("Shared", " summary: two\n"))
+ self._import(a_path)
+ assert Series.objects.get(name="Shared").summary == "two"
+ assert self._series_summary_column(a_path) == "two"
+ assert self._series_summary_column(b_path) == "two"
diff --git a/tests/test_tag_write_date.py b/tests/test_tag_write_date.py
index 2058757a1..17351f1a1 100644
--- a/tests/test_tag_write_date.py
+++ b/tests/test_tag_write_date.py
@@ -26,10 +26,11 @@
from django.test import TestCase
from loguru import logger
-from codex.choices.tagging import FORMAT_FIELD_SUPPORT
+from codex.choices.tagging import FORMAT_FIELD_SUPPORT, SERIES_STATUSES
from codex.librarian.scribe.tag_writer import TagWriter
from codex.librarian.scribe.tasks import BulkTagWriteTask
from codex.models import Comic, Imprint, Library, Publisher, Series, Volume
+from codex.models.choices import SeriesStatusChoices
from tests.tmp_dirs import tmp_dir
_TMP_DIR: Final = tmp_dir("codex.tests.tagwritedate")
@@ -39,6 +40,7 @@
_ISSUE_NUMBER: Final = "1"
_ALTERNATIVE_ISSUE: Final = MappingProxyType({"number": 5, "suffix": "AU"})
_COLLECTION_TITLE: Final = "The Dark Phoenix Saga"
+_START_YEAR: Final = 1963
def _double(stub: object) -> Any:
@@ -105,6 +107,7 @@ def test_format_exclusive_fields(self) -> None:
"collection_title",
"alternative_issue_number",
"alternative_issue_suffix",
+ "start_year",
}
assert metron_only <= metron
assert not (metron_only & comic_info)
@@ -116,6 +119,15 @@ def test_format_exclusive_fields(self) -> None:
# its own.
assert "reading_direction" in comic_info
+ def test_database_only_series_fields_are_no_format_field(self) -> None:
+ # No format holds them; the editor saves them to the database.
+ for fields in FORMAT_FIELD_SUPPORT.values():
+ assert not {"end_year", "series_status", "series_summary"} & set(fields)
+
+ def test_series_statuses_follow_the_model_choices(self) -> None:
+ values = [choice["value"] for choice in SERIES_STATUSES]
+ assert values == list(SeriesStatusChoices.values)
+
class TagWriteDateTestCase(TestCase):
"""Composite tag-editor keys write through to the archive."""
@@ -206,3 +218,11 @@ def test_write_collection_title(self) -> None:
sub_md = self._write_metron({"collection_title": _COLLECTION_TITLE})
assert sub_md.get("collection_title") == _COLLECTION_TITLE
+
+ def test_write_series_start_year(self) -> None:
+ """The editor's Start Year rides the series object into MetronInfo."""
+ sub_md = self._write_metron(
+ {"series": {"name": "S", "start_year": _START_YEAR}}
+ )
+
+ assert sub_md.get("series", {}).get("start_year") == _START_YEAR
diff --git a/uv.lock b/uv.lock
index dab5b0e74..df7d0593e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -419,11 +419,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.10.18"
+version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
@@ -675,7 +675,7 @@ wheels = [
[[package]]
name = "codex"
-version = "2.5.5"
+version = "2.5.6"
source = { editable = "." }
dependencies = [
{ name = "adrf" },
@@ -764,7 +764,7 @@ requires-dist = [
{ name = "asgiref", specifier = "~=3.12" },
{ name = "bidict", specifier = "~=0.23" },
{ name = "channels", specifier = "~=4.2" },
- { name = "comicbox", extras = ["pdf"], specifier = "~=5.3.2" },
+ { name = "comicbox", extras = ["pdf"], specifier = "~=5.4.0" },
{ name = "cryptography", specifier = ">=48.0.0" },
{ name = "dateparser", specifier = "~=1.2" },
{ name = "django", specifier = "~=6.0" },
@@ -850,7 +850,7 @@ wheels = [
[[package]]
name = "comicbox"
-version = "5.3.2"
+version = "5.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
@@ -885,9 +885,9 @@ dependencies = [
{ name = "xmltodict" },
{ name = "zipremove" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/bb/ed/29b03680e8f12c27b32fcf89df46b81038374a2ecf594451c9e513647d57/comicbox-5.3.2.tar.gz", hash = "sha256:c88f1863dde9cc4539dc680eaa4d0cb00dc8906d6b9456112e20518654adb0a4", size = 1348264, upload-time = "2026-10-19T02:15:46.142Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/d6/2480f456a86015a771dca8591ff7cda7710dd6f27e02811581e27abb4a7a/comicbox-5.4.0.tar.gz", hash = "sha256:00a354d8e2e04b137759fd0453efab6a16fe71c9ce8720f86a60b2d4ce47b649", size = 1365122, upload-time = "2026-10-20T02:13:04.054Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ad/83/90567e7463a3203de0e21bfc0c9266c297212ab4d97fc39c025ae0a151da/comicbox-5.3.2-py3-none-any.whl", hash = "sha256:681cc802606fd7365928c276a7b657027c74bc1380a0e02b311eb53812e5b210", size = 484612, upload-time = "2026-10-19T02:15:44.569Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/89/711ef28d4196deb50262a3e7832a57172f3998fabd39681f7e615e4d7e8c/comicbox-5.4.0-py3-none-any.whl", hash = "sha256:48374545a4b1748e723cead27aa1fdd1dcd4dfd2eff20b0cd2c97ef9d5c7ccca", size = 489294, upload-time = "2026-10-20T02:13:02.479Z" },
]
[package.optional-dependencies]
@@ -1234,15 +1234,15 @@ wheels = [
[[package]]
name = "django-allauth"
-version = "65.20.0"
+version = "65.20.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a5/5f/b69a68c09c41a0c2f235996fa47aba3eb88c0810bcfc823d22459c3e235f/django_allauth-65.20.0.tar.gz", hash = "sha256:54019cd2860eef707eaaef241a6598149d2d3b6bd7e78d175089b27d57cd8d83", size = 2301827, upload-time = "2026-10-16T11:23:48.232Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8a/98/a343df4b6230e483cd0437a9265f9acd365e4639d8750c17021455f94a2c/django_allauth-65.20.1.tar.gz", hash = "sha256:954cf5cb39e7793e25420f0352b77c74c9a68ab36ae2c8b9d783c2d710650f19", size = 2304239, upload-time = "2026-10-20T19:59:25.752Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/86/7aa7601e64ba7bda498466ab1a8af9aa568d0f51fa106d9eb46d8a4c5a30/django_allauth-65.20.0-py3-none-any.whl", hash = "sha256:83e22cb65b742d28c49e22a2dbfb475d5ceabc844f16b7a8a5c6a2676e612e9e", size = 2077399, upload-time = "2026-10-16T11:23:44.684Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/9f/0ea3886dd6310ddfb44d20a20152c04b7dbf6380c911171662d4d766119a/django_allauth-65.20.1-py3-none-any.whl", hash = "sha256:39f4b66d113b6f6df6805d22d577b8f06aa54e251425de1699fb6a3d7f8d1e45", size = 2077848, upload-time = "2026-10-20T19:57:49.207Z" },
]
[package.optional-dependencies]
@@ -1599,73 +1599,73 @@ wheels = [
[[package]]
name = "granian"
-version = "2.8.4"
+version = "2.8.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/46/6e/7002545a24aa0652c2ea00e626563379316b713ef1a09c2db5eacc109a41/granian-2.8.4.tar.gz", hash = "sha256:15e4f240dda62ca1bc9d84b264a25503812481ac60dfbedd784a3a25de110436", size = 130456, upload-time = "2026-09-30T15:12:35.901Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c0/b7/229ef688c4829522b65113754cd0ede0b3b3586596e1e8da4dd7e0932c90/granian-2.8.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2991b162535c9a0d8847b18c5111f8f0b9547520f0fcfb36b12332fdafe84660", size = 4549026, upload-time = "2026-09-30T15:10:49.089Z" },
- { url = "https://files.pythonhosted.org/packages/50/53/76be0f6ad8be7214cb156ff26f4d36eee6f2e6ee5ba492a33c8d721416f1/granian-2.8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0519692106465e0145d1a810034227a3cae0008d2aee3a19fe250105307c265d", size = 4257474, upload-time = "2026-09-30T15:10:50.613Z" },
- { url = "https://files.pythonhosted.org/packages/94/fd/b18ca267dd93f067bfe2d2b0a484d662987c44303bd1b31324cb9713e4fb/granian-2.8.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96ff07be39e0d7d94a43736a87c1cc41c8a8d17ef9336334b04cc055911b5968", size = 5133239, upload-time = "2026-09-30T15:10:52.086Z" },
- { url = "https://files.pythonhosted.org/packages/bc/5d/65bb8ac6b6f2593902bb5844655753fe1cf1f06825a9880b5efe88f681c2/granian-2.8.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:123daec45941e285b9f057a957183404f1607f239fcf7aeffef168384dfb3fa7", size = 4652024, upload-time = "2026-09-30T15:10:53.836Z" },
- { url = "https://files.pythonhosted.org/packages/76/7b/3a7501dec4bb630db8832f53c1d65ad344783aebf579b3d5e99824c064ec/granian-2.8.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18037cc4a5fbaebedcf333ae29ce2f742158e2a73195f6948d2f26cd9e689636", size = 5030621, upload-time = "2026-09-30T15:10:55.233Z" },
- { url = "https://files.pythonhosted.org/packages/53/e4/a60f4e237e9f096d6d0976ad51718d695336acc785b1a0a98ae218153840/granian-2.8.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:19feff2d50f9c72d88d554e66e7cf465bf49635a99c874fa5b632df0e27e487d", size = 5127306, upload-time = "2026-09-30T15:10:56.567Z" },
- { url = "https://files.pythonhosted.org/packages/a9/d7/74e1777a5c064bdcd1960014cd7d9ba928d384083123529e334d6f97380e/granian-2.8.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:05bf45f207d1be729ae383f09c07ee08d85aa6cd263e066ec88c44a8792b116e", size = 5169531, upload-time = "2026-09-30T15:10:57.974Z" },
- { url = "https://files.pythonhosted.org/packages/e5/0f/8f8ed0bb55f1c64b1de86c8a416cdd85775acf90ffdf4042e926280029ec/granian-2.8.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:cc7ef4fc9036789a91cca6158e41062587817243ac96e2f05604c0ec145669a6", size = 5251863, upload-time = "2026-09-30T15:10:59.526Z" },
- { url = "https://files.pythonhosted.org/packages/b8/11/7a1658cbeb6fc2ca10c1f8931865322d53ea72773d00fd769b0a888c8787/granian-2.8.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d3a7d8b71692f3dd22595fc4f4cda45a6d82e773df4da39a5f9ecf17fc8c543b", size = 5100426, upload-time = "2026-09-30T15:11:01.028Z" },
- { url = "https://files.pythonhosted.org/packages/19/28/886c3ca853cc905d20c6ca39c686953c169a5b9a8ccaf32bdf65b8c4292a/granian-2.8.4-cp312-cp312-win_amd64.whl", hash = "sha256:ecdf904691c07e5e73f79a27b12926aac86d04f264fd8a72fedf2e0bdc53c5c4", size = 3168325, upload-time = "2026-09-30T15:11:02.47Z" },
- { url = "https://files.pythonhosted.org/packages/a6/ab/0fc042bf594304f7587ac288bf29d9026b434b503e643c52f54c963c5732/granian-2.8.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1cb89448969d5b64b131a7d2709cfcd207c85d5535a4b9c11648f78184b1fa54", size = 4522590, upload-time = "2026-09-30T15:11:03.86Z" },
- { url = "https://files.pythonhosted.org/packages/af/a5/282169d9e30f99dab2bf9d9b2d559fd425deaa1b8b15ab64e9029b6dabd1/granian-2.8.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8359c7bbc316cc6d9272e44cc82e62a14bd6affe47b0c10c95c30cf3d0f02d91", size = 4258002, upload-time = "2026-09-30T15:11:05.159Z" },
- { url = "https://files.pythonhosted.org/packages/12/20/555b072727e7a6ee13a08ad5c3cc752195ce5042ed820767ee5aa389f752/granian-2.8.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf0c4a2cabce0df8a605a49d98b0022cd067d3b813ac45b3a88f512c3db59fe1", size = 5022333, upload-time = "2026-09-30T15:11:06.688Z" },
- { url = "https://files.pythonhosted.org/packages/2c/f1/76de3df725f6fa815f3e3f051cfdcca2a921e3b1832104c04d1bf6422535/granian-2.8.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39c19059bdc7e9156c14efe2c540af16049dfb3a8d197ba0742b973f34ae48e6", size = 4572162, upload-time = "2026-09-30T15:11:08.325Z" },
- { url = "https://files.pythonhosted.org/packages/13/5a/c64b1c67127deaed269ed73efce0f76171a0701893a0c724c300c86e4fbe/granian-2.8.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f28d6ff79f26e7cd4c992b6198a9f14fae266da80d2b6bd0da8fceef07c57850", size = 5031889, upload-time = "2026-09-30T15:11:09.852Z" },
- { url = "https://files.pythonhosted.org/packages/cd/c2/ea37cb80127293a24ea8dd5c9223b446ec132de819d8abcf58a05fbc32c8/granian-2.8.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c5a028410a4f9532ec5c93769828cc978b17efe7e3fae76fc66311df81aeb8d0", size = 5120823, upload-time = "2026-09-30T15:11:11.358Z" },
- { url = "https://files.pythonhosted.org/packages/59/b6/a00f0b11fcf1894914c3c0067a577beed4fb3983683fa919b1c0981bd668/granian-2.8.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:82285a809e72d59a576904fed60bb7aa117e52a1f20ac62bb1149b585cba6dea", size = 5164882, upload-time = "2026-09-30T15:11:12.783Z" },
- { url = "https://files.pythonhosted.org/packages/53/0e/96cb2d5d2682f0c00fa7919b9771ebd09d38837be35b28b885d7e7365f46/granian-2.8.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e0fe0f742a807abb25f4145303e46ff2dfd6fcd4b3c541fa89f691a0b1fd84c0", size = 5119982, upload-time = "2026-09-30T15:11:14.211Z" },
- { url = "https://files.pythonhosted.org/packages/16/c0/2aeab45b53968edb7ef80466da684c34f1afd36c6a692ceb3e90f5c41e6f/granian-2.8.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:8bc056a839cfc1a498c8505ee9fafe86e6da4caa01bc2358efbb59fb6607d663", size = 5103851, upload-time = "2026-09-30T15:11:15.658Z" },
- { url = "https://files.pythonhosted.org/packages/be/72/9ff987c1651dfe446ed514312b1f8c8d7a83eb7e26d082b7d6184e37213a/granian-2.8.4-cp313-cp313-win_amd64.whl", hash = "sha256:e1aca643411ee94cb7acf5c2378d46c9e32d6cd94d235ae940fbbcef972cd60b", size = 3189341, upload-time = "2026-09-30T15:11:17.124Z" },
- { url = "https://files.pythonhosted.org/packages/df/c3/91d8bb7c8250ed85ef187271495e16447afa7d282dfd8cb26d8bd620de63/granian-2.8.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e6387e6c784ba6c778b6417161bfed6fd75dc93df7152fedc61847ca79bdf3c4", size = 4537988, upload-time = "2026-09-30T15:11:18.508Z" },
- { url = "https://files.pythonhosted.org/packages/68/8a/89d37db67ff2682a3ffaec1b7ea09af7db59624713d30f5e7b7fb3a633d7/granian-2.8.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:522de7ed1154082f11176a45df841bf58ed45fcaf810eef6200e39b4f371a29c", size = 4294229, upload-time = "2026-09-30T15:11:19.967Z" },
- { url = "https://files.pythonhosted.org/packages/69/5a/3840c16e0ba01724f2aa00acd784d69f5979143651a22bea709e513ed201/granian-2.8.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:439f2bc74e0db5485a1749ec7040043a4061e05d444e4a754393d90738697668", size = 5019362, upload-time = "2026-09-30T15:11:21.321Z" },
- { url = "https://files.pythonhosted.org/packages/a6/09/cf2b9f5e43723260e7abca049ab637c3d6c7c417fadffa2fbe1ddbaef192/granian-2.8.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f00690ea448a4dcd4cc69e8bcdcf8cb6e7acf9549c273ef344577bc5f5d05cb", size = 4567850, upload-time = "2026-09-30T15:11:23.056Z" },
- { url = "https://files.pythonhosted.org/packages/4e/44/7cee563075a450361faa1441af71e9ad2e847398f88dbab67324dcf5a368/granian-2.8.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41fd375030f9de7c864d52e51bc436db5476a5599fa97a6cd481fbf7c1e349ca", size = 5009761, upload-time = "2026-09-30T15:11:24.599Z" },
- { url = "https://files.pythonhosted.org/packages/3c/77/f82439fecd929514915582039a3de7d27aff95c1bff517324aea77273f30/granian-2.8.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:72e09942428b04931a0b1fb853f5101352a11f82e5805468d72bd2f5ec27e810", size = 5128512, upload-time = "2026-09-30T15:11:26.06Z" },
- { url = "https://files.pythonhosted.org/packages/2d/5d/bf0619b52f671b6a535c56295f48a87d11e6f7f27108433ba176c39322d3/granian-2.8.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bd7796798d4e127b273dde3d5b7fd3b43f682e6b21f2cef84c8b20e6a09e1255", size = 5166857, upload-time = "2026-09-30T15:11:27.854Z" },
- { url = "https://files.pythonhosted.org/packages/37/e6/f15ee7af0a18fa7881ca692300d100296eeea98358d523b2d321cbe6f3b4/granian-2.8.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:71242dee81f4f59e8d6dff173682332cadd9635e76a5b014e7b664421db90566", size = 5118394, upload-time = "2026-09-30T15:11:29.517Z" },
- { url = "https://files.pythonhosted.org/packages/0f/6c/f8fda6d6551576f635d403c4bb7945fe9400141fb30236556a8b1ab4ea04/granian-2.8.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:85b87b6fb351d1bb1926691c6c73ec95f21eb6882f4742241b597391c456a645", size = 5080931, upload-time = "2026-09-30T15:11:31.139Z" },
- { url = "https://files.pythonhosted.org/packages/e6/89/5825188a9acfad14a780bf90435ebb8a603c66b459d9eff44fdee596b060/granian-2.8.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0479221d6d0224f26130decd24f01a4415c672c18f35c8b4500cd42ec5591c0", size = 3139278, upload-time = "2026-09-30T15:11:32.61Z" },
- { url = "https://files.pythonhosted.org/packages/db/ba/7d522854e9994a53e50b652e30f26833d742395d12d23ffdb0c10389afd3/granian-2.8.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:169339c7f6f8c755fb27e8639ba4c5e86e1c70a371d6067ae8448b6f74e52ba2", size = 4434002, upload-time = "2026-09-30T15:11:34.047Z" },
- { url = "https://files.pythonhosted.org/packages/d1/5a/f7df2a966d1be6b6d741806e70a634f3a134e4718479a2f489d61467fcde/granian-2.8.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20db1d287d1eba2e25ed71ed64ef5385bba09866d686b540f190c7d3c16f422f", size = 4150598, upload-time = "2026-09-30T15:11:35.854Z" },
- { url = "https://files.pythonhosted.org/packages/22/a6/5d4b8e092a105d77f523008ea727d8d29f7aa5ba08c690baa15f12e59ac8/granian-2.8.4-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d14375c88b689fa236bc68f80a8157142207971e4c87d500fd20740e69ba16d", size = 4413958, upload-time = "2026-09-30T15:11:37.366Z" },
- { url = "https://files.pythonhosted.org/packages/02/4b/d1790037d362936370ca6aecb366429a23ab4829c4ac9d416077e9972781/granian-2.8.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e6273949df06399caf9dbab4d4580f0c90ac47bdb2c2befc582883b16352d908", size = 5041687, upload-time = "2026-09-30T15:11:39.026Z" },
- { url = "https://files.pythonhosted.org/packages/53/4d/7e9f7168bce74d4629b596ca9832388fa1831aef0ed51ac5f01752092c54/granian-2.8.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:605ec23545d5c49c8a42ba0f94f0bccc4c05777cedc7ebcdd008313de0fb34c3", size = 4858472, upload-time = "2026-09-30T15:11:40.595Z" },
- { url = "https://files.pythonhosted.org/packages/6e/3f/5a953871497f0d3d6b9dd23dee5bf96421a97ecbec34d1732ca68512805d/granian-2.8.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8696d0a85251c6be57d5f768af38d0b065c2704c61f7872b692ad9d604b202e5", size = 4906779, upload-time = "2026-09-30T15:11:42.555Z" },
- { url = "https://files.pythonhosted.org/packages/4f/a5/71735f279a6598c7e09cbb020dda67a4303bb74ece2c834e51345a12d678/granian-2.8.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:9cab808b3ef5d8db83226e898510b337d6872bceaa8e1e5f932ba0df90fdf974", size = 5106296, upload-time = "2026-09-30T15:11:44.072Z" },
- { url = "https://files.pythonhosted.org/packages/a2/0b/e11a340ebbb717eb3e9b30c1baa9684fcb0d065f9109412f1e7a8067849d/granian-2.8.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6d9c15ad2e6f692ef7c8e7d0a82f6a9942268b8859ed790e4aaaa5dc18aa1a60", size = 5163457, upload-time = "2026-09-30T15:11:45.813Z" },
- { url = "https://files.pythonhosted.org/packages/1c/ab/922185fd354eb531fc78cf2205f3523e25452efc2faf4e0ba47f4649a5de/granian-2.8.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:663bdf86c3afbb16c2f4b390de78dbd4ce8ad5ba41264ae4ac52a79b58bcddd7", size = 5099484, upload-time = "2026-09-30T15:11:47.379Z" },
- { url = "https://files.pythonhosted.org/packages/aa/e1/2290930bb9f322e2bc6c3f6f576600ff16778fc8d19fcac6bce90d298bc0/granian-2.8.4-cp314-cp314t-win_amd64.whl", hash = "sha256:66bf5670d5b29e4b026516e29a3ce74cbb83a76b4b448398a4fc503c5497c772", size = 3110657, upload-time = "2026-09-30T15:11:48.86Z" },
- { url = "https://files.pythonhosted.org/packages/4a/a6/83006f1d834631d7baecdea42d504c4d2ab9a25cc2e21044c106983c7527/granian-2.8.4-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:5cb49d576297e15657282bb8755945e990729e0e3a830a2bc1e959e7680f4528", size = 4537585, upload-time = "2026-09-30T15:11:50.36Z" },
- { url = "https://files.pythonhosted.org/packages/41/95/099a730f340556291d13aafa2fabd425ed951edd9e7d25c15c5affbb4420/granian-2.8.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:61692e10cba625ea8455039629b87c8df2b1c25daca6c6891384d2315b7a61a2", size = 4293488, upload-time = "2026-09-30T15:11:51.938Z" },
- { url = "https://files.pythonhosted.org/packages/7b/5f/1ea52bf78cf5234f3fb4d6c13349f9c475122c6fc1b3278549622a2f1da3/granian-2.8.4-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6bf588c3c8d26bd355f75442a55806a2c62a0d81005ed83c7c382062da8127ae", size = 5019509, upload-time = "2026-09-30T15:11:53.357Z" },
- { url = "https://files.pythonhosted.org/packages/a4/b6/d4fe258bc4c82eaec3902545dc4a7fb90f9810504dbba1260b022b8ef8e9/granian-2.8.4-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c2f85e97c4c1578c05fafe4c82878a9d868049ae7e919ebd28d3b5ea30f0dd3", size = 4568784, upload-time = "2026-09-30T15:11:55.601Z" },
- { url = "https://files.pythonhosted.org/packages/6f/6c/95b2d44f773cc54c40c73c556228a28234d3b28a24ebd1d8a9f106724a3b/granian-2.8.4-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79cc8b1d9f6f3027800b6c14398978d832fee78a2050ca83c60a6c0cdcd95195", size = 5009546, upload-time = "2026-09-30T15:11:57.028Z" },
- { url = "https://files.pythonhosted.org/packages/14/a3/7724eb6ce6bb0fc6eceff22b9ca9ab3a8669543beef5a1061fc75593f89b/granian-2.8.4-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:3c3385e268c4c4e79af73f1b2d8dc4eec9701a3105fffbb148f786351580fc82", size = 5127719, upload-time = "2026-09-30T15:11:58.727Z" },
- { url = "https://files.pythonhosted.org/packages/fa/8a/52701781c0761683fafdc0675244b769c7e952ebe907c1c30a6b32d6a17d/granian-2.8.4-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:bc21d4aec5c36b251855ef63ef7173a3cd7105fc20936c4e7ea0c03a9638d246", size = 5166484, upload-time = "2026-09-30T15:12:00.276Z" },
- { url = "https://files.pythonhosted.org/packages/ed/c5/e41b74a152882c1af274df3ed2d24d626538d5ea99763670367e5f9ad3ba/granian-2.8.4-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:b1aa1a2f42e41cc63fbd2b3cb7de0fe65b23e656615779c6ec96ccf2112e5325", size = 5119075, upload-time = "2026-09-30T15:12:01.732Z" },
- { url = "https://files.pythonhosted.org/packages/ae/11/f61ed83a4d888e7b3d5171ede6daeadf045ae273bf0d58cb3c7eedce24c1/granian-2.8.4-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:38519ba3650c5667af5aec2d6dcc778656696742a777661a3c27caa0b68ac05f", size = 5078995, upload-time = "2026-09-30T15:12:03.344Z" },
- { url = "https://files.pythonhosted.org/packages/fb/f9/7ed2d4489b24a76d59529efd28c814e3ec2100af830809ac46cac564a07e/granian-2.8.4-cp315-cp315-win_amd64.whl", hash = "sha256:a23f30b2acf5bc1b30fc72110142e098cf929d75321c47db2f6c16a309ef9fe2", size = 3138950, upload-time = "2026-09-30T15:12:05.221Z" },
- { url = "https://files.pythonhosted.org/packages/9d/39/e07a28bc53346d93a518bd89eb21c1839af1ab05d930502bf0177f0c064a/granian-2.8.4-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:05e0b6fd7f6ac6f584d36886407399a327d292b1950f6dc96b0f9f865d5ddd99", size = 4433179, upload-time = "2026-09-30T15:12:06.708Z" },
- { url = "https://files.pythonhosted.org/packages/59/86/3b8e351e427bcf77bfcb91f84bc76e487fd6d1808106874f071a97b4c3a7/granian-2.8.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4eaa524434e86ff49a5ec91cc9f68379c8c1fbb95640b4178173f74dda9daf51", size = 4150416, upload-time = "2026-09-30T15:12:08.358Z" },
- { url = "https://files.pythonhosted.org/packages/e1/0f/0512b4ccbd3622d58ac15214b19e9991f863322bc3d99eed83c63c3ef1f1/granian-2.8.4-cp315-cp315t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a751aa614a7c970c49abdd09e2c8434e23b6c4b9f574db811d7e34a1bfb63a2", size = 4414189, upload-time = "2026-09-30T15:12:10.087Z" },
- { url = "https://files.pythonhosted.org/packages/44/c1/36e55a2e62faf9043004082240d1ba5dc7d2b86aaa324e0af70a85ae68a0/granian-2.8.4-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:408f231dd02f2b0b0f9cbd1979952a242f28ea0abb7f4ba28dac93a607ce82da", size = 5042083, upload-time = "2026-09-30T15:12:11.6Z" },
- { url = "https://files.pythonhosted.org/packages/46/a4/20a42d79ca47af0376e766f5edd420bc967d41ce34a0dcf58eb028806dee/granian-2.8.4-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96b36fa67273e846a98634abf00adbddae7a56480c1639920157b21a1e96fc5d", size = 4858516, upload-time = "2026-09-30T15:12:13.049Z" },
- { url = "https://files.pythonhosted.org/packages/44/82/adba7f7748dbd66c2c56f26ccfcaf6fffb715ccda626da7741a1d2cff1d1/granian-2.8.4-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:f49deab00a5bb19a262f7ed727181c9b24a47c1db58debc6e929559c9480dbff", size = 4905319, upload-time = "2026-09-30T15:12:14.44Z" },
- { url = "https://files.pythonhosted.org/packages/ca/8c/caa449473cd4a3c7d13957dd6af0da478f0fce4abe3b0f808edd2262783b/granian-2.8.4-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:74ef5fd4547f72c949daa2bfa5e1595f84e5c5e486d64ba821e1cbd9254dbc64", size = 5105886, upload-time = "2026-09-30T15:12:16.008Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0e/f4bb890dd82ddc7a0137eb6a65634f7a2bc43e38b1f9894776fe0cbad2d3/granian-2.8.4-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:1a3140ae4381bcae90cf61fbaadd34862d5757f4d50491f21f95907ccdd8f0a8", size = 5163449, upload-time = "2026-09-30T15:12:17.612Z" },
- { url = "https://files.pythonhosted.org/packages/24/46/61c05c9ccb0548682580e1e47fe84b8338be8041f7d7c988b7b4eadb6c48/granian-2.8.4-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:2628902003bdb7830dae67dba301aa3457922beb06a66d090755f79c46801628", size = 5098768, upload-time = "2026-09-30T15:12:19.137Z" },
- { url = "https://files.pythonhosted.org/packages/ae/a4/b3f7b8010d71dc233bcb1a6bd9742153cf094da3b9d2f93a59a85690e6dc/granian-2.8.4-cp315-cp315t-win_amd64.whl", hash = "sha256:44d3a5fdd97b78cd9971244996486387b2a80f577b42703725e78681f36ea810", size = 3111688, upload-time = "2026-09-30T15:12:20.912Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/e2/63/c2548102956296ee0f8671291dd5a6f3f3dc2fc7c4e62e0b86e3be6de571/granian-2.8.5.tar.gz", hash = "sha256:032588689e2d524d633fcb088ee54fad4c5655d0af1a75058d5b4240be4f6864", size = 130933, upload-time = "2026-10-20T11:43:38.079Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/91/103c04ea8ef7d9d40cdecb96a07cae09bfe9e3857d39b8174fc5c1a771fd/granian-2.8.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3404a620364f3816f126863910e6dc86da833298608896e556c01c9bf52a63c2", size = 4600588, upload-time = "2026-10-20T11:42:01.027Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f2/52735de5caa6db560bfe91b60c97f5a1bf57a035ccd08004f21f18762830/granian-2.8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcb0ecee7650794738fa347ce1aac5a7f790bf36dc0dace4194578335ad3fa60", size = 4310695, upload-time = "2026-10-20T11:42:02.305Z" },
+ { url = "https://files.pythonhosted.org/packages/60/e3/274741428e960bf59b12c51cdca2340698e708f3bab4ec6fa16b0765d12a/granian-2.8.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a5e1459736acd617631524db3c430df7b7fe2fd55b6f21536eb9d30a603d41", size = 5156401, upload-time = "2026-10-20T11:42:03.738Z" },
+ { url = "https://files.pythonhosted.org/packages/77/53/c9c16f814597303e4373eb3cdfc8b0ca75582f6939a1a84b9109c98cbfeb/granian-2.8.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:217c38e88aa324ef73c9c5d823434b5c5ee7a7181b36c23b408d439905ece7a9", size = 4658203, upload-time = "2026-10-20T11:42:05.252Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7e/9a4aebcc1fe18d849b79cbf2aded21e8d6023436b98a53b1514955c5470a/granian-2.8.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66f0de2cb17109da7ca070a2898e4543083914c2c440ba4a134a53cf4ec526be", size = 5067921, upload-time = "2026-10-20T11:42:06.555Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/4b/d7bb8f98d739e62379d88131654ee68de441ccc5d6118f578784689257fe/granian-2.8.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:95bae2039b53bc532386a0b1327b52e73c16b3e11997a727024f9bd385242a2b", size = 5117522, upload-time = "2026-10-20T11:42:07.749Z" },
+ { url = "https://files.pythonhosted.org/packages/59/3e/2c494c6afa6c4ae564c05d0611ce7ee108824812a74bb20e024e752a8f2c/granian-2.8.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c72079469aba9856d4d030b21ff75a5c2d9a201cab930926237ab1646e5596f", size = 5141659, upload-time = "2026-10-20T11:42:08.96Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/16/047f981331408110441c5adbdf9a21acd34846e13ee6546e77c7359b760a/granian-2.8.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d263fe13e16a612a0149e6948f556e5ed8fb4fdec9890812743d116025fb442", size = 5210772, upload-time = "2026-10-20T11:42:10.818Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/21/40f73b96a0fe0bcdea7a65ede042a029a10c1490a513fbf8909087eab02c/granian-2.8.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:19902f90f272c089ecea998214c62f7eae846d454a9a82458fcb77b41adddd29", size = 5157211, upload-time = "2026-10-20T11:42:12.484Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a5/ac92e100d1f5141115ee796321d8f9d25d8f2c07198e5763b1ce53c457bc/granian-2.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:efc2ae72102983a32cf6cc524a595fae3211937bdeb5bf93767b01dc6798f8ea", size = 3212050, upload-time = "2026-10-20T11:42:13.894Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9e/c831afbdc6306b4c05ea89619b3e51d3350d499ab3a3078f42b3e1481a6f/granian-2.8.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c30bf8ad2a9547b8efe4cffa67adef5d3f065cac7b37ff7ca6f7df12ed07bfe8", size = 4600095, upload-time = "2026-10-20T11:42:15.316Z" },
+ { url = "https://files.pythonhosted.org/packages/29/d0/66c795135b0dc79f0a1e14fdbfc9c1d01d88ef7ceafa651185ef6f9d3e87/granian-2.8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ea263ab8c64434080269a49dc706a1841f7939ceaf1ddec0f204edbab2d83fc", size = 4305333, upload-time = "2026-10-20T11:42:16.815Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/3d/1b32e5d497d413903c5c58dded3751b8588ea8282d4c61ca57d694408fb9/granian-2.8.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:048feae9762a044357ed7e31b9117f646ca985b5cd57383120f93b2d3da5bb63", size = 5114313, upload-time = "2026-10-20T11:42:18.291Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/94/063998b729003dab2a7a793a3deb4fec23581aa0a51f4a5e492a5fab194b/granian-2.8.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87eca4dbf606eb4a91f1c01f7cf7517c8290343cc083274a9e6d24d7b20577c1", size = 4599262, upload-time = "2026-10-20T11:42:19.529Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/00/c3db2af29473d37252a1629f9512c45479da3f4efe542a8ec07ebed24108/granian-2.8.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf44d5c35995f6009307d43c6b7c8310299f07f415d6cbecbacc5fdf8508c73f", size = 5065166, upload-time = "2026-10-20T11:42:20.995Z" },
+ { url = "https://files.pythonhosted.org/packages/64/26/05d2bb66e8a6c07568a6ae334b7a5e9c674462ddc88cdcb9fc5a14e2b5cf/granian-2.8.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:30c85664d3a51397c97413f5c341e7031ea08614c016d9bfb2ee82bbd6029c12", size = 5114096, upload-time = "2026-10-20T11:42:22.261Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/ca/41e56be77bcd77b4bab356302dc6b7755227ab33645185131ae3a40b1331/granian-2.8.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1be90ad793a14e367c3cc8a21aa82078b4a6e84df95700e3fd76f4207e558b3f", size = 5141240, upload-time = "2026-10-20T11:42:23.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a8/cd89ab5a87ff71a3f6867fa9ec931a27810bb8e7da6b24d41288476a8f70/granian-2.8.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:9c84d73e53c774d8600d95c9eda1904ac72c3ce761407f4b41af20ce9a0b2859", size = 5172814, upload-time = "2026-10-20T11:42:24.719Z" },
+ { url = "https://files.pythonhosted.org/packages/26/56/baf53317b674e5a8b8aeb1d188be42dae67982301da140b6214b448d2f7d/granian-2.8.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f66fd2c88fc3f9bed832df754dc4c05d685d5254fec54f77abd813ac28433b2e", size = 5155624, upload-time = "2026-10-20T11:42:26.048Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/4b/d3fab8903e19053a1a877d12f61c9574f4eb22657ca8d16fa8dc8d66621f/granian-2.8.5-cp313-cp313-win_amd64.whl", hash = "sha256:34f5eba9ee539a45f83cfeeda5d21b84da165db1482c7be2525bca689eb37871", size = 3240628, upload-time = "2026-10-20T11:42:27.321Z" },
+ { url = "https://files.pythonhosted.org/packages/94/6c/44290c79496b69cdc68c1438428cd8207de00a8b14bbf45aca59273f1b7d/granian-2.8.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6a2de1f78f4b54193f37f6c89562b9fd372dc68812e0f4aa82499770631ef6a3", size = 4575722, upload-time = "2026-10-20T11:42:28.764Z" },
+ { url = "https://files.pythonhosted.org/packages/09/09/5b1e9d5ee47ca8e248ed4d8d41f0290a312c1c2d9415f558ae0106a6691a/granian-2.8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0ba7323572d6f0b6cc1a86fac8f6bc42d467fe0034c85429252e93b111c79a1f", size = 4292154, upload-time = "2026-10-20T11:42:30.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/75/8e8ab830eb7d3bbeb5147bdd5e05e7f307f46950647e0277973811ce511c/granian-2.8.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cdf207b3942b0c131f98cd01769b2e1e8f302891b220b39140c8d4a325d3af5d", size = 5111113, upload-time = "2026-10-20T11:42:31.924Z" },
+ { url = "https://files.pythonhosted.org/packages/77/b4/1b3c164cdb8c16c130fa483ab15b18c4f068406e901b1bf30725ab2dca3b/granian-2.8.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a25ee45ab230a9983c6ba6401b1dbc7b3ee7de48793c52712c8ffb9662baefce", size = 4598387, upload-time = "2026-10-20T11:42:33.386Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/65/70d500aaf815be539d2761192b499fc7514a625eb92b1f55c4639db7d75c/granian-2.8.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8162358380d4b9398a2176779cefe553921fd5e433ccad7fe6d371e0845fe82", size = 5043379, upload-time = "2026-10-20T11:42:34.784Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/b0/863f62924357de8b486f22a7a5b11e7bda2dbebcc44ce2950af210f3d57b/granian-2.8.5-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:091fa616adfbd83460433a99df0858ff3e831e937e478d1e8170a0fb71c61431", size = 5104873, upload-time = "2026-10-20T11:42:36.078Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/19/4214986ccad3b6791b5b70ee8ee1545af4a12c8650726025280f69854655/granian-2.8.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:66f2ebe92dabfd986d99cb911c1027bacb35570119e244918e5beeef56dce1e3", size = 5133431, upload-time = "2026-10-20T11:42:37.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/68/fbeb8760e5854115d0573a998d2239d844f3681781f3d2ab1b732fc05224/granian-2.8.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:519d1461c2ea4086ff5643b8abaa78dadc5a3d0f2ab5ac4de1811f9980c3e153", size = 5168722, upload-time = "2026-10-20T11:42:38.779Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/72/063abeff9026aa62aa1412b327cb4b75b161dec5744e60b24eba6c98bbd7/granian-2.8.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2049b3b0d591c754ac89e313f88e8dd35c4aa741193e39f2329eece4e447c04b", size = 5130161, upload-time = "2026-10-20T11:42:40.216Z" },
+ { url = "https://files.pythonhosted.org/packages/20/25/fd9cee9460989df1caa497bfebe4cbe7d98d2de013e8ca92594e8b15d4a0/granian-2.8.5-cp314-cp314-win_amd64.whl", hash = "sha256:14d92199bf255db7ce64a63a63f42d52392f287c097a0427e60436111a7853a7", size = 3233798, upload-time = "2026-10-20T11:42:41.451Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/cd/a56c529ae01d5ebf68971a4e741844274d1565ac0d2e05ef8f2cb76ff878/granian-2.8.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9ae77ceee1148982c9d3f7ded52961355c81adae206e9621fe27c9a7c426a260", size = 4495468, upload-time = "2026-10-20T11:42:42.809Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ff/6c86c7cc796c440c383300aaf3c95736e5663a756a8164b4faa6ed0be2a9/granian-2.8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3ab0046f99ccbb39198ecadac6df163ff312886b93630e1ca319a1fafecf6af4", size = 4176464, upload-time = "2026-10-20T11:42:44.035Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d4/6211c9839cf93b675221d388e4eb14e1eb1f3826f694b9d4b872d2a5f0af/granian-2.8.5-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:83ece3459a3652a6389ee663efdb39b5c5c27ebf349efdff0cbe4c6dcb7cbc9c", size = 4337940, upload-time = "2026-10-20T11:42:45.577Z" },
+ { url = "https://files.pythonhosted.org/packages/05/fa/a4061b90996d5b6d8868d71b40a75f5d52618e4c8f59b5adf8d40bf420d9/granian-2.8.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2fd8a7956acfbd8b223e39103d63f80e6106d30fc9dc2f913eb3a638bc5c57", size = 5011790, upload-time = "2026-10-20T11:42:47.238Z" },
+ { url = "https://files.pythonhosted.org/packages/27/b8/63070894c66028a35820d4d0c7a7ab976f74cc2c0e00cdbd1f60f79026ac/granian-2.8.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67a0fbdca1f7e923d1627b584a1f0dab66b7a18227acfd583ecdaeeff67fa4e", size = 4854816, upload-time = "2026-10-20T11:42:48.526Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/d9/2f6e76dff34f81eaa4c45de9fbc86f5468f5a88e1224250c506a338bbf2a/granian-2.8.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8efb3ab7e55f48c83695cc6cb196f56bc54874c45d2229e21c859135a4837788", size = 4884836, upload-time = "2026-10-20T11:42:50.035Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5c/a9459e7561b233420baec9dde2bdd1f76f9c1e026ebd51570b8f99013a6c/granian-2.8.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:91bdc54ddee28d5b809533937a086a2e1dc78e0b746b7d925785863036d815c6", size = 5082247, upload-time = "2026-10-20T11:42:51.343Z" },
+ { url = "https://files.pythonhosted.org/packages/75/29/afa188c5f28b0d9ec09eda9016fa99e2a44ecd9ea399b3c3fa67f399300b/granian-2.8.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9f9fbcfb98e19f649f70e12c432609c4d03eaf61e14b6e2093abde7042f9c533", size = 5053807, upload-time = "2026-10-20T11:42:52.739Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ff/b36c8988e56a2b3e7846fc63e5c93a15408d96786807427533fed87c49e6/granian-2.8.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c961cb4b17dc58bafa8a0c02a7be988c81f69fce76b5ecfe20c656fda0ede22", size = 5095841, upload-time = "2026-10-20T11:42:54.325Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/9d/c25f5eaa94f5e57238c23686531833bbd5c9dc2896068f8b95f8faf860e8/granian-2.8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3012b24c51882581c6717f77f907e9ea2e8178a9f5170a4eaabcb1b67a059adf", size = 3166239, upload-time = "2026-10-20T11:42:55.75Z" },
+ { url = "https://files.pythonhosted.org/packages/98/7e/7fb5e711fa2e973691175cd7629ad3ad2268359489cc00c928ee7326ce30/granian-2.8.5-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:4d7d369665b628aca1e9aead5942e017f796ddf1d894aae775b61430a76b8c34", size = 4575846, upload-time = "2026-10-20T11:42:57.195Z" },
+ { url = "https://files.pythonhosted.org/packages/75/92/30313d9f420871f17937f460356a37161f0749ef4bf00f1e55a206845644/granian-2.8.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:5efdd2240bfa3c73b28823b600a536435e668c319b563056169dc2ccc8455c00", size = 4289515, upload-time = "2026-10-20T11:42:58.922Z" },
+ { url = "https://files.pythonhosted.org/packages/37/48/faebf1a75105cbb80fb5aaf10b0e847b5a3c95be4d1ae418574e0b9f471c/granian-2.8.5-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4643e7e06e41c5e6fd3f64120351b82f0355cac2fc55753b416d4ccfdcf3b096", size = 5107846, upload-time = "2026-10-20T11:43:00.087Z" },
+ { url = "https://files.pythonhosted.org/packages/77/aa/74a3daefc65bae63aa69e83304ebc40b71f0cce697084f57ec08475062b4/granian-2.8.5-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f88a2287bd249e5412c48389eb8ef7abd402e224cae91a991c69038c05168a15", size = 4597929, upload-time = "2026-10-20T11:43:01.693Z" },
+ { url = "https://files.pythonhosted.org/packages/82/7e/357ae2afd73f26f66afb10d5f56a05964034479202c6dcee0625acf11f0a/granian-2.8.5-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4dd03483d79831d9e67c3490aa4d68e717bbf68b15fd84d75d05413b341764", size = 5043750, upload-time = "2026-10-20T11:43:02.975Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/ae/5a2323c84244954ea1f57cff8839b21dc160fc8867595ad02d14047c4e14/granian-2.8.5-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:1eefe87b1d4294e6c5f0876f5f497af6ecca6b1ebd79a4a841cbdc9469981781", size = 5105156, upload-time = "2026-10-20T11:43:04.628Z" },
+ { url = "https://files.pythonhosted.org/packages/40/6d/842f6ba44ef4c8eb58487de3b80e712c2863e79053272ad6dc6936b4052f/granian-2.8.5-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:bc75eabd606b76dbe31f2641d006152b27e8f317336faf18eb5ebe68e408b23f", size = 5135072, upload-time = "2026-10-20T11:43:05.938Z" },
+ { url = "https://files.pythonhosted.org/packages/df/4c/413a653dff737322f37aa58bff198624a19a10bd949d269ae3851c98e296/granian-2.8.5-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:9db4955fdddcbf975cb6f5aa97862679647ead741ecdd08963b5fc8b8d4a3ec5", size = 5170126, upload-time = "2026-10-20T11:43:07.332Z" },
+ { url = "https://files.pythonhosted.org/packages/65/2a/d2faa15f0e8dfce88abaa607946219dad89ace7c0c0654e77157db65ed24/granian-2.8.5-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:cf3ecf3a3244b9131ce0a847e50fa404b340a99b0904047d993eef0ed4cc5f4f", size = 5130094, upload-time = "2026-10-20T11:43:08.636Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f4/79b63eabb67cd36b43b98fa6d9a1a8ee95b8a01eadab64fbf9cfdfabd148/granian-2.8.5-cp315-cp315-win_amd64.whl", hash = "sha256:e23bcb230aaf0c15d8e8720f7950a7d4048d73e50032c17e5db6076af5dbbc69", size = 3233682, upload-time = "2026-10-20T11:43:10.701Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/35/6cb05558f4c3ceb75e8e5902450aa6ad40b11241684ef47cc9e2483584d0/granian-2.8.5-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:08b00072451bfc29cdd0dfef1066dbb8f9e9ca9d260b223f82c79d1168a194eb", size = 4494355, upload-time = "2026-10-20T11:43:12.011Z" },
+ { url = "https://files.pythonhosted.org/packages/04/43/24df4c606be4d15dfca92a5ce3ee4b8f4fcf5327519af4d3c403a5b2fe4d/granian-2.8.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b18d114a56029d464ff2f782387b906b180177462d9d2254f4f73dda829c4ad3", size = 4176714, upload-time = "2026-10-20T11:43:13.843Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ef/7ad359c036d88ae4801360bf5350e9976a9c662e2479964b96ba182463bf/granian-2.8.5-cp315-cp315t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ea673c5c98fd182138ed21d0a05528a11bd469e201f557a7b59bb8062b5b37d6", size = 4340682, upload-time = "2026-10-20T11:43:15.085Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2c/e23041ec7f44022fe9e5ffdc927008de62bc83c33eb8d3f3a6151a15a8bc/granian-2.8.5-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7aa3d26e71794cb7767b0909d92d76736b14aa0fdd451b94b6a0d4d0c875d403", size = 5012489, upload-time = "2026-10-20T11:43:16.573Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/95/7b9ba12477bd6c8fd34261c9586f303a9dc98928a15d8b05a0e50ced47a2/granian-2.8.5-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62fe53469efaf497f84a822537470fe8702df8125b0247ee38415e4684ee1c91", size = 4854972, upload-time = "2026-10-20T11:43:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/da/f4/b4d469cfdb0ca72e8990e4dd3183e668904f5c2e42cbda911c95bc502eb0/granian-2.8.5-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:fd39d21c95d49dd5be07b6e3031acef2590a0ef41218a38fca489700c7e05847", size = 4884122, upload-time = "2026-10-20T11:43:19.214Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c8/14db3b06e52ddf74b8b50e70b0ce7d8929aed24a828d35fc8bba7cf4cae3/granian-2.8.5-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:deb8d3bfb1ebefd8eaabbb2b25bb533f74a3e453bcaa419177e59d39682710e3", size = 5083706, upload-time = "2026-10-20T11:43:20.575Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/9e/4fcf222551e36358c66c6b971e0b2149ba8a0adb5ef99d0168033b84505d/granian-2.8.5-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:58713e4fd8b3ee5d51b5d1aacc2f800b6922dd5f19cd2609dbbcc47336bc6b7d", size = 5055522, upload-time = "2026-10-20T11:43:21.77Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/5c/6e1b5d5b498fae263145fe719d053810b38d86d754a92454d15a985274d2/granian-2.8.5-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:42aa50fcf6798f30bec90f9b5a22d3039be06932423b12ffa80b1ade2328a631", size = 5096223, upload-time = "2026-10-20T11:43:23.238Z" },
+ { url = "https://files.pythonhosted.org/packages/72/ba/8f76547c8c52eed6ac2fe2b17e749b7fb8bef9bc23dcfd849897bc7ae7b1/granian-2.8.5-cp315-cp315t-win_amd64.whl", hash = "sha256:1fb135bb48bef82f9dcaef3462c4b49b24a6a423572c65e6b9db4647555b821e", size = 3166220, upload-time = "2026-10-20T11:43:24.509Z" },
]
[package.optional-dependencies]
@@ -2002,11 +2002,11 @@ wheels = [
[[package]]
name = "jsonpointer"
-version = "3.3"
+version = "3.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c9/3a/8c1782388c28969ac464cfd643b4a318d427f49b42ce77bb5f110fb32934/jsonpointer-3.3.tar.gz", hash = "sha256:2d851da328132043289a03df1dbb1577b901ad9cd22911f515d17875fbc1773a", size = 12076, upload-time = "2026-10-18T21:14:40.19Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/77/9be5cce647fa4d7afa9047c0c1029bc73292c27430e64fc9d2393ccf6727/jsonpointer-3.4.tar.gz", hash = "sha256:e57015ae65af3821c6a3976069860789cf326103f38b22b2178d41d021adad25", size = 13113, upload-time = "2026-10-19T10:33:24.436Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/52/f21d1a5ae39200fb2d7d1bd0396e4a5849d95ee0fc71c25d276645bf7e26/jsonpointer-3.3-py3-none-any.whl", hash = "sha256:1e9b099b883a4617561960f754879a1af62b6d2e0f4e4a8029e8326e923e6723", size = 8427, upload-time = "2026-10-18T21:14:38.922Z" },
+ { url = "https://files.pythonhosted.org/packages/10/d0/3d41544018b879e9c608c32bd5fb468fd87669d53c2dbf5ae67c1bad9882/jsonpointer-3.4-py3-none-any.whl", hash = "sha256:136e2e3273fd82a1502274f56ec172f331b6a1703d86a1824d503af4d9a96142", size = 9047, upload-time = "2026-10-19T10:33:22.963Z" },
]
[[package]]
@@ -2211,11 +2211,11 @@ wheels = [
[[package]]
name = "markdown"
-version = "3.11.1"
+version = "3.11.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4e/d4/f3f4b6ed70b7c7608fa026ff3bbe59ace9b1ebca43d8ae4886c87c95e81d/markdown-3.11.1.tar.gz", hash = "sha256:496f4f80f9ebd3395a04c8ec9595c40bbe8ec19e9c67d21fe071a1643e876606", size = 492927, upload-time = "2026-10-13T19:29:13.343Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/32/0b4d0297064244f04af4331b65de16237cc3cce07474171ee14222a28981/markdown-3.11.2.tar.gz", hash = "sha256:87bf5d90f2d2882e2c02fcda182c2800456b985bff38913867c49706481514b0", size = 491445, upload-time = "2026-10-19T19:41:42.687Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/75/e6/1c7b7a48aa3f2c2a5d3c71a6c9c90a6c8c2903e5c73663b5f5e38f87257f/markdown-3.11.1-py3-none-any.whl", hash = "sha256:f1fa378ba5d682900c9ecb55ccceacca936016dda7c3b27097e8ae03ff78feb5", size = 116774, upload-time = "2026-10-13T19:29:12.066Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/66/bcab3b677b71ad286b4e607e3427499537c76e13ad2e22584ba7a7d9882c/markdown-3.11.2-py3-none-any.whl", hash = "sha256:f80390747a2a0ff2ba54da074ce4f862f72a6685f608cd2637b646a6d83993f5", size = 114266, upload-time = "2026-10-19T19:41:41.244Z" },
]
[[package]]
@@ -3345,11 +3345,11 @@ wheels = [
[[package]]
name = "pyrate-limiter"
-version = "4.5.0"
+version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/62/43/48693393af06b9fffbaea6bb8fe03be3c3f17be5d1423dab347d1aad1dde/pyrate_limiter-4.5.0.tar.gz", hash = "sha256:098345fff3a52b84dee9bcf6973f184c8b3ef8d34e1f4f781ac0773e3984598b", size = 123240, upload-time = "2026-08-30T10:35:39.272Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/0b/f69f1614940f29d51e31d4fe8de69b2b4d411094ba3c77f50da34dfb63c6/pyrate_limiter-4.5.1.tar.gz", hash = "sha256:7f03c6a9235b00a4ba3c7b1e8cb6b23fbacc9d40b09f326220917a12ef22cc09", size = 129966, upload-time = "2026-10-20T13:51:27.935Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/64/dc/02f88649feeabf4282c8c194a6f6060ca858041bab1204365b5d8ef8a95e/pyrate_limiter-4.5.0-py3-none-any.whl", hash = "sha256:e7320a715adf404cb7b2f399e05ceb59cae165275b21f7708eb1b9fd9608e91c", size = 57188, upload-time = "2026-08-30T10:35:38.069Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/72/bf7279ae5799e1b54cf137859c17755446b2d2b6194606bda41753a43f76/pyrate_limiter-4.5.1-py3-none-any.whl", hash = "sha256:bb8dd2926b0679e4ada6629a865e85daa5599268f930c61a8992dafa8bf3ba0d", size = 57873, upload-time = "2026-10-20T13:51:26.584Z" },
]
[[package]]
@@ -4352,27 +4352,27 @@ wheels = [
[[package]]
name = "ty"
-version = "0.0.89"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz", hash = "sha256:bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4", size = 7978241, upload-time = "2026-10-16T10:35:05.249Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/84/e199d9a3dc3c94c804ddd9e94b63dbe543ae7f39329574963b6a6a95872f/ty-0.0.89-py3-none-linux_armv6l.whl", hash = "sha256:739b087e8873e177ffda99ff8a3600a4a7704d06523a8e88c4493021eee40b8a", size = 14254879, upload-time = "2026-10-16T10:34:24.813Z" },
- { url = "https://files.pythonhosted.org/packages/e5/d5/5afe09bf5c83c4e3a9e5913386a460e240a7029f2ae5fc96df5b43563a91/ty-0.0.89-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:47ebc98480c8a301955e18a8bdd67ef07090071ad47349b3254f325d3d5ab4e7", size = 13790417, upload-time = "2026-10-16T10:34:27.672Z" },
- { url = "https://files.pythonhosted.org/packages/7e/f9/88648f0ba9400dfd642c1e8dc0aeecb87945b121f3ff51f3902883c016d6/ty-0.0.89-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0631291af5c850a15007c0ffb906d813966466e4ec12173c44977c0c5a2d51d", size = 12856900, upload-time = "2026-10-16T10:34:29.871Z" },
- { url = "https://files.pythonhosted.org/packages/6a/89/baae2b407101c4cb02f19216123900c147af8fd87cdb1ed7eba139472734/ty-0.0.89-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d768300ae16274ce656006e9199869ad68684b74400d963e1c82deddf3a22ad", size = 13653330, upload-time = "2026-10-16T10:34:32.296Z" },
- { url = "https://files.pythonhosted.org/packages/ed/1d/293516c2e10a77134cfc40d22c3fc08edd8c5eadae9c75531ec8a9263348/ty-0.0.89-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dd4c57ba58bab8f6c816b2c254db52b58834b5dbf8268287382f7a2f9735c8f", size = 13986215, upload-time = "2026-10-16T10:34:34.506Z" },
- { url = "https://files.pythonhosted.org/packages/2f/c3/da911c8fc22d2664692432570619576b25c7cd0a586401cab08987763094/ty-0.0.89-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1af774c24117d366b69142d4f35edee518617f8b926d13560d0334cbe5aa3f85", size = 14905008, upload-time = "2026-10-16T10:34:36.632Z" },
- { url = "https://files.pythonhosted.org/packages/5c/c3/aed8d920b9bbf7b582e9fc0305acb2f2c748ede788e7dba9d6952e939c78/ty-0.0.89-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8147811b9b3bc2dc9338cd01c7893ac931fc3fae7aace169047ecbd627e6628", size = 15278217, upload-time = "2026-10-16T10:34:39.144Z" },
- { url = "https://files.pythonhosted.org/packages/96/92/c6b4320f08fca44ffe279e5ddeb3eae12869c6341f596931accdefea1182/ty-0.0.89-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d5f178c91d50fb62461b5e579028b7d2e92872e1d8f02e96ace26464c2277a6", size = 15206289, upload-time = "2026-10-16T10:34:41.626Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc", size = 14510395, upload-time = "2026-10-16T10:34:44.063Z" },
- { url = "https://files.pythonhosted.org/packages/f0/32/5f9ce2377a77ad9ff36d8996edf86b1f069c1e8716e37c7d6d668402b110/ty-0.0.89-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a70ab1a402746d7837407dc169e461fdf90945bc1eef0d3c0f1be543490060bf", size = 14767366, upload-time = "2026-10-16T10:34:46.154Z" },
- { url = "https://files.pythonhosted.org/packages/63/72/2b28c71d6ebd97b5bb1378f1806598c5dd90278f7b99a431dd3879dddcfc/ty-0.0.89-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b55c28a49bd2676eac9b3540a1dcc4cc053e4671c65dcec9fdad3dfe55fb148c", size = 13670284, upload-time = "2026-10-16T10:34:48.225Z" },
- { url = "https://files.pythonhosted.org/packages/34/cc/68b8ebf98c9bc582fddc3d2b93f6e0f53719d1ddee1c5037dfa2e1cb8467/ty-0.0.89-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c9f22edeb8176f8ab189337e8cbd8f92b66744534a2886df9e252b52ade991c", size = 13997891, upload-time = "2026-10-16T10:34:50.556Z" },
- { url = "https://files.pythonhosted.org/packages/cf/4d/a1d61e06092fd5726f48a8711bd32eac6af132308eb222a0027caba4f42d/ty-0.0.89-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c186185cad57a1469b851096b51345de7b625e353c1f4acd338402daed22fa7", size = 14326247, upload-time = "2026-10-16T10:34:53.005Z" },
- { url = "https://files.pythonhosted.org/packages/f8/d2/466d3762e330504a2c114dbbd0f0383229f9652dccd56277dde008bfe0da/ty-0.0.89-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5e38302ac218d2404758241093b5542ed0345d4f569096589f1810034244e998", size = 14661527, upload-time = "2026-10-16T10:34:55.555Z" },
- { url = "https://files.pythonhosted.org/packages/b1/d5/dfd7ed5e4af472b20a11ef651da4bca55f9dc8be485cd152337f94caddde/ty-0.0.89-py3-none-win32.whl", hash = "sha256:0146637ed2321717280bfe4e4b20a18813b741da280e8039d7df2bf22b0251f1", size = 13224078, upload-time = "2026-10-16T10:34:57.735Z" },
- { url = "https://files.pythonhosted.org/packages/b4/14/1ee663c422e3a2d910c5cc27e7d84bd7709a3df0aca28a713843c6c3fa5d/ty-0.0.89-py3-none-win_amd64.whl", hash = "sha256:9fc56322a1aff29a19088d2025f3c42a6d25a7035668048bb908c9f8b66f480d", size = 13727433, upload-time = "2026-10-16T10:35:00.15Z" },
- { url = "https://files.pythonhosted.org/packages/09/93/af8f7250920823ca6d582bcf0abf803fb92c8b99415ba7f40f2304102c6f/ty-0.0.89-py3-none-win_arm64.whl", hash = "sha256:1d4967c8f7bfd67c9708115b41d7049197c5d20c76fb2e136fe78b0437ada05b", size = 13287101, upload-time = "2026-10-16T10:35:02.918Z" },
+version = "0.0.90"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/b1/613b5534624442a9118828d7a9e54d50b16f0f5c8b67c80eb6623f33b921/ty-0.0.90.tar.gz", hash = "sha256:25cef63ec07a0254c2f5d885a63b001d0bfa437b20caa0a408f4f52e66b133e7", size = 7996206, upload-time = "2026-10-20T14:25:19.477Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/33/0e3395afa2c31c0e8e91547629858d2a5de213c69af237e6e50f37545565/ty-0.0.90-py3-none-linux_armv6l.whl", hash = "sha256:0dedb54977b40bc86638fec2c90eefd593f3a6683003a86b7e6cb67c17bcc476", size = 14281945, upload-time = "2026-10-20T14:24:21.018Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/fd/5d632b2f88d5c18991a4936da8643c7f204cfbc4980f8c7cd9080af988ad/ty-0.0.90-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3d7c30ceaac76dfbfcf36b72d36272d21c3898e98fc4de296aa9cbae956dec9d", size = 13786355, upload-time = "2026-10-20T14:24:24.392Z" },
+ { url = "https://files.pythonhosted.org/packages/79/08/0d2f37bb4f766ab9630f01b07f4d11f1f7a6a6188f920d5ca9fa8a4676b1/ty-0.0.90-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c0b7e4d9ae0939294f0a18652de9ad7d81da2d7f567cf93f1edda1263008c124", size = 12856535, upload-time = "2026-10-20T14:24:27.386Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/77/70658f6b80c345ae0c12f2192a5cc89b8ca13eccfe06616b0fac3806c261/ty-0.0.90-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c4a095a86a132df374e652894fe08392aeb1601d6347e1eb4b58808094a302", size = 13696342, upload-time = "2026-10-20T14:24:30.413Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3e/693049f93653d2f6412bb0c2c0af1d01675e31643af29bef7177d3ff7e79/ty-0.0.90-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e24a8aa07c05d9d14648289e79a88b7c7c2c522c58b53bb5f466a6ba4704e22", size = 14014180, upload-time = "2026-10-20T14:24:33.783Z" },
+ { url = "https://files.pythonhosted.org/packages/27/08/19e373f97501eaadc8fc42e8c07cd54a64e375fbcf1470797be13590d5ba/ty-0.0.90-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40c3ea8fc1dfcf7502f71b93b12e80da1f7a8a5fd7e55ec9feabcf4ac39f72ea", size = 14935189, upload-time = "2026-10-20T14:24:37.459Z" },
+ { url = "https://files.pythonhosted.org/packages/33/54/3331984f2c896a7383b6b18c86e968a63dd046baf503119a8e8e7682c752/ty-0.0.90-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1af75532bbe3e305089c30806fa5e804f8b01de231c1fa07918f7f3b335626da", size = 15311482, upload-time = "2026-10-20T14:24:40.87Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f3/179acf8d730c2c864767ffcc9081f5ea6fdb1b34ef711f1bd226200e9cff/ty-0.0.90-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f09449b34d837a044dab043aeb2b4b6ff2d2cef5eaf8116c7c19d34cc2a16a21", size = 15278844, upload-time = "2026-10-20T14:24:44.539Z" },
+ { url = "https://files.pythonhosted.org/packages/71/7e/661bc2fa20e0ac84c4d5f92e4c098ec667a0e920851a6d10c10a0251f0dc/ty-0.0.90-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc868ffa7dfcb3169eea3ba7d634e5a3283c7133fb932c6e47a2e16620d26ca5", size = 14558774, upload-time = "2026-10-20T14:24:47.814Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/2e/3f65f61f4545f75255373be7646a606e004bb4cdc851e236bf17ca94bdc2/ty-0.0.90-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e2405206533dc896a2b711e781f2955d5883d85536f4df0ac262fca6517103c2", size = 14788158, upload-time = "2026-10-20T14:24:51.223Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/10/3c91a529591667b89d63c86806bfe2ac56d6989c7e0a09ee0964b7512deb/ty-0.0.90-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0dca4a29e8778285d2fc204af19254571486106fd14aed60769bfd272c795f6f", size = 13687177, upload-time = "2026-10-20T14:24:54.611Z" },
+ { url = "https://files.pythonhosted.org/packages/21/b2/1b6d48606c67614bfb0c8e61a5d67a053ca1e3c5956ddb584f8569b8dc3d/ty-0.0.90-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d004b8e4e898fead929eceea669b73936444671748e0dd44e624cc109346c0dd", size = 14027780, upload-time = "2026-10-20T14:24:59.045Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c4/a0771debf725312524ca1434f8a146d37e573d45257ca4bebb9cc8e35bbc/ty-0.0.90-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6be182da4281b4581c4a1b27c6a0caebeed7212e4bdc98c6b8fe0d9bc36dea0", size = 14355890, upload-time = "2026-10-20T14:25:03.034Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/4c/036724b6936ffc38e28ff5e20ab7972d609c65a3dd9645f3d10f57cfd16f/ty-0.0.90-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ba6fe0a24ff4cbc5034f3aa1c88b3ad1397315dd3369357e694fd157a11864e", size = 14692306, upload-time = "2026-10-20T14:25:07.461Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2a/c74fd5fc3fb5b046fe06a9067cabf5417b73e28249dc9af90ef72700f8fd/ty-0.0.90-py3-none-win32.whl", hash = "sha256:4c46a79caf84c4da0f7293e16bd769bd467c7cbc38015cac7608ab4e5ec8ba9c", size = 13235434, upload-time = "2026-10-20T14:25:10.545Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/54/be5cd6b17d8087a5e4c26892311c07e39d42b66c925b0f06cfc51a9ce83b/ty-0.0.90-py3-none-win_amd64.whl", hash = "sha256:8d77c16d20201b7a07ccaafb610eaf60488783dbba9865c4f9182e584f1426f8", size = 13855668, upload-time = "2026-10-20T14:25:13.648Z" },
+ { url = "https://files.pythonhosted.org/packages/33/68/6dea089a897e8b314504eecc2633de3c8fe14f70e71a35606d14a2ba169e/ty-0.0.90-py3-none-win_arm64.whl", hash = "sha256:bfb63e74eeedb0a29d49cf58d611c540bfdfc0975dfce26efc6575bcf439208d", size = 13330853, upload-time = "2026-10-20T14:25:16.642Z" },
]
[[package]]
@@ -4401,11 +4401,11 @@ wheels = [
[[package]]
name = "types-python-dateutil"
-version = "2.9.0.20260807"
+version = "2.9.0.20261021"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c8/4e/b3fa538f9cb38dfece0d6ccf6d3d0d925bdedb144fb9c8129dfc007cd003/types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034", size = 17618, upload-time = "2026-08-07T04:17:13.491Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/66/4783d826afabb22f29f47b22514298efaedee4718eac0a76c37bea381a07/types_python_dateutil-2.9.0.20261021.tar.gz", hash = "sha256:62d66fcdd293e38745eae644e5da4cb399b98799c0ae61f03a54896fb51b3140", size = 17792, upload-time = "2026-10-21T06:42:00.127Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e4/5e/3715867caea2f4cea56ccb04c851cde23ed063449c3b004c7a047f20dd48/types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db", size = 18486, upload-time = "2026-08-07T04:17:12.504Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/06/acccde409bf5719a80d6653659fb23aad1784095348df4ffd0212510a6d5/types_python_dateutil-2.9.0.20261021-py3-none-any.whl", hash = "sha256:12aab3bdfe55e1d9de7f54301e025d6276ce17e6a08e31644f2202d59c8d1fca", size = 18509, upload-time = "2026-10-21T06:41:58.922Z" },
]
[[package]]
@@ -4575,38 +4575,38 @@ wheels = [
[[package]]
name = "wcwidth"
-version = "0.9.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", size = 955039, upload-time = "2026-10-05T00:24:05.521Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", size = 605343, upload-time = "2026-10-05T00:23:22.649Z" },
- { url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", size = 609960, upload-time = "2026-10-05T00:23:27.049Z" },
- { url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", size = 762566, upload-time = "2026-10-05T00:23:38.322Z" },
- { url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", size = 771632, upload-time = "2026-10-05T00:23:32.448Z" },
- { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", size = 769763, upload-time = "2026-10-05T00:23:40.175Z" },
- { url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", size = 782134, upload-time = "2026-10-05T00:23:33.926Z" },
- { url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", size = 782851, upload-time = "2026-10-05T00:23:41.974Z" },
- { url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", size = 784621, upload-time = "2026-10-05T00:23:43.449Z" },
- { url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", size = 593123, upload-time = "2026-10-05T00:23:55.953Z" },
- { url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", size = 597827, upload-time = "2026-10-05T00:23:57.489Z" },
- { url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", size = 598415, upload-time = "2026-10-05T00:23:51.517Z" },
- { url = "https://files.pythonhosted.org/packages/4e/41/549eef1ab767032bdbdc1f0ab655d404b082b1e9a1dab1361dbba90f64ed/wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", size = 606555, upload-time = "2026-10-05T00:23:24.188Z" },
- { url = "https://files.pythonhosted.org/packages/9b/64/a875ed7ea71cacadc0ae11b5fd3fac3486efd58bb25e67a7344248dceadd/wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", size = 610829, upload-time = "2026-10-05T00:23:28.563Z" },
- { url = "https://files.pythonhosted.org/packages/c6/98/513095e484fe79b6f2613d6a72f855f5d56b65e15c215c2a6746fbc638f5/wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", size = 775819, upload-time = "2026-10-05T00:23:45.116Z" },
- { url = "https://files.pythonhosted.org/packages/22/fc/c02f3eec57224731e78f84b68e272250f784b6205acc7e0dcef6a7c23a0e/wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", size = 786915, upload-time = "2026-10-05T00:23:35.323Z" },
- { url = "https://files.pythonhosted.org/packages/3d/6f/b0529a79bac3fe8d94f32b4237a13dbc3f955508753f6a6f06c73d679dc2/wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", size = 784186, upload-time = "2026-10-05T00:23:46.366Z" },
- { url = "https://files.pythonhosted.org/packages/d5/bd/6357c84ca9a734bfc735b7c48dbe21336b3777fab8a4101d14976dfe49a7/wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", size = 600791, upload-time = "2026-10-05T00:23:59.398Z" },
- { url = "https://files.pythonhosted.org/packages/98/de/037591ca18d897cc2179559dde72e6efc6ce0c90e9cd1e6bca4e87c38b4b/wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f", size = 605463, upload-time = "2026-10-05T00:24:01.049Z" },
- { url = "https://files.pythonhosted.org/packages/d0/07/c9d96e106d938d26f7ab639bc80b8199359a1645ba6e3498413313ab6f38/wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", size = 605936, upload-time = "2026-10-05T00:23:52.765Z" },
- { url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", size = 606575, upload-time = "2026-10-05T00:23:25.663Z" },
- { url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", size = 611045, upload-time = "2026-10-05T00:23:30.421Z" },
- { url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", size = 778143, upload-time = "2026-10-05T00:23:47.7Z" },
- { url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", size = 786630, upload-time = "2026-10-05T00:23:36.967Z" },
- { url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", size = 784839, upload-time = "2026-10-05T00:23:49.88Z" },
- { url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", size = 600845, upload-time = "2026-10-05T00:24:02.497Z" },
- { url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", size = 605441, upload-time = "2026-10-05T00:24:04.052Z" },
- { url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", size = 605885, upload-time = "2026-10-05T00:23:54.274Z" },
- { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", size = 301667, upload-time = "2026-10-05T00:23:21.097Z" },
+version = "0.9.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/4a/82d61476a1ccc20d43d0805724c5de4baa4971131a8db1816705ef53fe16/wcwidth-0.9.3.tar.gz", hash = "sha256:dfe7cfbd4cb59db9fe0931bd6091ab4a95b3acc5d56bfb50fa9fef0ccba5dc5c", size = 956320, upload-time = "2026-10-20T16:02:43.488Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/6d/eec61b5edeb88e3ea2a714184eade38335722c4246d94be567977fc20caa/wcwidth-0.9.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3b119ef60177a92b4f298b80e98464c2922a1fba3ed77fe9a2bcb69edf1b9af4", size = 605400, upload-time = "2026-10-20T16:02:02.523Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3c/714eba03905a23a46277cc96d3db8da6bd22d2cc0d09a4dead60a2bd1757/wcwidth-0.9.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aab90ab489850f87633922c3064f6d0442d8f6ae5407a62f82bdb1f424179c05", size = 610124, upload-time = "2026-10-20T16:02:07.256Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ad/8566e7cc712bea55eab0bb6d19581b7c727a58fcdd55f02365b309aaaccd/wcwidth-0.9.3-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e1cbe18c18d152e1e68e4e2561dab6c2bc988f8fd337a1fb2e1e0809c3fa568", size = 762700, upload-time = "2026-10-20T16:02:17.508Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/7a/4e26574d032a1709bb1fd7d95d406b7b8f87363c60305a101e6e2d9285f5/wcwidth-0.9.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c12abd75ffcbad417e7770a6feb82413c102ccce5c435068c39a82e2218b00b3", size = 771850, upload-time = "2026-10-20T16:02:11.553Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d6/48dad66d577417ef8594000506fa8619d45d7282e20b4cdc916fcc68f95a/wcwidth-0.9.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ab8bfee107e08de09e15f9f6afb9fe0f64c8d64fd296d0d8557bafe71ea3f93", size = 770047, upload-time = "2026-10-20T16:02:19.092Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/61/5f1e02e588d289837d5312478cc9f0e7bca93f4c8a77807741f79fb6a31c/wcwidth-0.9.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f875a4c9e62df2ecfa70d99b24c551f2245609858e179c3a40e74631b2d08a4", size = 782210, upload-time = "2026-10-20T16:02:12.753Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/11/0da2720cba3f917148448c9bf73f3079d71f918308ee9e4d737a0b7acfd8/wcwidth-0.9.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:70a2ca239b85a219324968993c76c117fe423d4e1a7c9d860c999c66192f2570", size = 783172, upload-time = "2026-10-20T16:02:20.663Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/b79407036ca4543bf708a6d4ca561f78620dc1e5443e685e094480c37753/wcwidth-0.9.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:537ea00b6792bb243cd09582322c54dbc848879e5d34ea4c5fee0147529685ab", size = 784806, upload-time = "2026-10-20T16:02:22.164Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/86/ada6c2244b08a86c871d42f7cd85b5226515b5f3fa7e604d1e6e45590275/wcwidth-0.9.3-cp310-abi3-win32.whl", hash = "sha256:f2f943c4ef14e9baba730740265f4e5bf5fc8473550e36cd92828cd5663561a6", size = 593154, upload-time = "2026-10-20T16:02:34.301Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/6e/46363d972b2b9f002fbfc3bf8e9ab0ff89f139c935a5f21d22016e7a2018/wcwidth-0.9.3-cp310-abi3-win_amd64.whl", hash = "sha256:569a0cdb826d3097b6dbacfaa45aa0002d72a2ba6fa8a63251ea43ac4dd7b6a4", size = 597685, upload-time = "2026-10-20T16:02:35.748Z" },
+ { url = "https://files.pythonhosted.org/packages/69/3d/5583cac85e233514c3906d5476b6a74160bc4dc82729dabef3485dbf3d52/wcwidth-0.9.3-cp310-abi3-win_arm64.whl", hash = "sha256:80557db1f77148b78e1b3c2a9cc4ebc4feabef42aaf69a555565057031c32367", size = 598774, upload-time = "2026-10-20T16:02:30.016Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b2/53e0838babc6b87505b9504ce62b72c79403642f88d3e568e05d0ef04825/wcwidth-0.9.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6f3e855d3a09af8b7b6d56e76bc270681032f8a37d527a8d5707a441958e3730", size = 606640, upload-time = "2026-10-20T16:02:04.01Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/fb/27c94facff5c4a39b954a41fac20c238647f0aad32452d0d9e33fc05e852/wcwidth-0.9.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1bbc8ebf133422cadfefea95a9f89d6c9e9a1ae19930bef7862a9b59b20bbad", size = 610956, upload-time = "2026-10-20T16:02:08.764Z" },
+ { url = "https://files.pythonhosted.org/packages/38/61/38e6236356d25966d34636ef6f49ee83a66513e051c4a3b1e19c8b9ec806/wcwidth-0.9.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7a95d8c784cbccbbcb808eaadfd156cd282b2bdd95e89b32505e3443381ce61d", size = 775967, upload-time = "2026-10-20T16:02:23.765Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/87/c8f9295cd14a058dc27be0360fcc5ec70ce71b0d2e6a018871f585f23126/wcwidth-0.9.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a44acbb23b525070a0b8c343dd7cab7a4020aff73b38804f7a66d6a66e16e3fc", size = 787147, upload-time = "2026-10-20T16:02:14.095Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/c0/238c135e283b1b0e16fef8b082d432dda1fe7e568d6dd85d5c7fc5e83859/wcwidth-0.9.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a7c0405a3bf3c564e481d6b2d55c99cc231bb68cf8f4a0995944bbce5ebf1a5d", size = 784548, upload-time = "2026-10-20T16:02:25.189Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ed/33e3080fe32f6f8f86334b2c5adde243b9a3a3a46cef97694dbed73b9dc4/wcwidth-0.9.3-cp314-cp314t-win32.whl", hash = "sha256:457f986e4867111cb037ccbf0d97ae100b1ecad030d43a020e7207f1e29649d5", size = 600761, upload-time = "2026-10-20T16:02:37.097Z" },
+ { url = "https://files.pythonhosted.org/packages/27/62/6684a59147c1f86d58d0138f3109f506a6761b815dcb3d86b74d9af3a723/wcwidth-0.9.3-cp314-cp314t-win_amd64.whl", hash = "sha256:f11789e520ade8017fa93b01a4b8985571bb433eaefc2f857b57e36875e2e1d8", size = 605316, upload-time = "2026-10-20T16:02:38.513Z" },
+ { url = "https://files.pythonhosted.org/packages/94/24/3ebcfd772d273e43e28447dd1946d88b8625db57b6a23496ea4eb9e1dbb7/wcwidth-0.9.3-cp314-cp314t-win_arm64.whl", hash = "sha256:eef39f3260991d8767189d296f0116fa4b45ad9c3a9a16d5af9187fb66b27564", size = 606392, upload-time = "2026-10-20T16:02:31.333Z" },
+ { url = "https://files.pythonhosted.org/packages/76/fe/f90b9444bbc2d566634f91756bd11c2bd39d50c2c9077551711b10d8d03f/wcwidth-0.9.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:52a13869704d43a4add59c68ebc892198e53063883114783bf0b7457e250e3f4", size = 606655, upload-time = "2026-10-20T16:02:05.816Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/63/edffb23938f4e27c28400c7d8d5a8715381d77f5db96bf80cf5dd6687f88/wcwidth-0.9.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9470169fcb4e5340269175ebb3642dd5bfc8f82da8c86f29fc1fefc6c2b9bc1b", size = 611217, upload-time = "2026-10-20T16:02:10.252Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e6/0e4b5c81c238fac47eb66902f058a39ccdeacd7b2c5ab0c559066f203262/wcwidth-0.9.3-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c4531d93344441a7948e665854f0c40e3a8342ee106436d1531efa7a9fa7641e", size = 778155, upload-time = "2026-10-20T16:02:27.141Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/16/745439326544b2a6d65facbdf8462ee9d31e09c64830a764d2526c6c80d6/wcwidth-0.9.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:658dcbce546e7f28ee361f88e4a8e591dd56f46bff6084e11b4ad8310ed99946", size = 786757, upload-time = "2026-10-20T16:02:15.569Z" },
+ { url = "https://files.pythonhosted.org/packages/08/a4/47193dc9902c30e187d81feaa7a5bc2672c41fe07093ca1ce5381395b85c/wcwidth-0.9.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9362be098c43d076aa43754face3cc0180fa81cea9ba304240b15490f2617e8", size = 785088, upload-time = "2026-10-20T16:02:28.784Z" },
+ { url = "https://files.pythonhosted.org/packages/11/6a/9727002b938c28ab4456fc43ba31a3159ebff040021ce8dbd70e904c7cc9/wcwidth-0.9.3-cp315-cp315t-win32.whl", hash = "sha256:16d09df90b3a8f799ea14c726a41d9b906c3104f95f46d158e1ebe45cf4d547b", size = 600787, upload-time = "2026-10-20T16:02:39.766Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/db/0e24c2ff1e82cf298dd40a29e022da31b79be2589f0b8d865488e49140e1/wcwidth-0.9.3-cp315-cp315t-win_amd64.whl", hash = "sha256:3e3e31d6c17d6da3051f7eb854d5e4bebdc52a08a7a52adc2da6f15af51304e8", size = 605298, upload-time = "2026-10-20T16:02:41.479Z" },
+ { url = "https://files.pythonhosted.org/packages/84/71/b470bd1dec14cbe6cee0c2e142787a8d99ac01711d5a1b533dda90754b48/wcwidth-0.9.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5d3b0b6c37f40e8d8298941ec373a426a892bb9a514328abcc9226cdc60ae6eb", size = 606346, upload-time = "2026-10-20T16:02:32.662Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/12/e10aad98050389ef674c9f69409e38ae086c2104912644e1f9143b394301/wcwidth-0.9.3-py3-none-any.whl", hash = "sha256:e3c9069c54a2da0bf4f6c7e65a735cb1da0f47601a15cb5fef7e4b6ffca4cf60", size = 301629, upload-time = "2026-10-20T16:02:00.861Z" },
]
[[package]]