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.""" 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/frontend/src/components/admin/tabs/defaults-tab.vue b/frontend/src/components/admin/tabs/defaults-tab.vue index a3d325402..7a6396627 100644 --- a/frontend/src/components/admin/tabs/defaults-tab.vue +++ b/frontend/src/components/admin/tabs/defaults-tab.vue @@ -42,18 +42,23 @@
Show Collections
-
+ -
-

{{ lockedShowHint }}

+
this.draft.browser[key]) + .map(({ key }) => key); + }, + set(keys) { + // Flip only the one flag a click changed. + const changed = this.showChoices.find( + ({ key }) => keys.includes(key) !== this.shownKeys.includes(key), + ); + if (changed) { + this.draft.browser[changed.key] = keys.includes(changed.key); + } + }, + }, // The current top collection's box: unchecking it would hide it. lockedShowKey() { return SHOW_KEYS[this.draft.browser.topCollection]; @@ -562,12 +584,6 @@ export default { @use "@/components/admin/tabs/admin-section.scss"; @use "@/components/admin/tabs/design.scss" as d; -.defaultsCheckRow { - display: flex; - flex-wrap: wrap; - column-gap: d.$space-4; -} - .tableColumnsRow { display: flex; flex-wrap: wrap; diff --git a/frontend/src/components/admin/tabs/tagging-tab.vue b/frontend/src/components/admin/tabs/tagging-tab.vue index 7128f52d8..9f44c396b 100644 --- a/frontend/src/components/admin/tabs/tagging-tab.vue +++ b/frontend/src/components/admin/tabs/tagging-tab.vue @@ -14,35 +14,46 @@ fail.
- - - @@ -43,15 +49,31 @@ export default { computed: { ...mapState(useBrowserStore, { collectionChoices: (state) => - state.choices?.static?.settingsCollection || {}, + state.choices?.static?.settingsCollection || [], showSettings: (state) => state.settings?.show || {}, }), + shownCollections() { + return this.collectionChoices + .filter(({ value }) => this.showSettings[value]) + .map(({ value }) => value); + }, showShowSettings() { return SHOW_SETTINGS_COLLECTIONS.has(this.$route?.params?.collection); }, }, methods: { ...mapActions(useBrowserStore, ["setSettings"]), + onShownUpdate(shown) { + // The group reports the whole list; send only the flag a click flipped, + // since every settings change reloads the browser page. + const changed = this.collectionChoices.find( + ({ value }) => + shown.includes(value) !== this.shownCollections.includes(value), + ); + if (changed) { + this.setShow(changed.value, shown.includes(changed.value)); + } + }, setShow(collection, value) { const data = { show: { [collection]: value === true } }; this.setSettings(data); @@ -64,4 +86,19 @@ export default { padding-right: 10px; padding-left: 15px; } + +/* + * The sub-header is the group's label. Undo the label's dimming and its + * gap above the boxes so it and the checkboxes sit where they did before. + */ +@layer codex-components { + .browserCollectionGroup :deep(> .v-input__control > .v-label) { + opacity: 1; + } + + .browserCollectionGroup + :deep(> .v-input__control > .v-label + .v-selection-control-group) { + margin-block-start: 0; + } +} diff --git a/frontend/src/components/browser/table/browser-table-column-picker.vue b/frontend/src/components/browser/table/browser-table-column-picker.vue index a4a0579a2..20d9975da 100644 --- a/frontend/src/components/browser/table/browser-table-column-picker.vue +++ b/frontend/src/components/browser/table/browser-table-column-picker.vue @@ -47,19 +47,20 @@
Add Columns
-
-
{{ category.label }}
-
+
@@ -325,9 +326,6 @@ export default { topCollectionLabel() { return TOP_COLLECTION[this.topCollection] ?? this.topCollection; }, - selectedSet() { - return new Set(this.draft); - }, categoriesWithColumns() { const seen = new Set(); const categories = _CATEGORIES @@ -414,6 +412,22 @@ export default { this.draft = this.draft.filter((k) => k !== key); } }, + onCategoryUpdate(next) { + /* + * A checkbox group appends a checked key to the end of its model; + * route the one key a click changed through toggleColumn so it + * lands in canonical position instead. + */ + const added = next.find((key) => !this.draft.includes(key)); + if (added) { + this.toggleColumn(added, true); + return; + } + const removed = this.draft.find((key) => !next.includes(key)); + if (removed) { + this.toggleColumn(removed, false); + } + }, removeColumn(key) { this.draft = this.draft.filter((k) => k !== key); }, @@ -583,12 +597,24 @@ export default { margin-top: 12px; } -.pickerCategoryHeader { - font-size: 0.85em; - font-weight: 600; - text-transform: uppercase; - color: rgb(var(--v-theme-text-secondary)); - margin-bottom: 4px; +/* + * The category title is the checkbox group's label. Restyle it as the + * old section header, with no extra gap above the checkboxes under it. + */ +@layer codex-components { + .pickerCategory :deep(> .v-input__control > .v-label) { + margin-bottom: 4px; + font-size: 0.85em; + font-weight: 600; + text-transform: uppercase; + color: rgb(var(--v-theme-text-secondary)); + opacity: 1; + } + + .pickerCategory + :deep(> .v-input__control > .v-label + .v-selection-control-group) { + margin-block-start: 0; + } } /* 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 @@