Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions codex/choices/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 18 additions & 8 deletions codex/librarian/scribe/importer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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: (
(
Expand All @@ -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,),
Expand Down
8 changes: 6 additions & 2 deletions codex/librarian/scribe/importer/create/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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({}),
}
Expand Down Expand Up @@ -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: (
{
Expand Down
4 changes: 2 additions & 2 deletions codex/librarian/scribe/importer/create/foreign_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
72 changes: 59 additions & 13 deletions codex/librarian/scribe/importer/read/foreign_keys.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 (
Expand All @@ -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):
Expand Down Expand Up @@ -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],
Expand All @@ -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."""
Expand Down
55 changes: 55 additions & 0 deletions codex/migrations/0058_series_fields.py
Original file line number Diff line number Diff line change
@@ -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=""
),
),
]
8 changes: 7 additions & 1 deletion codex/models/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
18 changes: 16 additions & 2 deletions codex/models/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
4 changes: 4 additions & 0 deletions codex/serializers/browser/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions codex/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading