Skip to content
7 changes: 6 additions & 1 deletion codex/choices/choices_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
IDENTIFIER_TYPES,
LANGUAGES,
SERIES_STATUSES,
WRITE_FORMATS,
)

_DEFAULTS = MappingProxyType(
Expand All @@ -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}
),
}
)

Expand Down
11 changes: 9 additions & 2 deletions codex/choices/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,28 @@
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

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.
# 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."""
Expand Down
24 changes: 24 additions & 0 deletions codex/serializers/admin/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
40 changes: 28 additions & 12 deletions frontend/src/components/admin/tabs/defaults-tab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,23 @@
</div>
<div class="adminCard">
<div class="adminCardTitle">Show Collections</div>
<div class="defaultsCheckRow">
<v-checkbox-group
v-model="shownKeys"
aria-label="Show Collections"
:hint="lockedShowHint"
persistent-hint
hide-details="auto"
density="compact"
inline
>
<v-checkbox
v-for="item in showChoices"
:key="item.key"
v-model="draft.browser[item.key]"
:value="item.key"
:label="item.title"
:disabled="item.key === lockedShowKey"
density="compact"
hide-details
/>
</div>
<p v-if="lockedShowHint" class="adminHint">{{ lockedShowHint }}</p>
</v-checkbox-group>
</div>
<div class="adminCard">
<v-select
Expand Down Expand Up @@ -386,6 +391,23 @@ export default {
]),
);
},
// The show flags as the checkbox group's list of checked keys.
shownKeys: {
get() {
return this.showChoices
.filter(({ key }) => 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];
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 55 additions & 29 deletions frontend/src/components/admin/tabs/tagging-tab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,46 @@
fail.
</template>
<div class="adminCard">
<v-select
<v-checkbox-group
v-model="draft.defaultFormats"
:items="formatChoices"
label="Metadata Formats"
:hint="metadataFormatsHint"
persistent-hint
hide-details="auto"
:messages="formatsMessages"
:error-messages="formatsError"
density="compact"
multiple
chips
inline
>
<!-- Render the hint via the message slot so the docs links live
<v-checkbox
v-for="format in formatChoices"
:key="format.value"
:value="format.value"
:label="format.title"
:disabled="isLastFormat(format.value)"
/>
<!-- Render the docs hint via the message slot so its links live
inside the field's own hint, styled like every other. -->
<template #message>
These metadata formats are written into each comic every time
its tags are edited. Learn more about
<a
href="https://anansi-project.github.io/docs/category/comicinfo"
target="_blank"
>ComicInfo<v-icon size="small">{{ mdiOpenInNew }}</v-icon></a
>
and
<a
href="https://metron-project.github.io/docs/category/metroninfo"
target="_blank"
>MetronInfo<v-icon size="small">{{ mdiOpenInNew }}</v-icon></a
>.
<template #message="{ message }">
<template v-if="message === metadataFormatsHint">
These metadata formats are written into each comic every time
its tags are edited. Learn more about
<a
href="https://anansi-project.github.io/docs/category/comicinfo"
target="_blank"
>ComicInfo<v-icon size="small">{{
mdiOpenInNew
}}</v-icon></a
>
and
<a
href="https://metron-project.github.io/docs/category/metroninfo"
target="_blank"
>MetronInfo<v-icon size="small">{{
mdiOpenInNew
}}</v-icon></a
>.
</template>
<template v-else>{{ message }}</template>
</template>
</v-select>
</v-checkbox-group>
</div>
<div class="adminCard">
<v-checkbox
Expand Down Expand Up @@ -398,11 +409,6 @@ import ValidationChip from "@/components/admin/tabs/tagging-validation-chip.vue"
import ConfirmDialog from "@/components/confirm-dialog.vue";
import { useAdminStore } from "@/stores/admin";

const FORMAT_CHOICES = [
{ title: "MetronInfo", value: "METRON_INFO" },
{ title: "ComicInfo", value: "COMIC_INFO" },
];

const EDITABLE_FIELDS = Object.freeze([
"defaultFormats",
"deleteOriginal",
Expand Down Expand Up @@ -437,12 +443,13 @@ export default {
mdiChevronDown,
mdiChevronUp,
mdiOpenInNew,
formatChoices: FORMAT_CHOICES,
formatChoices: TAGGING_CHOICES.formats,
matchModeChoices: TAGGING_CHOICES.matchMode,
promptsModeChoices: TAGGING_CHOICES.promptsMode,
effortChoices: TAGGING_CHOICES.effort,
metadataFormatsHint:
"These metadata formats are written into each comic every time its tags are edited. Learn more about ComicInfo and MetronInfo.",
lastFormatHint: "At least one format must be written",
deleteOriginalHint:
"Writing tags to CBR, CB7, or CBT archives converts them to CBZ. Enable this to delete the original file after conversion.",
renameFilesHint:
Expand Down Expand Up @@ -507,6 +514,21 @@ export default {
Boolean(this.comicvineKey) || Boolean(this.defaults?.comicvineKeySet)
);
},
formatsMessages() {
// The last checked box is disabled; say why under the docs hint.
const messages = [this.metadataFormatsHint];
if ((this.draft.defaultFormats || []).length === 1) {
messages.push(this.lastFormatHint);
}
return messages;
},
formatsError() {
// Only a row saved empty before formats were required gets here. Don't
// fill in a default, which would auto-save on open; checking a box does.
return (this.draft.defaultFormats || []).length
? []
: ["Choose at least one format"];
},
canMerge() {
// Merging needs at least two enabled sources; with one there's nothing
// to merge. metronEnabled / comicvineEnabled already gate on credentials.
Expand Down Expand Up @@ -547,6 +569,10 @@ export default {
"updateTaggingDefaults",
"validateTaggingCredentials",
]),
isLastFormat(value) {
const formats = this.draft.defaultFormats || [];
return formats.length === 1 && formats.includes(value);
},
setSourceEnabled(source, enabled) {
// defaultSources order is run priority; a newly enabled source
// joins at the end (lowest priority).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,24 @@
}"
class="browserSettingsBlock"
>
<div class="settingsSubHeader">Show Collection Levels</div>
<v-checkbox
v-for="choice of collectionChoices"
:key="choice.title"
class="browserCollectionCheckbox"
<v-checkbox-group
:model-value="shownCollections"
density="compact"
hide-details="auto"
:model-value="showSettings[choice.value]"
:true-value="true"
:label="`Show ${choice.title}`"
@update:model-value="setShow(choice.value, $event)"
/>
hide-details
class="browserCollectionGroup"
@update:model-value="onShownUpdate"
>
<template #label>
<div class="settingsSubHeader">Show Collection Levels</div>
</template>
<v-checkbox
v-for="choice of collectionChoices"
:key="choice.title"
class="browserCollectionCheckbox"
:value="choice.value"
:label="`Show ${choice.title}`"
/>
</v-checkbox-group>
</div>
<v-divider />
</template>
Expand All @@ -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);
Expand All @@ -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;
}
}
</style>
Loading