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 }} +
+ +
+
+ + + +
+ +
+
+ + + +
+
+ + + +
+
+
+ + +
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]]