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
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ border-radius: 128px;

- Features
- Series years, status and summary in the metadata pane.
- Filter by series years and status.
- Search series years, status and summary.
- Online tagging saves series years, status and summary.
- Browser title shows series years and a Tags button.
- Tag editor edits series years, status and summary. End year, status and
Expand Down
10 changes: 10 additions & 0 deletions codex/choices/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"month",
"year",
"page_count",
"series_end_year",
"series_start_year",
"size",
"decade",
"volume",
Expand Down Expand Up @@ -114,7 +116,15 @@ def _get_fieldmap_values(*args) -> tuple:
"review": (),
"scan_info": ("scan",),
"series": (),
"series_end_year": _get_fieldmap_values("series_end_year", "end_year"),
"series_groups": _get_fieldmap_values("series_groups"),
"series_start_year": _get_fieldmap_values("series_start_year", "start_year"),
# Hand-written: the generator would strip the final "s" into a
# bogus singular.
"series_status": ("series-status", "seriesstatus", "status"),
"series_summary": _get_fieldmap_values(
"series_summary", "series_desc", "series_description"
),
"size": (),
"sources": _get_fieldmap_values("sources", "id_sources"),
"stories": ("story",),
Expand Down
2 changes: 2 additions & 0 deletions codex/librarian/scribe/importer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ def _metron_fts_values(values: tuple) -> tuple:
REPRINT_LANGUAGE_FIELD_NAME = "language"
# Reprints index only their series names, under their own ComicFTS column.
ALTERNATE_SERIES_FTS_FIELD_NAME = "alternate_series"
# The linked series' summary, indexed on each of its comics.
SERIES_SUMMARY_FTS_FIELD_NAME = "series_summary"
UNIVERSES_FIELD_NAME = "universes"
NAME_FIELD_NAME = "name"
NUMBER_TO_FIELD_NAME = "number_to"
Expand Down
22 changes: 21 additions & 1 deletion codex/librarian/scribe/importer/create/foreign_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
FTS_CREATED_M2MS,
FTS_UPDATED_M2MS,
MODEL_REL_MAP,
SERIES_SUMMARY_FTS_FIELD_NAME,
SUMMARY_FIELD_NAME,
TOTAL,
UPDATE_FKS,
)
Expand All @@ -35,7 +37,7 @@
)
from codex.librarian.status import Status
from codex.models.base import BaseModel
from codex.models.collections import Folder
from codex.models.collections import Folder, Series
from codex.models.named import Universe


Expand Down Expand Up @@ -213,6 +215,14 @@ def bulk_create_all_models(self, status) -> int:
)
return count

@staticmethod
def _series_summary_changed(obj: BaseModel, update_args: Mapping) -> bool:
"""Whether a series update changes the summary its comics index."""
return (
isinstance(obj, Series)
and update_args.get(SUMMARY_FIELD_NAME, obj.summary) != obj.summary
)

def _bulk_update_models(self, model: type[BaseModel], status) -> int:
count = 0
update_tuples: list[tuple] | None = self.metadata[UPDATE_FKS].pop(model, None)
Expand All @@ -226,6 +236,7 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int:
update_objs = []
update_tuples = sorted(update_tuples, key=str)
fts_update_pks = set()
series_summary_pks = set()
for values_tuple in update_tuples:
key_args, update_args = self._get_create_update_args(
model,
Expand All @@ -235,6 +246,10 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int:
parent_pk_maps,
)
obj = model.objects.get(**key_args)
# The importer prunes unchanged series links, so a summary
# change has to re-index every comic of the series.
if self._series_summary_changed(obj, update_args):
series_summary_pks.add(obj.pk)
for key, value in update_args.items():
setattr(obj, key, value)
obj.presave()
Expand All @@ -251,6 +266,11 @@ def _bulk_update_models(self, model: type[BaseModel], status) -> int:
if fts_update_pks:
field_name = model.__name__.lower() + "s"
self.metadata[FTS_UPDATED_M2MS][field_name] = fts_update_pks
if series_summary_pks:
# Merge: the per-comic phases run once per chunk.
self.metadata[FTS_UPDATED_M2MS].setdefault(
SERIES_SUMMARY_FTS_FIELD_NAME, set()
).update(series_summary_pks)

update_fields = tuple(update_args_map.keys())
if model in COLLECTION_BASE_MODELS:
Expand Down
7 changes: 6 additions & 1 deletion codex/librarian/scribe/importer/create/link_fks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
LINK_FKS,
PARENT_FOLDER_FIELD_NAME,
PROTAGONIST_FIELD_MODEL_MAP,
SERIES_SUMMARY_FTS_FIELD_NAME,
VOLUME_FIELD_NAME,
)
from codex.librarian.scribe.importer.link import LinkComicsImporter
from codex.models import Folder
from codex.models import Folder, Series
from codex.models.comic import Comic
from codex.settings import IMPORTER_LINK_FK_BATCH_SIZE

Expand Down Expand Up @@ -161,6 +162,10 @@ def _get_comic_simple_fk_links(
field_name,
(values[-1],),
)
if isinstance(instance, Series):
self.add_links_to_fts(
subkey, SERIES_SUMMARY_FTS_FIELD_NAME, (instance.summary,)
)

def get_comic_fk_links(
self, subkey: str | int, path: str, *, for_create: bool
Expand Down
29 changes: 24 additions & 5 deletions codex/librarian/scribe/importer/search/sync_m2m.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
"""Update fts fields for updated foreign keys with non key search values."""

from itertools import batched
from types import MappingProxyType
from typing import TYPE_CHECKING

from django.db import transaction
from django.db.models.expressions import Value
from django.db.models.expressions import F, Value
from django.db.models.functions.datetime import Now
from django.db.models.functions.text import Concat
from loguru import logger

if TYPE_CHECKING:
from collections.abc import Collection

from codex.librarian.scribe.importer.const import FTS_UPDATED_M2MS
from codex.librarian.scribe.importer.const import (
FTS_UPDATED_M2MS,
SERIES_SUMMARY_FTS_FIELD_NAME,
)
from codex.librarian.scribe.importer.finish import FinishImporter
from codex.models.comic import ComicFTS
from codex.models.functions import GroupConcat
Expand All @@ -21,6 +25,16 @@
# default. ``ComicFTS`` carries ~28 columns; 500 rows x 28 cols is a
# safe ceiling that still amortizes per-statement overhead.
_FTS_BATCH_SIZE = 500
# FTS columns that index a plain attribute of an FK, keyed by the column:
# (filter on the FK's updated pks, the attribute's value). No GROUP_CONCAT.
_FK_ATTR_FTS_MAP = MappingProxyType(
{
SERIES_SUMMARY_FTS_FIELD_NAME: (
"comic__series__in",
F("comic__series__summary"),
),
}
)


class SearchIndexSyncManyToManyImporter(FinishImporter):
Expand Down Expand Up @@ -73,8 +87,11 @@ def _gather_m2m_field_values(
for field_name in field_names:
if self.abort_event.is_set():
return per_comic
rel = f"comic__{field_name}__in"
fts_value = self._get_fts_m2m_concat(field_name)
fk_attr = _FK_ATTR_FTS_MAP.get(field_name)
rel, fts_value = fk_attr or (
f"comic__{field_name}__in",
self._get_fts_m2m_concat(field_name),
)
model_pks = self.metadata[FTS_UPDATED_M2MS].pop(field_name)
self.log.debug(
f"Preparing {len(model_pks)} search entries for {field_name} updates."
Expand All @@ -86,7 +103,9 @@ def _gather_m2m_field_values(
.values_list("pk", "fts_value")
)
for pk, raw_value in rows:
value = (raw_value or "").strip(",")
value = raw_value or ""
if not fk_attr:
value = value.strip(",")
per_comic.setdefault(pk, {})[field_name] = value
return per_comic

Expand Down
1 change: 1 addition & 0 deletions codex/librarian/scribe/search/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"publisher",
"imprint",
"series",
"series_summary",
"age_rating_tagged",
"age_rating_metron",
"country",
Expand Down
1 change: 1 addition & 0 deletions codex/librarian/scribe/search/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"fts_publisher",
"fts_imprint",
"fts_series",
"fts_series_summary",
"fts_age_rating_tagged",
"fts_age_rating_metron",
"fts_original_format",
Expand Down
2 changes: 2 additions & 0 deletions codex/librarian/scribe/search/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
# The second traverses the AgeRating.metron FK into AgeRatingMetron.
"fts_age_rating_tagged": F("age_rating__name"),
"fts_age_rating_metron": F("age_rating__metron__name"),
# Series.summary is the one non-name FK value in the index.
"fts_series_summary": F("series__summary"),
}
)
# Alias → FK-traversal path. The alias becomes the FTS column name
Expand Down
72 changes: 72 additions & 0 deletions codex/migrations/0059_series_filters_and_fts_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""
Series filters and a searchable series summary.

The browser filters gain the series start year, end year and status, so
the per-user filter settings get a column for each.

The FTS5 rebuild adds the ``series_summary`` column. ``ComicFTS`` is
unmanaged, so makemigrations never sees it — the column list lives in
SQL and has to be dropped and recreated by hand, as 0049 did for
``alternate_series``. The table comes back empty; the librarian's
regular ``SearchIndexSyncTask`` repopulates it.
"""

from django.db import migrations, models

# 0049's column list, for the reverse migration.
_OLD_FTS_SQL = (
"CREATE VIRTUAL TABLE codex_comicfts USING fts5("
"comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, "
"publisher, imprint, series, name, collection_title, "
"age_rating_tagged, age_rating_metron, country, language, "
"original_format, review, scan_info, summary, tagger, "
"alternate_series, characters, "
"credits, genres, locations, roles, series_groups, stories, "
"story_arcs, tags, teams, universes, sources"
")"
)
# 0049's column list plus ``series_summary``.
_NEW_FTS_SQL = (
"CREATE VIRTUAL TABLE codex_comicfts USING fts5("
"comic_id UNINDEXED, created_at UNINDEXED, updated_at UNINDEXED, "
"publisher, imprint, series, name, collection_title, "
"age_rating_tagged, age_rating_metron, country, language, "
"original_format, review, scan_info, summary, series_summary, tagger, "
"alternate_series, characters, "
"credits, genres, locations, roles, series_groups, stories, "
"story_arcs, tags, teams, universes, sources"
")"
)
_DROP_FTS_SQL = "DROP TABLE IF EXISTS codex_comicfts;"


class Migration(migrations.Migration):
"""Series filter columns and FTS series_summary."""

dependencies = [
("codex", "0058_series_fields"),
]

operations = [
migrations.AddField(
model_name="settingsbrowserfilters",
name="series_end_year",
field=models.JSONField(default=list),
),
migrations.AddField(
model_name="settingsbrowserfilters",
name="series_start_year",
field=models.JSONField(default=list),
),
migrations.AddField(
model_name="settingsbrowserfilters",
name="series_status",
field=models.JSONField(default=list),
),
# ComicFTS is unmanaged; its Django state tracks only (comic,
# created_at, updated_at). The FTS columns are a SQL-level concern —
# no state_operations are needed, only a raw DROP + CREATE. Unlike
# 0049, the reverse restores the previous table rather than none.
migrations.RunSQL(sql=_DROP_FTS_SQL, reverse_sql=_OLD_FTS_SQL),
migrations.RunSQL(sql=_NEW_FTS_SQL, reverse_sql=_DROP_FTS_SQL),
]
1 change: 1 addition & 0 deletions codex/models/comic.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ class ComicFTS(BaseModel):
name = CharField(db_collation="nocase", max_length=MAX_NAME_LEN)
review = TextField(db_collation="nocase")
summary = TextField(db_collation="nocase")
series_summary = TextField(db_collation="nocase")
# FK collections
publisher = CharField(db_collation="nocase", max_length=MAX_NAME_LEN)
imprint = CharField(db_collation="nocase", max_length=MAX_NAME_LEN)
Expand Down
6 changes: 6 additions & 0 deletions codex/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,10 @@ class SettingsBrowserFilters(BaseModel):
original_format = JSONField(default=list)
reading_direction = JSONField(default=list)
reprints = JSONField(default=list)
series_end_year = JSONField(default=list)
series_groups = JSONField(default=list)
series_start_year = JSONField(default=list)
series_status = JSONField(default=list)
stories = JSONField(default=list)
story_arcs = JSONField(default=list)
tagger = JSONField(default=list)
Expand Down Expand Up @@ -267,7 +270,10 @@ class SettingsBrowserFilters(BaseModel):
"original_format",
"reading_direction",
"reprints",
"series_end_year",
"series_groups",
"series_start_year",
"series_status",
"stories",
"story_arcs",
"tagger",
Expand Down
25 changes: 24 additions & 1 deletion codex/serializers/browser/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
VuetifyFileTypeChoiceField,
VuetifyReadingDirectionChoiceField,
VuetifyReadOnlyListField,
VuetifySeriesStatusChoiceField,
)
from codex.serializers.models.pycountry import CountrySerializer, LanguageSerializer

Expand All @@ -46,7 +47,10 @@ class BrowserFilterChoicesSerializer(Serializer):
original_format = BooleanField(read_only=True)
reading_direction = BooleanField(read_only=True)
reprints = BooleanField(read_only=True)
series_end_year = BooleanField(read_only=True)
series_groups = BooleanField(read_only=True)
series_start_year = BooleanField(read_only=True)
series_status = BooleanField(read_only=True)
stories = BooleanField(read_only=True)
story_arcs = BooleanField(read_only=True)
tagger = BooleanField(read_only=True)
Expand Down Expand Up @@ -81,7 +85,10 @@ class BrowserSettingsFilterSerializer(Serializer):
child=VuetifyReadingDirectionChoiceField
)
reprints = VuetifyReadOnlyListField()
series_end_year = VuetifyReadOnlyListField()
series_groups = VuetifyReadOnlyListField()
series_start_year = VuetifyReadOnlyListField()
series_status = VuetifyReadOnlyListField(child=VuetifySeriesStatusChoiceField)
stories = VuetifyReadOnlyListField()
story_arcs = VuetifyReadOnlyListField()
tagger = VuetifyReadOnlyListField()
Expand Down Expand Up @@ -141,6 +148,12 @@ class BrowserChoicesCharPkSerializer(BrowserChoicesIntegerPkSerializer):
pk = VuetifyCharField(read_only=True)


class BrowserChoicesSeriesStatusPkSerializer(BrowserChoicesIntegerPkSerializer):
"""Status strings, but the "None" row keeps the int null code."""

pk = VuetifySeriesStatusChoiceField(read_only=True)


class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer):
"""Named Model Serailizer with pk = char hack for languages & countries."""

Expand All @@ -157,10 +170,20 @@ class BrowserChoicesDecimalPkSerializer(BrowserChoicesIntegerPkSerializer):
"file_type": BrowserChoicesCharPkSerializer,
"language": LanguageSerializer,
"reprints": BrowserChoicesReprintPkSerializer,
"series_status": BrowserChoicesSeriesStatusPkSerializer,
"universe": BrowserChoicesUniversePkSerializer,
}
)
_LIST_FIELDS = frozenset({"decade", "monochrome", "reading_direction", "year"})
_LIST_FIELDS = frozenset(
{
"decade",
"monochrome",
"reading_direction",
"series_end_year",
"series_start_year",
"year",
}
)
# ``BrowserSettingsFilterSerializer().get_fields()`` walks DRF's
# metaclass and instantiates a serializer just to look up four field
# representations. The fields are class-level definitions, so resolve
Expand Down
Loading