diff --git a/NEWS.md b/NEWS.md index 625a8bdda..d6db613cd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,12 @@ width: 128px; border-radius: 128px; " /> +## v2.5.6 + +- Features + - Series years, status and summary in the metadata pane. + - Tag editor edits the MetronInfo series start year. + ## v2.5.5 - Features diff --git a/codex/choices/tagging.py b/codex/choices/tagging.py index abd9f8993..8048dd149 100644 --- a/codex/choices/tagging.py +++ b/codex/choices/tagging.py @@ -156,16 +156,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/scribe/importer/const.py b/codex/librarian/scribe/importer/const.py index da89fff74..4808a38b1 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" @@ -159,14 +163,20 @@ def _metron_fts_values(values: tuple) -> tuple: ####### # 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( @@ -282,7 +292,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 +303,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,), 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..aaf6076dd 100644 --- a/codex/librarian/scribe/importer/create/foreign_keys.py +++ b/codex/librarian/scribe/importer/create/foreign_keys.py @@ -8,7 +8,7 @@ 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, @@ -93,7 +93,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 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/migrations/0058_series_fields.py b/codex/migrations/0058_series_fields.py new file mode 100644 index 000000000..f91744035 --- /dev/null +++ b/codex/migrations/0058_series_fields.py @@ -0,0 +1,55 @@ +"""Generated by Django 6.1.2 on 2026-10-20 01:44.""" + +from django.db import migrations, models + +import codex.models.fields + + +class Migration(migrations.Migration): + """ + Give series 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. Additive and + reversible: the years are nullable and the text columns default empty. + Existing rows stay empty until their comics are re-imported. + """ + + dependencies = [ + ("codex", "0057_remove_pinned_settings"), + ] + + operations = [ + 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="" + ), + ), + ] 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/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/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/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/frontend/src/components/metadata/edit-mode/edit-panel.vue b/frontend/src/components/metadata/edit-mode/edit-panel.vue index ceae18111..921dd4c4d 100644 --- a/frontend/src/components/metadata/edit-mode/edit-panel.vue +++ b/frontend/src/components/metadata/edit-mode/edit-panel.vue @@ -259,6 +259,31 @@ +