From 723d5494a1b07b9a4587ff9f8964988a991c282f Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Thu, 29 Oct 2026 14:40:17 -0700 Subject: [PATCH 1/9] feat(frontend): register Vuetify labs components Auto-import the labs components (VCheckboxGroup, VSplitBtn). A checkbox group passes its own color to its boxes even when unset, so give groups the primary color checkboxes already default to, and line groups up with a lone checkbox: drop the labs CSS's 16px label and 6px box indents and space inline boxes apart. Co-Authored-By: Claude Opus 5.5 --- frontend/src/plugins/vuetify.js | 4 ++++ frontend/src/styles/global.scss | 20 ++++++++++++++++++++ frontend/vite.config.js | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/src/plugins/vuetify.js b/frontend/src/plugins/vuetify.js index 0792ecbe6..e12ef182f 100644 --- a/frontend/src/plugins/vuetify.js +++ b/frontend/src/plugins/vuetify.js @@ -67,6 +67,10 @@ const vuetify = createVuetify({ VCheckboxBtn: { color: "primary", }, + // A group hands its own color to its checkboxes, unset or not. + VCheckboxGroup: { + color: "primary", + }, VCombobox: { color: "primary", }, diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index 9b61ff38a..746d604b9 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -54,6 +54,26 @@ color: rgb(var(--v-theme-text-disabled)); } + /* + * A checkbox group lines up with a lone checkbox. Vuetify's labs CSS + * indents the group's label 16px and its boxes 6px. Inline boxes get + * room between one label and the next box. + */ + .v-checkbox-group > .v-input__control > .v-label { + margin-inline-start: 0; + } + + .v-checkbox-group + > .v-input__control + > .v-label + + .v-selection-control-group { + padding-inline-start: 0; + } + + .v-checkbox-group .v-selection-control-group--inline { + column-gap: 16px; + } + /* * Sub-header inside the settings drawers. Shared by six components * across the browser and reader drawers. diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9743dea60..f09386c00 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -206,7 +206,7 @@ const config = defineConfig(({ mode }) => { define: defineObj, plugins: [ vue(), - vuetify({ autoImport: true }), + vuetify({ autoImport: { labs: true } }), // Lint runs ESLint over the frontend; vitest needn't run it again. !IS_TEST_ENV && checker({ From cb9bd93de0a37893c262b16913226b796fa9dd35 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Thu, 29 Oct 2026 14:40:17 -0700 Subject: [PATCH 2/9] feat(choices): derive the tag write formats Add the write formats, titled by comicbox, to tagging-choices.json so the tag editor and the admin Tagging tab stop hand-writing the list. Co-Authored-By: Claude Opus 5.5 --- codex/choices/choices_to_json.py | 7 ++++++- codex/choices/tagging.py | 11 +++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/codex/choices/choices_to_json.py b/codex/choices/choices_to_json.py index 775545013..a25652e41 100755 --- a/codex/choices/choices_to_json.py +++ b/codex/choices/choices_to_json.py @@ -34,6 +34,7 @@ IDENTIFIER_TYPES, LANGUAGES, SERIES_STATUSES, + WRITE_FORMATS, ) _DEFAULTS = MappingProxyType( @@ -59,7 +60,11 @@ "admin-status-titles.json": ADMIN_STATUS_TITLES, "browser-choices.json": BROWSER_CHOICES, "reader-choices.json": READER_CHOICES, - "tagging-choices.json": TAGGING_CHOICES, + # The write formats live with the rest of the tag-editor choices, + # which choices.admin can't import without a models import cycle. + "tagging-choices.json": MappingProxyType( + {**TAGGING_CHOICES, "formats": WRITE_FORMATS} + ), } ) diff --git a/codex/choices/tagging.py b/codex/choices/tagging.py index 4fc5aae31..55475735a 100644 --- a/codex/choices/tagging.py +++ b/codex/choices/tagging.py @@ -23,6 +23,7 @@ from comicbox.enums.generic import GenericFormatEnum from comicbox.enums.maps.age_rating import COMICINFO_AGE_RATING_MAP from comicbox.enums.metroninfo import MetronAgeRatingEnum, MetronFormatEnum +from comicbox.formats import MetadataFormats from comicbox.formats.comic_info.transform import ComicInfoTransform from comicbox.formats.metron_info.transform import MetronInfoTransform @@ -30,14 +31,20 @@ from codex.models.choices import SeriesStatusChoices from codex.models.identifier import IdentifierType, to_comicbox_id_type -# comicbox transform per codex write-format id. +# comicbox transform per codex write-format id, in the order the editors +# offer them. _TRANSFORMS = MappingProxyType( { - "COMIC_INFO": ComicInfoTransform, "METRON_INFO": MetronInfoTransform, + "COMIC_INFO": ComicInfoTransform, } ) +# The write formats the editors offer, titled by comicbox (id -> title). +WRITE_FORMATS = MappingProxyType( + {fmt: MetadataFormats[fmt].value.label for fmt in _TRANSFORMS} +) + def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType, ...]: """Build a tuple of read-only ``{title, value}`` vuetify choices.""" From f7d3cb7274447b103816fc5f14dcb1403472081a Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Thu, 29 Oct 2026 14:40:17 -0700 Subject: [PATCH 3/9] fix(admin): require at least one default tag write format comicbox refuses a write with no formats, so an empty default made every file in an online-tagging session fail. Reject an empty list, a non-list and unknown format ids. Co-Authored-By: Claude Opus 5.5 --- codex/serializers/admin/tagging.py | 24 ++++++++++++++++++++++++ tests/test_admin_singleton_settings.py | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index 8880d4e71..0743681c6 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -13,6 +13,7 @@ ValidationError, ) +from codex.choices.tagging import WRITE_FORMATS from codex.librarian.onlinetag.credential_validator import KNOWN_SOURCES from codex.models import ComicboxTaggingDefaults from codex.serializers.models.base import BaseModelSerializer @@ -179,6 +180,29 @@ def get_has_comicvine_credentials(obj) -> bool: """Whether a Comic Vine API key is set.""" return bool(obj.comicvine_key) + @staticmethod + def validate_default_formats(value: object) -> list: + """ + Require at least one known write format. + + comicbox refuses a write with no formats, so an empty default would + fail every file in an online-tagging session. + """ + if not isinstance(value, list): + reason = "Expected a list of metadata formats." + raise ValidationError(reason) + formats = list(dict.fromkeys(value)) + if not formats: + reason = "At least one metadata format is required." + raise ValidationError(reason) + if unknown := [fmt for fmt in formats if fmt not in WRITE_FORMATS]: + reason = ( + f"Unknown metadata format(s): {', '.join(map(str, unknown))}. " + f"Known: {', '.join(WRITE_FORMATS)}." + ) + raise ValidationError(reason) + return formats + @staticmethod def validate_default_sources(value: list) -> list: """Require known source names; preserve the priority order.""" diff --git a/tests/test_admin_singleton_settings.py b/tests/test_admin_singleton_settings.py index 197f3d056..c5a405598 100644 --- a/tests/test_admin_singleton_settings.py +++ b/tests/test_admin_singleton_settings.py @@ -180,6 +180,29 @@ def test_tagging_defaults_rejects_unknown_source(self) -> None: ) assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content + def test_tagging_defaults_rejects_no_formats(self) -> None: + """An empty format list is a 400: comicbox can't write no formats.""" + resp = _put_envelope( + self.client, "/api/v4/admin/tagging-defaults", {"defaultFormats": []} + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content + + def test_tagging_defaults_rejects_unknown_format(self) -> None: + resp = _put_envelope( + self.client, + "/api/v4/admin/tagging-defaults", + {"defaultFormats": ["COMIC_INFO", "COMET"]}, + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content + + def test_tagging_defaults_rejects_non_list_formats(self) -> None: + resp = _put_envelope( + self.client, + "/api/v4/admin/tagging-defaults", + {"defaultFormats": "COMIC_INFO"}, + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.content + def test_throttle_settings_put(self) -> None: """PUT /admin/throttle-settings returns 200 with the saved row.""" resp = _put_envelope( From a2b0e1a5012490a30ce3d225a3329cdbe40e0aab Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Thu, 29 Oct 2026 14:40:17 -0700 Subject: [PATCH 4/9] feat(tag-editor): formats as a checkbox group Replace the Formats chip select with checkboxes. The last checked format is disabled and the group says why, which replaces enforceMinFormat and its undeclared lastFormats. The hint sits beside the boxes on a wide toolbar and under them, with the toolbar wrapping, on a narrow one. Co-Authored-By: Claude Opus 5.5 --- .../metadata/edit-mode/edit-panel.vue | 84 ++++++++++++++----- .../tests/unit/edit-panel-formats.test.js | 76 +++++++++++++++++ 2 files changed, 137 insertions(+), 23 deletions(-) create mode 100644 frontend/tests/unit/edit-panel-formats.test.js diff --git a/frontend/src/components/metadata/edit-mode/edit-panel.vue b/frontend/src/components/metadata/edit-mode/edit-panel.vue index 4e0c24374..984aebdbe 100644 --- a/frontend/src/components/metadata/edit-mode/edit-panel.vue +++ b/frontend/src/components/metadata/edit-mode/edit-panel.vue @@ -1,17 +1,24 @@
- - -