diff --git a/backend/maint-scripts/update_titles_from_latest_books.py b/backend/maint-scripts/update_titles_from_latest_books.py new file mode 100755 index 00000000..9709834e --- /dev/null +++ b/backend/maint-scripts/update_titles_from_latest_books.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session as OrmSession + +from cms_backend import logger +from cms_backend.db import Session +from cms_backend.db.models import Book, Title +from cms_backend.db.title import get_title_by_id, title_is_missing_mandatory_metadata + + +def get_latest_book_for_title(session: OrmSession, title: Title) -> Book | None: + """Get the latest prod/staging book for a title.. + + Assumes book has passed all the checks done by the mill when it processes + a zimfarm notification. + """ + stmt = ( + select(Book) + .where( + Book.title_id == title.id, + Book.location_kind.in_(["prod", "staging"]), + Book.needs_processing.is_(False), + Book.has_error.is_(False), + Book.needs_file_operation.is_(False), + ) + .order_by( + # let prod books take precedence by sorting location_kind in ascending order + Book.location_kind.asc(), + Book.created_at.desc(), + ) + .limit(1) + ) + return session.scalars(stmt).first() + + +def process_title(session: OrmSession, title: Title) -> tuple[bool, str]: + """Process a single title: fetch latest book and update metadata.""" + if title.archived: + logger.info(f"Skipping archived title {title.id} ({title.name})") + return (False, "Title is archived") + + book = get_latest_book_for_title(session, title) + + if not book: + logger.info(f"No prod/staging books found for title {title.id} ({title.name})") + return (False, "No prod/staging book found meet constraints") + + if title_is_missing_mandatory_metadata(title): + title.title = book.zim_metadata["Title"] + title.creator = book.zim_metadata["Creator"] + title.publisher = book.zim_metadata["Publisher"] + title.description = book.zim_metadata["Description"] + title.language = book.zim_metadata["Language"] + title.illustration_48x48_at_1 = book.zim_metadata["Illustration_48x48@1"] + title.long_description = book.zim_metadata.get("LongDescription") + title.license = book.zim_metadata.get("License") + title.relation = book.zim_metadata.get("Relation") + title.source = book.zim_metadata.get("Source") + logger.info(f"✓ Updated title {title.id} ({title.name}) from book {book.id}") + return (True, "") + else: + logger.info(f"No updates needed for title {title.id} ({title.name}) ") + return (True, "") + + +def main(): + + with Session.begin() as session: + title_ids = session.scalars(select(Title.id)).all() + logger.info(f"Found {len(title_ids)} titles to process") + nb_titles_updated = 0 + nb_titles_skipped = 0 + reasons: list[dict[str, Any]] = [] + + for title_id in title_ids: + title = get_title_by_id(session, title_id=title_id) + processed, reason = process_title(session, title) + if processed: + nb_titles_updated += 1 + else: + nb_titles_skipped += 1 + reasons.append({title.name: reason}) + + logger.info( + f"Updated {nb_titles_updated} title(s) metadata, skipped " + f"{nb_titles_skipped} titles(s)" + ) + + if reasons: + print("\nSkipped titles summary:") + print("| Title Name | Reason |") + print("|------------|--------|") + for entry in reasons: + for title_name, reason in entry.items(): + print(f"| {title_name} | {reason} |") + + +if __name__ == "__main__": + main() diff --git a/backend/src/cms_backend/api/routes/fields.py b/backend/src/cms_backend/api/routes/fields.py index b412b80d..cf879abd 100644 --- a/backend/src/cms_backend/api/routes/fields.py +++ b/backend/src/cms_backend/api/routes/fields.py @@ -1,3 +1,4 @@ +import base64 from typing import Annotated, Any from pydantic import ( @@ -35,6 +36,15 @@ def not_empty(value: str) -> str: return value.strip() +def validate_base64(value: str) -> str: + """Validate that a string is a base64 string.""" + try: + base64.b64decode(value, validate=True) + except Exception as exc: + raise ValueError(f"Invalid base64 string: {exc}") from exc + return value + + NoNullCharString = Annotated[str, AfterValidator(no_null_char)] NotEmptyString = Annotated[NoNullCharString, AfterValidator(not_empty)] @@ -42,3 +52,5 @@ def not_empty(value: str) -> str: SkipField = Annotated[int, Field(ge=0), WrapValidator(skip_validation)] LimitFieldMax200 = Annotated[int, Field(ge=1, le=200), WrapValidator(skip_validation)] + +Base64Str = Annotated[NotEmptyString, AfterValidator(validate_base64)] diff --git a/backend/src/cms_backend/api/routes/titles.py b/backend/src/cms_backend/api/routes/titles.py index 3c9b59a9..4ce18f49 100644 --- a/backend/src/cms_backend/api/routes/titles.py +++ b/backend/src/cms_backend/api/routes/titles.py @@ -10,7 +10,12 @@ get_current_account_or_none, require_permission, ) -from cms_backend.api.routes.fields import LimitFieldMax200, NotEmptyString, SkipField +from cms_backend.api.routes.fields import ( + Base64Str, + LimitFieldMax200, + NotEmptyString, + SkipField, +) from cms_backend.api.routes.http_errors import ForbiddenError from cms_backend.api.routes.models import ListResponse, calculate_pagination_metadata from cms_backend.db import gen_dbsession @@ -19,7 +24,7 @@ from cms_backend.db.title import archive_title as db_archive_title from cms_backend.db.title import archive_titles as db_archive_titles from cms_backend.db.title import create_title as db_create_title -from cms_backend.db.title import create_title_full_schema +from cms_backend.db.title import create_title_full_schema, create_title_light_schema from cms_backend.db.title import get_title_by_id as db_get_title_by_id from cms_backend.db.title import get_title_by_name as db_get_title_by_name from cms_backend.db.title import get_titles as db_get_titles @@ -51,6 +56,16 @@ class RestoreTitlesSchema(BaseModel): class BaseTitleCreateUpdateSchema(BaseModel): collection_titles: list[BaseTitleCollectionSchema] | None = None + long_description: NotEmptyString | None = None + license: NotEmptyString | None = None + relation: NotEmptyString | None = None + source: NotEmptyString | None = None + title: NotEmptyString | None = None + creator: NotEmptyString | None = None + description: NotEmptyString | None = None + publisher: NotEmptyString | None = None + language: NotEmptyString | None = None + illustration_48x48_at_1: Base64Str | None = None @model_validator(mode="after") def validate_unique_collection_titles(self) -> Self: @@ -133,13 +148,18 @@ def create_title( name=title_data.name, maturity=title_data.maturity, collection_titles=title_data.collection_titles, + _title=title_data.title, + creator=title_data.creator, + publisher=title_data.publisher, + language=title_data.language, + illustration_48x48_at_1=title_data.illustration_48x48_at_1, + license_=title_data.license, + relation=title_data.relation, + source=title_data.source, + long_description=title_data.long_description, + description=title_data.description, ) - return TitleLightSchema( - id=title.id, - name=title.name, - maturity=title.maturity, - archived=title.archived, - ) + return create_title_light_schema(title) @router.patch( @@ -151,20 +171,25 @@ def update_title( title_data: TitleUpdateSchema, session: OrmSession = Depends(gen_dbsession), ) -> TitleLightSchema: - """Update a title's maturity and/or collection_titles""" + """Update a title""" title = db_update_title( session, title_id=title_id, name=title_data.name, maturity=title_data.maturity, collection_titles=title_data.collection_titles, + _title=title_data.title, + creator=title_data.creator, + description=title_data.description, + long_description=title_data.long_description, + publisher=title_data.publisher, + language=title_data.language, + illustration_48x48_at_1=title_data.illustration_48x48_at_1, + license_=title_data.license, + relation=title_data.relation, + source=title_data.source, ) - return TitleLightSchema( - id=title.id, - name=title.name, - maturity=title.maturity, - archived=title.archived, - ) + return create_title_light_schema(title) @router.post( @@ -210,12 +235,7 @@ def archive_title( session, title_identifier=title_id, ) - return TitleLightSchema( - id=title.id, - name=title.name, - maturity=title.maturity, - archived=title.archived, - ) + return create_title_light_schema(title) @router.patch( @@ -231,9 +251,4 @@ def restore_archived_title( session, title_identifier=title_id, ) - return TitleLightSchema( - id=title.id, - name=title.name, - maturity=title.maturity, - archived=title.archived, - ) + return create_title_light_schema(title) diff --git a/backend/src/cms_backend/api/routes/utils.py b/backend/src/cms_backend/api/routes/utils.py index a9369132..81f97e31 100644 --- a/backend/src/cms_backend/api/routes/utils.py +++ b/backend/src/cms_backend/api/routes/utils.py @@ -14,7 +14,7 @@ def build_library_xml( library_elem.set("version", "20110515") for entry in entries: - book, download_base_url, path, filename = entry + book, title, download_base_url, path, filename = entry if not book.zim_metadata: continue @@ -30,11 +30,13 @@ def build_library_xml( # Metadata from zim_metadata dict zim_meta = book.zim_metadata - book_elem.set("title", zim_meta.get("Title", "")) - book_elem.set("description", zim_meta.get("Description", "")) - book_elem.set("language", zim_meta.get("Language", "")) - book_elem.set("creator", zim_meta.get("Creator", "")) - book_elem.set("publisher", zim_meta.get("Publisher", "")) + book_elem.set("title", title.title or zim_meta.get("Title", "")) + book_elem.set( + "description", title.description or zim_meta.get("Description", "") + ) + book_elem.set("language", title.language or zim_meta.get("Language", "")) + book_elem.set("creator", title.creator or zim_meta.get("Creator", "")) + book_elem.set("publisher", title.publisher or zim_meta.get("Publisher", "")) book_elem.set("name", zim_meta.get("Name", "")) book_elem.set("date", zim_meta.get("Date", "")) @@ -42,7 +44,9 @@ def build_library_xml( tags = zim_meta.get("Tags", "") book_elem.set("tags", ";".join(convert_tags(tags))) - favicon = zim_meta.get("Illustration_48x48@1", "") + favicon = title.illustration_48x48_at_1 or zim_meta.get( + "Illustration_48x48@1", "" + ) if favicon: book_elem.set("favicon", favicon) book_elem.set("faviconMimeType", "image/png") diff --git a/backend/src/cms_backend/db/book.py b/backend/src/cms_backend/db/book.py index eeef2411..4d169bef 100644 --- a/backend/src/cms_backend/db/book.py +++ b/backend/src/cms_backend/db/book.py @@ -90,6 +90,7 @@ def create_book_full_schema(book: Book) -> BookFullSchema: date=book.date, deletion_date=book.deletion_date, flavour=book.flavour, + issues=book.issues, article_count=book.article_count, media_count=book.media_count, size=book.size, @@ -339,6 +340,37 @@ def recover_book(session: OrmSession, book_id: UUID) -> Book: return book +def get_differing_metadata_keys(book: Book) -> list[str]: + """Get the list of metadata keys that are different between book and it's title. + + Assumes book and title both have mandatory metadata set. + Assumes that the book name and title name already match, thus aren't checked. + """ + + if book.title is None: + raise ValueError("Book has no associated title.") + + book_metadata = { + "Title": book.zim_metadata["Title"], + "Creator": book.zim_metadata["Creator"], + "Publisher": book.zim_metadata["Publisher"], + "Description": book.zim_metadata["Description"], + "Language": book.zim_metadata["Language"], + "Illustration_48x48@1": book.zim_metadata["Illustration_48x48@1"], + } + + title_metadata = { + "Title": book.title.title, + "Creator": book.title.creator, + "Publisher": book.title.publisher, + "Description": book.title.description, + "Language": book.title.language, + "Illustration_48x48@1": book.title.illustration_48x48_at_1, + } + + return [key for key in book_metadata if book_metadata[key] != title_metadata[key]] + + def update_book(session: OrmSession, book_id: UUID, *, flavour: str) -> Book: book = get_book(session, book_id) if book.location_kind == "deleted": diff --git a/backend/src/cms_backend/db/books.py b/backend/src/cms_backend/db/books.py index df220af8..c2e766e5 100644 --- a/backend/src/cms_backend/db/books.py +++ b/backend/src/cms_backend/db/books.py @@ -45,6 +45,7 @@ def get_books( Book.name, Book.date, Book.flavour, + Book.issues, ).order_by( Book.has_error.desc(), Book.location_kind, @@ -122,6 +123,7 @@ def get_books( name=name, date=date, flavour=flavour, + issues=book_issues, ) for ( book_id_result, @@ -135,6 +137,7 @@ def get_books( name, date, flavour, + book_issues, ) in session.execute( stmt.offset(skip) .limit(limit) diff --git a/backend/src/cms_backend/db/collection.py b/backend/src/cms_backend/db/collection.py index a4497946..6c2ff8b0 100644 --- a/backend/src/cms_backend/db/collection.py +++ b/backend/src/cms_backend/db/collection.py @@ -65,6 +65,7 @@ class LibraryBookData(NamedTuple): """Tuple containing book alongside other data needed for library rendering.""" book: Book + title: Title download_base_url: str | None path: Path filename: str @@ -91,7 +92,7 @@ def get_latest_books_for_collection( stmt = ( select( Book, - Title.id.label("title_id"), + Title, Collection.download_base_url, CollectionTitle.path.label("subpath"), BookLocation.filename, @@ -115,16 +116,18 @@ def get_latest_books_for_collection( .order_by(Title.id, Book.flavour, Book.created_at.desc()) ) # Filter to keep only the latest book per name+flavour combination - seen: set[tuple[str | None, str | None]] = set() + seen: set[tuple[UUID, str | None]] = set() latest_books: list[LibraryBookData] = [] for row in session.execute(stmt).all(): book = cast(Book, row.Book) - key = (row.title_id, book.flavour) + title = cast(Title, row.Title) + key = (title.id, book.flavour) if key not in seen: seen.add(key) latest_books.append( LibraryBookData( book=book, + title=title, path=row.subpath, download_base_url=row.download_base_url, filename=row.filename, diff --git a/backend/src/cms_backend/db/models.py b/backend/src/cms_backend/db/models.py index 6a113d96..2f24f85b 100644 --- a/backend/src/cms_backend/db/models.py +++ b/backend/src/cms_backend/db/models.py @@ -138,6 +138,10 @@ class Book(Base): location_kind: Mapped[str] = mapped_column( init=False, default="quarantine", server_default="quarantine" ) + # ideally, these issues should not prevent a book from being acted upon + issues: Mapped[list[str]] = mapped_column( + default_factory=list, server_default="{}", init=False + ) deletion_date: Mapped[datetime | None] = mapped_column(default=None, init=False) events: Mapped[list[str]] = mapped_column(init=False, default_factory=list) @@ -196,6 +200,16 @@ class Title(Base): init=False, primary_key=True, server_default=text("uuid_generate_v4()") ) name: Mapped[str] = mapped_column(unique=True, index=True) + title: Mapped[str | None] = mapped_column(default=None) + creator: Mapped[str | None] = mapped_column(default=None) + publisher: Mapped[str | None] = mapped_column(default=None) + description: Mapped[str | None] = mapped_column(default=None) + language: Mapped[str | None] = mapped_column(default=None) + illustration_48x48_at_1: Mapped[str | None] = mapped_column(default=None) + long_description: Mapped[str | None] = mapped_column(default=None) + license: Mapped[str | None] = mapped_column(default=None) + relation: Mapped[str | None] = mapped_column(default=None) + source: Mapped[str | None] = mapped_column(default=None) maturity: Mapped[str] = mapped_column(init=False, index=True, default="unstable") events: Mapped[list[str]] = mapped_column(init=False, default_factory=list) archived: Mapped[bool] = mapped_column(default=False, server_default=false()) diff --git a/backend/src/cms_backend/db/staging.py b/backend/src/cms_backend/db/staging.py index 8d647a16..9d19e22d 100644 --- a/backend/src/cms_backend/db/staging.py +++ b/backend/src/cms_backend/db/staging.py @@ -9,6 +9,7 @@ from cms_backend.db.models import ( Book, BookLocation, + Title, ) @@ -27,9 +28,11 @@ def get_staging_books_library_data(session: OrmSession) -> list[LibraryBookData] stmt = ( select( Book, + Title, BookLocation.filename, ) .join(BookLocation) + .join(Title, Book.title_id == Title.id) .where( and_( Book.location_kind == "staging", @@ -46,6 +49,7 @@ def get_staging_books_library_data(session: OrmSession) -> list[LibraryBookData] return [ LibraryBookData( book=cast(Book, row.Book), + title=cast(Title, row.Title), # staging download url is supposed to contain the whole path already # for convenience in deployment path=Path(""), diff --git a/backend/src/cms_backend/db/title.py b/backend/src/cms_backend/db/title.py index d550ad48..c9cf0603 100644 --- a/backend/src/cms_backend/db/title.py +++ b/backend/src/cms_backend/db/title.py @@ -34,12 +34,22 @@ def create_title_full_schema(title: Title) -> TitleFullSchema: - """Create a schema of a tilte.""" + """Create a full schema of a tilte.""" return TitleFullSchema( id=title.id, name=title.name, maturity=title.maturity, events=title.events, + title=title.title, + creator=title.creator, + publisher=title.publisher, + description=title.description, + language=title.language, + illustration_48x48_at_1=title.illustration_48x48_at_1, + long_description=title.long_description, + license=title.license, + relation=title.relation, + source=title.source, books=[ BookLightSchema( id=book.id, @@ -53,6 +63,7 @@ def create_title_full_schema(title: Title) -> TitleFullSchema: name=book.name, date=book.date, flavour=book.flavour, + issues=book.issues, ) for book in sorted( title.books, @@ -79,6 +90,26 @@ def create_title_full_schema(title: Title) -> TitleFullSchema: ) +def create_title_light_schema(title: Title) -> TitleLightSchema: + """Create a light schema of a title.""" + return TitleLightSchema( + id=title.id, + name=title.name, + maturity=title.maturity, + archived=title.archived, + title=title.title, + creator=title.creator, + publisher=title.publisher, + description=title.description, + language=title.language, + illustration_48x48_at_1=title.illustration_48x48_at_1, + long_description=title.long_description, + license=title.license, + relation=title.relation, + source=title.source, + ) + + def get_title_by_id_or_none(session: OrmSession, *, title_id: UUID) -> Title | None: """Get a title by ID""" return session.scalars( @@ -142,6 +173,16 @@ def get_titles( Title.name.label("title_name"), Title.maturity.label("title_maturity"), Title.archived.label("title_archived"), + Title.title.label("title_title"), + Title.creator.label("title_creator"), + Title.publisher.label("title_publisher"), + Title.description.label("title_description"), + Title.language.label("title_language"), + Title.illustration_48x48_at_1.label("title_illustration_48x48_at_1"), + Title.long_description.label("title_long_description"), + Title.license.label("title_license"), + Title.relation.label("title_relation"), + Title.source.label("title_source"), ) .join(CollectionTitle, CollectionTitle.title_id == Title.id, isouter=True) .join(Collection, CollectionTitle.collection_id == Collection.id, isouter=True) @@ -175,12 +216,32 @@ def get_titles( name=title_name, maturity=title_maturity, archived=title_archived, + title=title_title, + creator=title_creator, + publisher=title_publisher, + description=title_description, + language=title_language, + illustration_48x48_at_1=title_illustration_48x48_at_1, + long_description=title_long_description, + license=title_license, + relation=title_relation, + source=title_source, ) for ( title_id, title_name, title_maturity, title_archived, + title_title, + title_creator, + title_publisher, + title_description, + title_language, + title_illustration_48x48_at_1, + title_long_description, + title_license, + title_relation, + title_source, ) in session.execute(stmt.offset(skip).limit(limit)).all() ], ) @@ -192,6 +253,16 @@ def create_title( name: str, maturity: str | None, collection_titles: list[BaseTitleCollectionSchema] | None, + _title: str | None = None, + creator: str | None = None, + publisher: str | None = None, + language: str | None = None, + description: str | None = None, + long_description: str | None = None, + illustration_48x48_at_1: str | None = None, + license_: str | None = None, + relation: str | None = None, + source: str | None = None, ) -> Title: """Create a new title""" @@ -200,6 +271,16 @@ def create_title( ) if maturity: title.maturity = maturity + title.title = _title + title.creator = creator + title.publisher = publisher + title.language = language + title.illustration_48x48_at_1 = illustration_48x48_at_1 + title.license = license_ + title.relation = relation + title.source = source + title.description = description + title.long_description = long_description title.events.append(f"{getnow()}: title created") session.add(title) @@ -241,8 +322,18 @@ def update_title( maturity: str | None = None, name: str | None = None, collection_titles: list[BaseTitleCollectionSchema] | None = None, + _title: str | None = None, + creator: str | None = None, + publisher: str | None = None, + language: str | None = None, + description: str | None = None, + long_description: str | None = None, + illustration_48x48_at_1: str | None = None, + license_: str | None = None, + relation: str | None = None, + source: str | None = None, ) -> Title: - """Update a title's maturity and/or collection_titles. + """Update a title's details When collection_titles changes: - Finds all books associated with this title where location_kind == 'prod' @@ -261,6 +352,82 @@ def update_title( f"{getnow()}: maturity updated from {old_maturity} to {maturity}" ) + # Update title if provided + if _title is not None and _title != title.title: + old_title = title.title + title.title = _title + title.events.append(f"{getnow()}: title updated from {old_title} to {_title}") + + # Update creator if provided + if creator is not None and creator != title.creator: + old_creator = title.creator + title.creator = creator + title.events.append( + f"{getnow()}: creator updated from {old_creator} to {creator}" + ) + + # Update description if provided + if description is not None and description != title.description: + old_description = title.description + title.description = description + title.events.append( + f"{getnow()}: description updated from {old_description} to {description}" + ) + + if long_description is not None and long_description != title.long_description: + old_long_description = title.long_description + title.long_description = long_description + title.events.append( + f"{getnow()}: long description updated from " + f"{old_long_description} to {long_description}" + ) + + # Update publisher if provided + if publisher is not None and publisher != title.publisher: + old_publisher = title.publisher + title.publisher = publisher + title.events.append( + f"{getnow()}: publisher updated from {old_publisher} to {publisher}" + ) + + # Update language if provided + if language is not None and language != title.language: + old_language = title.language + title.language = language + title.events.append( + f"{getnow()}: language updated from {old_language} to {language}" + ) + + # Update illustration_48x48_at_1 if provided + if ( + illustration_48x48_at_1 is not None + and illustration_48x48_at_1 != title.illustration_48x48_at_1 + ): + title.illustration_48x48_at_1 = illustration_48x48_at_1 + title.events.append(f"{getnow()}: illustration_48x48@1 updated") + + # Update license if provided + if license_ is not None and license_ != title.license: + old_license = title.license + title.license = license_ + title.events.append( + f"{getnow()}: license updated from {old_license} to {license_}" + ) + + # Update relation if provided + if relation is not None and relation != title.relation: + old_relation = title.relation + title.relation = relation + title.events.append( + f"{getnow()}: relation updated from {old_relation} to {relation}" + ) + + # Update source if provided + if source is not None and source != title.source: + old_source = title.source + title.source = source + title.events.append(f"{getnow()}: source updated from {old_source} to {source}") + name_changed: bool = False # Update name if provided if name and name != title.name: @@ -455,3 +622,22 @@ def restore_titles( """Restore a list of archived titles""" for title_name in title_names: restore_title(session, title_name) + + +def title_is_missing_mandatory_metadata(title: Title) -> bool: + """Check if a title is missing the mandatory metadata information + + See https://wiki.openzim.org/wiki/Metadata for the list of metadata + """ + + return any( + value is None + for value in [ + title.title, + title.creator, + title.publisher, + title.description, + title.language, + title.illustration_48x48_at_1, + ] + ) diff --git a/backend/src/cms_backend/migrations/versions/e70e0c595eb9_add_metadata_to_title.py b/backend/src/cms_backend/migrations/versions/e70e0c595eb9_add_metadata_to_title.py new file mode 100644 index 00000000..a0bdb09a --- /dev/null +++ b/backend/src/cms_backend/migrations/versions/e70e0c595eb9_add_metadata_to_title.py @@ -0,0 +1,59 @@ +"""add metadata to title + +Revision ID: e70e0c595eb9 +Revises: a8f64135b053 +Create Date: 2026-05-25 08:20:45.716452 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "e70e0c595eb9" +down_revision = "a8f64135b053" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("title", sa.Column("title", sa.String(), nullable=True)) + op.add_column("title", sa.Column("creator", sa.String(), nullable=True)) + op.add_column("title", sa.Column("publisher", sa.String(), nullable=True)) + op.add_column("title", sa.Column("description", sa.String(), nullable=True)) + op.add_column("title", sa.Column("language", sa.String(), nullable=True)) + op.add_column( + "title", sa.Column("illustration_48x48_at_1", sa.String(), nullable=True) + ) + op.add_column("title", sa.Column("long_description", sa.String(), nullable=True)) + op.add_column("title", sa.Column("license", sa.String(), nullable=True)) + op.add_column("title", sa.Column("relation", sa.String(), nullable=True)) + op.add_column("title", sa.Column("source", sa.String(), nullable=True)) + op.add_column( + "book", + sa.Column( + "issues", + postgresql.ARRAY(sa.String()), + server_default="{}", + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("title", "source") + op.drop_column("title", "relation") + op.drop_column("title", "license") + op.drop_column("title", "long_description") + op.drop_column("title", "illustration_48x48_at_1") + op.drop_column("title", "language") + op.drop_column("title", "description") + op.drop_column("title", "publisher") + op.drop_column("title", "creator") + op.drop_column("title", "title") + op.drop_column("book", "issues") + # ### end Alembic commands ### diff --git a/backend/src/cms_backend/mill/processors/title.py b/backend/src/cms_backend/mill/processors/title.py index 50b2f7b5..f207e0dd 100644 --- a/backend/src/cms_backend/mill/processors/title.py +++ b/backend/src/cms_backend/mill/processors/title.py @@ -2,9 +2,13 @@ from cms_backend import logger from cms_backend.context import Context -from cms_backend.db.book import create_book_target_locations +from cms_backend.db.book import ( + create_book_target_locations, + get_differing_metadata_keys, +) from cms_backend.db.models import Book, Title from cms_backend.db.rules import apply_retention_rules +from cms_backend.db.title import title_is_missing_mandatory_metadata from cms_backend.schemas.models import FileLocation from cms_backend.utils.datetime import getnow from cms_backend.utils.filename import compute_target_filename @@ -39,10 +43,33 @@ def add_book_to_title(session: OrmSession, book: Book, title: Title): book_id=book.id, ) - # Determine if this book goes to staging or prod based on title maturity - # For now, only 'stable' maturity move straight to prod, other maturity moves - # through staging first - goes_to_staging = title.maturity != "stable" + if title_is_missing_mandatory_metadata(title): + title.title = book.zim_metadata["Title"] + title.creator = book.zim_metadata["Creator"] + title.publisher = book.zim_metadata["Publisher"] + title.description = book.zim_metadata["Description"] + title.language = book.zim_metadata["Language"] + title.illustration_48x48_at_1 = book.zim_metadata["Illustration_48x48@1"] + title.long_description = book.zim_metadata.get("LongDescription") + title.license = book.zim_metadata.get("License") + title.relation = book.zim_metadata.get("Relation") + title.source = book.zim_metadata.get("Source") + + different_metadata_keys = get_differing_metadata_keys(book) + if different_metadata_keys: + book.issues = ["metadata mismatch"] + book.events.append( + f"{getnow()}: book metadata is different from title metadata: " + f"{','.join(different_metadata_keys)}" + ) + + # Determine if this book goes to staging or prod based on + # - title maturity: For now, only 'stable' maturity move straight to prod, + # other maturity moves through staging first + # - if book has different metadata from title + goes_to_staging = ( + title.maturity != "stable" or len(different_metadata_keys) != 0 + ) target_locations = ( [ diff --git a/backend/src/cms_backend/schemas/orms.py b/backend/src/cms_backend/schemas/orms.py index 0f417d4a..5b45a4e4 100644 --- a/backend/src/cms_backend/schemas/orms.py +++ b/backend/src/cms_backend/schemas/orms.py @@ -23,6 +23,16 @@ class TitleLightSchema(BaseModel): name: str maturity: str | None archived: bool + title: str | None + creator: str | None + publisher: str | None + description: str | None + language: str | None + illustration_48x48_at_1: str | None + long_description: str | None + license: str | None + relation: str | None + source: str | None class BaseTitleCollectionSchema(BaseModel): @@ -105,6 +115,7 @@ class BookLightSchema(BaseModel): name: str | None date: str | None flavour: str | None + issues: list[str] class BookFullSchema(BookLightSchema): diff --git a/backend/src/cms_backend/utils/zim.py b/backend/src/cms_backend/utils/zim.py index 57738616..182e4d04 100644 --- a/backend/src/cms_backend/utils/zim.py +++ b/backend/src/cms_backend/utils/zim.py @@ -17,6 +17,7 @@ def get_missing_metadata_keys(zim_metadata: dict[str, Any]) -> list[str]: "Date", "Description", "Language", + "Illustration_48x48@1", ) diff --git a/backend/tests/api/routes/test_titles.py b/backend/tests/api/routes/test_titles.py index 5151915d..ce34f6bf 100644 --- a/backend/tests/api/routes/test_titles.py +++ b/backend/tests/api/routes/test_titles.py @@ -48,6 +48,16 @@ def test_get_titles( "name", "maturity", "archived", + "title", + "creator", + "publisher", + "description", + "language", + "illustration_48x48_at_1", + "long_description", + "relation", + "source", + "license", } assert data["items"][0]["name"] == "wikipedia_fr_all" @@ -62,12 +72,19 @@ def test_get_titles( def test_create_title_required_permissions( client: TestClient, create_account: Callable[..., Account], + illustration_48x48_at_1: str, permission: RoleEnum, expected_status_code: HTTPStatus, ): """Test creating a title with different roles""" title_data = { "name": "wikipedia_en_test", + "title": "Wikipedia in English", + "creator": "Wikipedia Contributors", + "publisher": "Kiwix", + "language": "eng", + "description": "A free encyclopedia", + "illustration_48x48_at_1": illustration_48x48_at_1, } account = create_account(permission=permission) @@ -86,10 +103,17 @@ def test_create_title_required_fields_only( client: TestClient, dbsession: OrmSession, access_token: str, + illustration_48x48_at_1: str, ): """Test creating a title with only required fields""" title_data = { "name": "wikipedia_en_test", + "title": "Wikipedia in English", + "creator": "Wikipedia Contributors", + "publisher": "Kiwix", + "language": "eng", + "description": "A free encyclopedia", + "illustration_48x48_at_1": illustration_48x48_at_1, } response = client.post( @@ -103,6 +127,12 @@ def test_create_title_required_fields_only( assert "id" in data assert "name" in data assert data["name"] == "wikipedia_en_test" + assert data["title"] == "Wikipedia in English" + assert data["creator"] == "Wikipedia Contributors" + assert data["publisher"] == "Kiwix" + assert data["language"] == "eng" + assert data["description"] == "A free encyclopedia" + assert data["illustration_48x48_at_1"] == illustration_48x48_at_1 # Verify the title was created in the database title = dbsession.get(Title, data["id"]) @@ -123,12 +153,23 @@ def test_create_title_all_fields( dbsession: OrmSession, create_collection: Callable[..., Collection], access_token: str, + illustration_48x48_at_1: str, ): """Test creating a title with all fields""" collection = create_collection(name="wikipedia") title_data = { "name": "wikipedia_en_test", "maturity": "unstable", + "title": "Wikipedia in English", + "creator": "Wikipedia Contributors", + "publisher": "Kiwix", + "language": "eng", + "description": "A free encyclopedia", + "long_description": "Wikipedia is a free online encyclopedia.", + "illustration_48x48_at_1": illustration_48x48_at_1, + "license": "CC-BY-SA", + "relation": "wikipedia", + "source": "https://en.wikipedia.org", "collection_titles": [ { "collection_name": "wikipedia", @@ -150,11 +191,31 @@ def test_create_title_all_fields( assert data["name"] == "wikipedia_en_test" assert "maturity" in data assert data["maturity"] == "unstable" + assert data["title"] == "Wikipedia in English" + assert data["creator"] == "Wikipedia Contributors" + assert data["publisher"] == "Kiwix" + assert data["language"] == "eng" + assert data["description"] == "A free encyclopedia" + assert data["long_description"] == "Wikipedia is a free online encyclopedia." + assert data["illustration_48x48_at_1"] == illustration_48x48_at_1 + assert data["license"] == "CC-BY-SA" + assert data["relation"] == "wikipedia" + assert data["source"] == "https://en.wikipedia.org" # Verify the title was created in the database and belongs to the collection title = dbsession.get(Title, data["id"]) assert title is not None assert title.name == "wikipedia_en_test" + assert title.title == "Wikipedia in English" + assert title.creator == "Wikipedia Contributors" + assert title.publisher == "Kiwix" + assert title.language == "eng" + assert title.description == "A free encyclopedia" + assert title.long_description == "Wikipedia is a free online encyclopedia." + assert title.illustration_48x48_at_1 == illustration_48x48_at_1 + assert title.license == "CC-BY-SA" + assert title.relation == "wikipedia" + assert title.source == "https://en.wikipedia.org" assert str(title.collections[0].path) == "wikis" assert title.collections[0].collection_id == collection.id @@ -170,11 +231,18 @@ def test_create_title_all_fields( def test_create_title_with_duplicate_collection_name( client: TestClient, access_token: str, + illustration_48x48_at_1: str, ): """Test creating a title with the same collection repeated.""" title_data = { "name": "wikipedia_en_test", "maturity": "unstable", + "title": "Wikipedia in English", + "creator": "Wikipedia Contributors", + "publisher": "Kiwix", + "language": "eng", + "description": "A free encyclopedia", + "illustration_48x48_at_1": illustration_48x48_at_1, "collection_titles": [ { "collection_name": "wikipedia", @@ -195,10 +263,17 @@ def test_create_title_with_duplicate_collection_name( def test_create_title_duplicate_name( client: TestClient, access_token: str, + illustration_48x48_at_1: str, ): """Test creating a title with duplicate name returns conflict error""" title_data = { "name": "wikipedia_en_duplicate", + "title": "Wikipedia in English", + "creator": "Wikipedia Contributors", + "publisher": "Kiwix", + "language": "eng", + "description": "A free encyclopedia", + "illustration_48x48_at_1": illustration_48x48_at_1, } # Create the first title @@ -241,6 +316,16 @@ def test_get_title_by_id( "books", "collections", "archived", + "title", + "creator", + "publisher", + "description", + "language", + "illustration_48x48_at_1", + "long_description", + "relation", + "source", + "license", } # Verify field values @@ -293,6 +378,7 @@ def test_get_title_by_id_with_books( "date", "flavour", "deletion_date", + "issues", } assert data["books"][0]["title_id"] == str(title.id) assert data["books"][1]["title_id"] == str(title.id) @@ -400,6 +486,64 @@ def test_update_title_with_existing_title_name( assert response.status_code == HTTPStatus.CONFLICT +def test_update_title_metadata( + client: TestClient, + dbsession: OrmSession, + create_title: Callable[..., Title], + access_token: str, + illustration_48x48_at_1: str, +): + """Test updating a title's metadata fields""" + title = create_title(name="wikipedia_en_test") + + update_data = { + "title": "Wikipedia in English", + "creator": "Wikipedia Contributors", + "publisher": "Kiwix", + "language": "eng", + "description": "A free encyclopedia", + "long_description": "Wikipedia is a free online encyclopedia.", + "illustration_48x48_at_1": illustration_48x48_at_1, + "license": "CC-BY-SA", + "relation": "wikipedia", + "source": "https://en.wikipedia.org", + } + + response = client.patch( + f"/v1/titles/{title.id}", + json=update_data, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.OK + data = response.json() + + assert data["id"] == str(title.id) + assert data["name"] == "wikipedia_en_test" + assert data["title"] == "Wikipedia in English" + assert data["creator"] == "Wikipedia Contributors" + assert data["publisher"] == "Kiwix" + assert data["language"] == "eng" + assert data["description"] == "A free encyclopedia" + assert data["long_description"] == "Wikipedia is a free online encyclopedia." + assert data["illustration_48x48_at_1"] == illustration_48x48_at_1 + assert data["license"] == "CC-BY-SA" + assert data["relation"] == "wikipedia" + assert data["source"] == "https://en.wikipedia.org" + + # Verify the metadata was updated in the database + dbsession.refresh(title) + assert title.title == "Wikipedia in English" + assert title.creator == "Wikipedia Contributors" + assert title.publisher == "Kiwix" + assert title.language == "eng" + assert title.description == "A free encyclopedia" + assert title.long_description == "Wikipedia is a free online encyclopedia." + assert title.illustration_48x48_at_1 == illustration_48x48_at_1 + assert title.license == "CC-BY-SA" + assert title.relation == "wikipedia" + assert title.source == "https://en.wikipedia.org" + + @pytest.mark.parametrize( "permission,expected_status_code", [ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 74d63c9d..094704b4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -157,14 +157,34 @@ def _create_title( *, name: str = "test_en_all", archived: bool = False, + title: str | None = None, + creator: str | None = None, + publisher: str | None = None, + description: str | None = None, + language: str | None = None, + illustration_48x48_at_1: str | None = None, + long_description: str | None = None, + license: str | None = None, # noqa: A002 + relation: str | None = None, + source: str | None = None, ) -> Title: - title = Title( + db_title = Title( name=name, + title=title, archived=archived, + creator=creator, + publisher=publisher, + description=description, + language=language, + illustration_48x48_at_1=illustration_48x48_at_1, + long_description=long_description, + license=license, + relation=relation, + source=source, ) - dbsession.add(title) + dbsession.add(db_title) dbsession.flush() - return title + return db_title return _create_title @@ -392,3 +412,8 @@ def _create_event( return event return _create_event + + +@pytest.fixture() +def illustration_48x48_at_1(): + return """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""" # noqa: E501 diff --git a/backend/tests/db/test_book.py b/backend/tests/db/test_book.py index 826eac58..02dacc04 100644 --- a/backend/tests/db/test_book.py +++ b/backend/tests/db/test_book.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session as OrmSession from cms_backend.db.book import create_book as db_create_book -from cms_backend.db.book import update_book +from cms_backend.db.book import get_differing_metadata_keys, update_book from cms_backend.db.exceptions import RecordDoesNotExistError from cms_backend.db.models import Book, Title, ZimfarmNotification @@ -39,6 +39,42 @@ def test_create_book( ) +def test_get_differing_metadata_keys( + dbsession: OrmSession, + create_title: Callable[..., Title], + create_book: Callable[..., Book], + illustration_48x48_at_1: str, +): + """Get the different metadata keys between book and it's title.""" + title = create_title( + title="Title", + creator="Title Creator", + publisher="openZIM", + description="Title Description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + } + ) + book.title_id = title.id + dbsession.add(book) + dbsession.flush() + + differences = get_differing_metadata_keys(book) + assert set(differences) == {"Title", "Creator", "Publisher", "Description"} + + def test_update_deleted_book(dbsession: OrmSession, create_book: Callable[..., Book]): book = create_book(location_kind="deleted") with pytest.raises(RecordDoesNotExistError, match=r"Book .* is already deleted"): diff --git a/backend/tests/db/test_title.py b/backend/tests/db/test_title.py index e92ca192..85fd3cb6 100644 --- a/backend/tests/db/test_title.py +++ b/backend/tests/db/test_title.py @@ -253,3 +253,76 @@ def test_restore_title( assert title.archived is False for book in title.books: assert book.location_kind == "prod" + + +def test_update_title_metadata( + dbsession: OrmSession, + create_title: Callable[..., Title], +): + """Test updating title metadata fields""" + title = create_title(name="wikipedia_en_test") + + updated_title = update_title( + dbsession, + title_id=title.id, + _title="Wikipedia in English", + creator="Wikipedia Contributors", + publisher="Kiwix", + language="eng", + description="A free encyclopedia", + long_description="Wikipedia is a free online encyclopedia.", + illustration_48x48_at_1="data:image/png;base64,test", + license_="CC-BY-SA", + relation="wikipedia", + source="https://en.wikipedia.org", + ) + + dbsession.refresh(updated_title) + assert updated_title.title == "Wikipedia in English" + assert updated_title.creator == "Wikipedia Contributors" + assert updated_title.publisher == "Kiwix" + assert updated_title.language == "eng" + assert updated_title.description == "A free encyclopedia" + assert updated_title.long_description == "Wikipedia is a free online encyclopedia." + assert updated_title.illustration_48x48_at_1 == "data:image/png;base64,test" + assert updated_title.license == "CC-BY-SA" + assert updated_title.relation == "wikipedia" + assert updated_title.source == "https://en.wikipedia.org" + + assert any("title updated" in event for event in updated_title.events) + assert any("creator updated" in event for event in updated_title.events) + assert any("publisher updated" in event for event in updated_title.events) + assert any("language updated" in event for event in updated_title.events) + assert any("description updated" in event for event in updated_title.events) + assert any("long description updated" in event for event in updated_title.events) + assert any("license updated" in event for event in updated_title.events) + assert any("relation updated" in event for event in updated_title.events) + assert any("source updated" in event for event in updated_title.events) + + +def test_update_title_metadata_no_change( + dbsession: OrmSession, + create_title: Callable[..., Title], +): + """Test updating title with same values doesn't create events""" + title = create_title(name="wikipedia_en_test") + + update_title( + dbsession, + title_id=title.id, + _title="Wikipedia", + creator="Contributors", + ) + dbsession.refresh(title) + + initial_event_count = len(title.events) + + update_title( + dbsession, + title_id=title.id, + _title="Wikipedia", + creator="Contributors", + ) + dbsession.refresh(title) + + assert len(title.events) == initial_event_count diff --git a/backend/tests/mill/processors/test_zimfarm_notification.py b/backend/tests/mill/processors/test_zimfarm_notification.py index e2d801cf..ed684022 100644 --- a/backend/tests/mill/processors/test_zimfarm_notification.py +++ b/backend/tests/mill/processors/test_zimfarm_notification.py @@ -34,6 +34,7 @@ "Date": "2025-01-01", "Description": "Test description", "Language": "eng", + "Illustration_48x48@1": "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", # noqa: E501 }, "zimcheck_url": "https://www.example.com/zimcheck.json", "folder_name": "test_folder", @@ -242,6 +243,81 @@ class TestValidNotificationWithMatchingTitleUnstableMaturity: Unstable maturity titles should have their books moved to staging. """ + def test_set_missing_title_metadata_from_book( + self, + dbsession: OrmSession, + warehouse: Warehouse, # noqa: ARG002 + create_zimfarm_notification: Callable[..., ZimfarmNotification], + create_title: Callable[..., Title], + ): + """ + Set title metadata from book because title has no metadata set + """ + # Create title that matches book name + title = create_title(name="test_en_all") + title.maturity = "unstable" + dbsession.flush() + + notification = create_zimfarm_notification(content=VALID_NOTIFICATION_CONTENT) + dbsession.flush() + + process_notification(dbsession, notification) + + assert notification.status == "processed" + + book = dbsession.query(Book).filter_by(id=notification.id).first() + assert book is not None + assert len(book.issues) == 0 + assert book.title_id == title.id + + dbsession.refresh(title) + assert title.title == book.zim_metadata["Title"] + assert title.creator == book.zim_metadata["Creator"] + assert title.publisher == book.zim_metadata["Publisher"] + assert title.description == book.zim_metadata["Description"] + assert title.language == book.zim_metadata["Language"] + + def test_preserve_title_metadata( + self, + dbsession: OrmSession, + warehouse: Warehouse, # noqa: ARG002 + create_zimfarm_notification: Callable[..., ZimfarmNotification], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, + ): + """ + Preserve existing title metadata even though book has different metadata + """ + # Create title that matches book name with all metadata matching with book + # except for language + title = create_title( + name="test_en_all", + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test Description", + language="ger", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + title.maturity = "unstable" + dbsession.flush() + + notification = create_zimfarm_notification(content=VALID_NOTIFICATION_CONTENT) + dbsession.flush() + + process_notification(dbsession, notification) + + assert notification.status == "processed" + + book = dbsession.query(Book).filter_by(id=notification.id).first() + assert book is not None + assert book.title_id == title.id + assert len(book.issues) == 1 + assert set(book.issues) == {"metadata mismatch"} + + dbsession.refresh(title) + assert title.language != book.zim_metadata["Language"] + def test_moves_book_to_staging( self, dbsession: OrmSession, @@ -447,6 +523,65 @@ def test_moves_book_to_collection_warehouses_with_empty_folder_name( assert book.needs_file_operation is True assert book.needs_processing is False + def test_moves_book_to_staging_due_to_diffrent_metadata_from_title( + self, + dbsession: OrmSession, + warehouse: Warehouse, # noqa: ARG002 + create_zimfarm_notification: Callable[..., ZimfarmNotification], + create_title: Callable[..., Title], + create_collection: Callable[..., Collection], + create_warehouse: Callable[..., Warehouse], + illustration_48x48_at_1: str, + ): + """ + Test that book goes to staging because there is a metadata mismatch between + it and it's title + """ + + # Create title that matches book name with all metadata matching with book + # except for language + title = create_title( + name="test_en_all", + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test Description", + language="ger", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + title.maturity = "stable" + + prod = create_warehouse( + name="prod", warehouse_id=UUID("00000000-0000-0000-0000-000000000003") + ) + collection = create_collection(warehouse=prod) + + ct = CollectionTitle(path=Path("wikipedia")) + ct.title = title + ct.collection = collection + dbsession.add(ct) + dbsession.flush() + + content = VALID_NOTIFICATION_CONTENT.copy() + content["folder_name"] = "" + + notification = create_zimfarm_notification(content=content) + dbsession.flush() + + process_notification(dbsession, notification) + + assert notification.status == "processed" + + book = dbsession.query(Book).filter_by(id=notification.id).first() + assert book is not None + assert book.title_id == title.id + assert book.location_kind == "staging" + assert len(book.issues) == 1 + assert set(book.issues) == {"metadata mismatch"} + assert book.has_error is False + assert book.needs_file_operation is True + assert book.needs_processing is False + class TestValidNotificationOnArchivedTitle: """Test valid notifications that are associated to an archived title.""" diff --git a/backend/tests/mill/test_process_title_modifications.py b/backend/tests/mill/test_process_title_modifications.py index 8dd79b9f..384de48d 100644 --- a/backend/tests/mill/test_process_title_modifications.py +++ b/backend/tests/mill/test_process_title_modifications.py @@ -102,6 +102,7 @@ def test_process_title_modifications_processes_matching_book( dbsession: OrmSession, create_title: Callable[..., Title], create_book: Callable[..., Book], + illustration_48x48_at_1: str, ): """Test that matching books are processed""" title = create_title(name="wikipedia_en_all") @@ -118,6 +119,7 @@ def test_process_title_modifications_processes_matching_book( "Date": "2025-01", "Description": "Wikipedia Encyclopedia", "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, }, ) book.has_error = False diff --git a/dev/README.md b/dev/README.md index 39391825..751fbbab 100644 --- a/dev/README.md +++ b/dev/README.md @@ -126,6 +126,45 @@ docker exec cms_shuttle python /scripts/wipe.py This is useful when you need to reset everything to a clean state before re-running setup scripts. +### Import production DB dump + +If you have access to a production DB dump, you can restore it locally. + +Mount you dump at `/data/cms` in PG container. + +Drop and recreate the `cms` database: + +``` +docker exec -it cms_postgresdb bash -c \ + 'psql -U cms -d postgres -c "DROP DATABASE cms WITH (FORCE);" -c "CREATE DATABASE cms;"' +``` + +Restore DB dump (assuming it is mounted in /data/cms) + +``` +docker exec -it cms_postgresdb bash -c \ + 'pg_restore -U cms -d cms /data/cms' +``` + +Delete admin user so that it is recreated by API startup with admin/admin_pass credentials: + +``` +docker exec -it cms_postgresdb bash -c \ + "psql -U cms -d cms -c \"DELETE FROM account WHERE username='admin';\"" +``` + +Restart the API: + +``` +docker restart cms_api +``` + +Create missing ZIM files locally so that shuttle operations works fine (it will create empty files with `touch`). + +```sh +docker exec cms_mill python /scripts/setup_books.py +``` + ### Restart the backend The backend might typically fail if the DB schema is not up-to-date, or if you create some nasty bug while modifying the code. diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 6a0d2dd3..392cf45a 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -103,7 +103,7 @@ services: environment: DEBUG: 1 DATABASE_URL: postgresql+psycopg://cms:cmspass@postgresdb:5432/cms - LOCAL_WAREHOUSE_PATHS: "11111111-1111-1111-1111-111111111111:/warehouses/hidden,22222222-2222-2222-2222-222222222222:/warehouses/prod,33333333-3333-3333-3333-333333333333:/warehouses/client1" + LOCAL_WAREHOUSE_PATHS: "11111111-1111-1111-1111-111111111111:/warehouses/dev_hidden,22222222-2222-2222-2222-222222222222:/warehouses/dev_prod,33333333-3333-3333-3333-333333333333:/warehouses/dev_client1" STAGING_WAREHOUSE_ID: 11111111-1111-1111-1111-111111111111 STAGING_BASE_PATH: staging STAGING_DOWNLOAD_BASE_URL: https://download.staging.acme.org/ diff --git a/dev/scripts/setup_books.py b/dev/scripts/setup_books.py new file mode 100644 index 00000000..a120c614 --- /dev/null +++ b/dev/scripts/setup_books.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Prod book files setup script. + +Creates files for the prod books in the DB at their locations so the shuttle can act on them +""" + +import sys +from pathlib import Path + +# Add backend source to path for imports +sys.path.insert(0, "/usr/local/lib/python3.13/site-packages") + +from sqlalchemy import select +from cms_backend.db import Session +from cms_backend.db.models import Book +from cms_backend.db.book import get_book + + +# Base directory where warehouse folders will be created (inside container) +WAREHOUSE_BASE_PATH = Path("/warehouses") + + +def create_book_files(): + """Create files for existing books in the DB""" + print("\nCreating dummy files for books in DB") + with Session.begin() as session: + book_ids = session.scalars( + select(Book.id).where(Book.location_kind != "deleted") + ).all() + for book_id in book_ids: + book = get_book(session, book_id) + current_locations = [ + location for location in book.locations if location.status == "current" + ] + for location in current_locations: + physical_path = ( + WAREHOUSE_BASE_PATH / Path(location.warehouse.name) / location.path + ) + physical_path.mkdir(parents=True, exist_ok=True) + dest = physical_path / location.filename + dest.touch(exist_ok=True) + print(f"Created file for book {book.name} at {dest}") + + +if __name__ == "__main__": + create_book_files() diff --git a/dev/scripts/setup_collections.py b/dev/scripts/setup_collections.py index 73d1d28f..e28455e6 100644 --- a/dev/scripts/setup_collections.py +++ b/dev/scripts/setup_collections.py @@ -17,11 +17,11 @@ # Configuration: Define collections COLLECTIONS_CONFIG = { - "prod": { + "dev_prod": { "id": UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), "warehouse_id": UUID("22222222-2222-2222-2222-222222222222"), }, - "client1": { + "dev_client1": { "id": UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), "warehouse_id": UUID("33333333-3333-3333-3333-333333333333"), }, diff --git a/dev/scripts/setup_notifications.py b/dev/scripts/setup_notifications.py index 02834a05..74c66735 100644 --- a/dev/scripts/setup_notifications.py +++ b/dev/scripts/setup_notifications.py @@ -42,7 +42,7 @@ "media_count": 5000, "size": 1024000000, "metadata": { - "Name": "wikipedia_en_all", + "Name": "dev_wikipedia_en_all", "Title": "Wikipedia English All Maxi", "Creator": "openZIM", "Publisher": "Kiwix", @@ -53,14 +53,14 @@ "Illustration_48x48@1": FAVICON_BLUE, }, "folder_name": "wikipedia", - "filename": "wikipedia_en_all_maxi_2025-01.zim", + "filename": "dev_wikipedia_en_all_maxi_2025-01.zim", }, { "article_count": 500, "media_count": 200, "size": 50000000, "metadata": { - "Name": "wiktionary_fr_all", + "Name": "dev_wiktionary_fr_all", "Title": "Wiktionnaire Francais", "Creator": "openZIM", "Publisher": "Kiwix", @@ -71,14 +71,14 @@ "Illustration_48x48@1": FAVICON_GREEN, }, "folder_name": "wiktionary", - "filename": "wiktionary_fr_all_maxi_2025-01.zim", + "filename": "dev_wiktionary_fr_all_maxi_2025-01.zim", }, { "article_count": 1500, "media_count": 2020, "size": 40000, "metadata": { - "Name": "wiktionary_en_all", + "Name": "dev_wiktionary_en_all", "Title": "English Wiktionary", "Creator": "openZIM", "Publisher": "Kiwix", @@ -89,7 +89,7 @@ "Illustration_48x48@1": FAVICON_RED, }, "folder_name": "", - "filename": "wiktionary_en_all_maxi_2025-01.zim", + "filename": "dev_wiktionary_en_all_maxi_2025-01.zim", }, ] @@ -109,7 +109,7 @@ def create_notifications(): # Check if file already exists in warehouse file_path = ( - WAREHOUSE_BASE_PATH / "hidden/quarantine" / folder_name / filename + WAREHOUSE_BASE_PATH / "dev_hidden/quarantine" / folder_name / filename ) if file_path.exists(): print(f" - File already exists at {file_path} (skipping)") diff --git a/dev/scripts/setup_titles.py b/dev/scripts/setup_titles.py index 5b3ebaa2..c01607f2 100644 --- a/dev/scripts/setup_titles.py +++ b/dev/scripts/setup_titles.py @@ -11,15 +11,15 @@ # Configuration: Define titles and their collection path associations TITLES_CONFIG = [ { - "name": "wikipedia_en_all", - "maturity": "dev", + "name": "dev_wikipedia_en_all", + "maturity": "unstable", "collections": [ {"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "path": "wikipedia"} ], }, { - "name": "wiktionary_fr_all", - "maturity": "robust", + "name": "dev_wiktionary_fr_all", + "maturity": "stable", "collections": [ {"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "path": "other"}, {"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "path": "all"}, diff --git a/dev/scripts/setup_warehouses.py b/dev/scripts/setup_warehouses.py index 1e677c2c..728924bd 100644 --- a/dev/scripts/setup_warehouses.py +++ b/dev/scripts/setup_warehouses.py @@ -19,15 +19,15 @@ # Configuration: Define warehouses and their paths # UUIDs must match those in docker-compose.yml LOCAL_WAREHOUSE_PATHS WAREHOUSES_CONFIG = { - "hidden": { + "dev_hidden": { "id": UUID("11111111-1111-1111-1111-111111111111"), "paths": ["quarantine", "staging"], }, - "prod": { + "dev_prod": { "id": UUID("22222222-2222-2222-2222-222222222222"), "paths": ["other", "wikipedia"], }, - "client1": { + "dev_client1": { "id": UUID("33333333-3333-3333-3333-333333333333"), "paths": ["all"], }, diff --git a/dev/scripts/wipe.py b/dev/scripts/wipe.py index ec773273..a50c4748 100644 --- a/dev/scripts/wipe.py +++ b/dev/scripts/wipe.py @@ -2,14 +2,16 @@ """ Development wipe script. -Deletes all data from the database and all ZIM files from warehouses. +Deletes all dev data from the database and all ZIM files from warehouses. Run inside the shuttle container: docker exec cms_shuttle python /scripts/wipe.py """ from pathlib import Path +from sqlalchemy import delete, select from cms_backend.db import Session +from sqlalchemy.orm import Session as OrmSession from cms_backend.db.models import ( Book, BookLocation, @@ -23,63 +25,100 @@ # Base directory where warehouse folders are located (inside container) WAREHOUSE_BASE_PATH = Path("/warehouses") +DEV_PREFIX = "dev\\_%" -def wipe_database(session): - """Delete all data from the database in the correct order.""" - print("Wiping database...") +def wipe_database(session: OrmSession): + """Delete all dev data from the database in the correct order.""" + print("Wiping dev entries in database...") # Delete in order respecting foreign key constraints # (children before parents) # 1. BookLocation (depends on Book and WarehousePath) - count = session.query(BookLocation).delete() + count = session.execute( + delete(BookLocation).where(BookLocation.filename.like(DEV_PREFIX)) + ).rowcount print(f" - Deleted {count} BookLocation records") # 2. ZimfarmNotification (depends on Book) - count = session.query(ZimfarmNotification).delete() + count = session.execute( + delete(ZimfarmNotification).where( + ZimfarmNotification.content.has_key("filename"), + ZimfarmNotification.content["filename"].astext.like(DEV_PREFIX), + ) + ).rowcount print(f" - Deleted {count} ZimfarmNotification records") # 3. Book (depends on Title) - count = session.query(Book).delete() + count = session.execute( + delete(Book).where(Book.name.is_not(None), Book.name.like(DEV_PREFIX)) + ).rowcount print(f" - Deleted {count} Book records") # 4. CollectionTitle (depends on Title and Collection) - count = session.query(CollectionTitle).delete() + count = session.execute( + delete(CollectionTitle).where( + CollectionTitle.title_id.in_( + select(Title.id).where(Title.name.like(DEV_PREFIX)) + ) + ) + ).rowcount print(f" - Deleted {count} CollectionTitle records") # 5. Title - count = session.query(Title).delete() + count = session.execute(delete(Title).where(Title.name.like(DEV_PREFIX))).rowcount print(f" - Deleted {count} Title records") # 7. Collection (depends on Warehouse) - count = session.query(Collection).delete() + count = session.execute( + delete(Collection).where(Collection.name.like(DEV_PREFIX)) + ).rowcount print(f" - Deleted {count} Collection records") # 9. Warehouse - count = session.query(Warehouse).delete() - print(f" - Deleted {count} Warehouse records") + warehouses = session.scalars( + select(Warehouse.name).where(Warehouse.name.like(DEV_PREFIX)) + ).all() + + count = session.execute( + delete(Warehouse).where(Warehouse.name.like(DEV_PREFIX)) + ).rowcount + print(f" - Deleted {count} Warehouse records") -def wipe_warehouse_files(): - """Delete all ZIM files in warehouse directories.""" print("\nWiping warehouse files...") - if not WAREHOUSE_BASE_PATH.exists(): - print(f" - Warehouse path {WAREHOUSE_BASE_PATH} does not exist") - return + nb_files_deleted = 0 + for warehouse in warehouses: + nb_files_deleted += wipe_warehouse_files(warehouse) + + print("\n+ Warehouse files wiped successfully") + + print(f" - Total files deleted: {nb_files_deleted}") - zim_files = list(WAREHOUSE_BASE_PATH.rglob("*.zim")) + +def wipe_warehouse_files(warehouse: str) -> int: + warehouse_path = WAREHOUSE_BASE_PATH / Path(warehouse) + + if not warehouse_path.exists(): + print(f" - Warehouse path {warehouse_path} does not exist") + return 0 + + zim_files = list(warehouse_path.rglob("*.zim")) if not zim_files: print(" - No ZIM files to delete") - return + return 0 + + nb_files_deleted = 0 for file_path in zim_files: file_path.unlink() + nb_files_deleted += 1 print(f" - Deleted {file_path}") - print(f" - Total files deleted: {len(zim_files)}") + return nb_files_deleted def wipe(): @@ -91,9 +130,6 @@ def wipe(): session.commit() print("\n+ Database wiped successfully") - wipe_warehouse_files() - print("\n+ Warehouse files wiped successfully") - except Exception as e: session.rollback() print(f"\n- Error: {e}") diff --git a/frontend/package.json b/frontend/package.json index d711a7e3..28529320 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "dependencies": { "axios": "^1.12.0", "deep-diff": "^1.0.2", - "filesize": "^11.0.7", + "filesize": "^11.0.17", "fuse.js": "^7.1.0", "jwt-decode": "^4.0.0", "luxon": "^3.6.1", @@ -24,6 +24,7 @@ "split-by-grapheme": "^1.0.1", "vite-plugin-vuetify": "^2.1.1", "vue": "^3.5.17", + "vue-advanced-cropper": "^2.8.9", "vue-matomo": "^4.2.0", "vue-router": "^4.5.1", "vuetify": "^3.8.11" diff --git a/frontend/src/components/BookStatus.vue b/frontend/src/components/BookStatus.vue index 6d5decd5..ed3dbff9 100644 --- a/frontend/src/components/BookStatus.vue +++ b/frontend/src/components/BookStatus.vue @@ -44,10 +44,36 @@ size="x-small" :color="locationColor" variant="flat" - class="align-self-start" + class="align-self-start mb-1 mr-1" > {{ locationLabel }} + + + + + + + + + {{ warning }} + + + @@ -78,6 +104,7 @@ const isMovingFiles = computed( props.book.location_kind !== 'deleted', ) const hasTitle = computed(() => props.book.title_id) +const hasIssues = computed(() => props.book.issues && props.book.issues.length > 0) const showLocationChip = computed(() => { // If the evaluated status is 'Errored' or 'Processing', we want to show the location chip diff --git a/frontend/src/components/EditTitleDialog.vue b/frontend/src/components/EditTitleDialog.vue deleted file mode 100644 index e10352e8..00000000 --- a/frontend/src/components/EditTitleDialog.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/frontend/src/components/ImageEditor.vue b/frontend/src/components/ImageEditor.vue new file mode 100644 index 00000000..82939255 --- /dev/null +++ b/frontend/src/components/ImageEditor.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/components/InlineImageEditor.vue b/frontend/src/components/InlineImageEditor.vue new file mode 100644 index 00000000..17b6a506 --- /dev/null +++ b/frontend/src/components/InlineImageEditor.vue @@ -0,0 +1,562 @@ + + + + + diff --git a/frontend/src/components/TitleForm.vue b/frontend/src/components/TitleForm.vue new file mode 100644 index 00000000..ac2df24c --- /dev/null +++ b/frontend/src/components/TitleForm.vue @@ -0,0 +1,857 @@ + + + + + diff --git a/frontend/src/components/TitleFormDialog.vue b/frontend/src/components/TitleFormDialog.vue index 917db03c..e68a090d 100644 --- a/frontend/src/components/TitleFormDialog.vue +++ b/frontend/src/components/TitleFormDialog.vue @@ -2,113 +2,17 @@ - {{ isEditMode ? 'Edit Title' : 'Create New Title' }} + Create New Title - - - - - - - - - -
-

Collection Paths

- - Add Collection - -
- - - No collections added. - - -
-
- Collection #{{ index + 1 }} - -
- - - - -
- - - Modifying title collections settings will cause books in production to be altered as - specified. Beware of potential impact of removing a book from a location already in use - by the library or currently being downloaded by users. - -
+ {{ error }} @@ -123,9 +27,9 @@ variant="elevated" @click="handleSubmit" :loading="loading" - :disabled="!formValid || loading || (isEditMode && !hasChanges)" + :disabled="!formValid || loading" > - {{ isEditMode ? 'Save Changes' : 'Create Title' }} + Create Title
@@ -133,9 +37,9 @@ - - diff --git a/frontend/src/stores/title.ts b/frontend/src/stores/title.ts index fe0dafba..3e43ebb8 100644 --- a/frontend/src/stores/title.ts +++ b/frontend/src/stores/title.ts @@ -113,7 +113,6 @@ export const useTitleStore = defineStore('title', () => { } catch (_error) { console.error('Failed to create title', _error) errors.value = translateErrors(_error as ErrorResponse) - throw _error } } @@ -126,7 +125,6 @@ export const useTitleStore = defineStore('title', () => { } catch (_error) { console.error('Failed to update title', _error) errors.value = translateErrors(_error as ErrorResponse) - throw _error } } diff --git a/frontend/src/types/book.ts b/frontend/src/types/book.ts index 79f5e9a9..bfca9add 100644 --- a/frontend/src/types/book.ts +++ b/frontend/src/types/book.ts @@ -21,6 +21,7 @@ export interface BookLight { needs_file_operation: boolean location_kind: LocationKind created_at: string + issues: string[] deletion_date?: string name?: string date?: string diff --git a/frontend/src/types/title.ts b/frontend/src/types/title.ts index 29d0fabe..be497e46 100644 --- a/frontend/src/types/title.ts +++ b/frontend/src/types/title.ts @@ -20,6 +20,16 @@ export interface TitleLight { name: string maturity: string archived: boolean + title: string | null + creator: string | null + publisher: string | null + description: string | null + language: string | null + illustration_48x48_at_1: string | null + long_description: string | null + license: string | null + relation: string | null + source: string | null } export interface Title extends TitleLight { @@ -32,10 +42,30 @@ export interface TitleCreate { name: string maturity: string collection_titles: BaseTitleCollection[] + title?: string | null + creator?: string | null + publisher?: string | null + description?: string | null + language?: string | null + illustration_48x48_at_1?: string | null + long_description?: string | null + license?: string | null + relation?: string | null + source?: string | null } export interface TitleUpdate { name?: string maturity: string collection_titles: BaseTitleCollection[] + title?: string | null + creator?: string | null + publisher?: string | null + description?: string | null + language?: string | null + illustration_48x48_at_1?: string | null + long_description?: string | null + license?: string | null + relation?: string | null + source?: string | null } diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index ee996804..7498e798 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -1,3 +1,5 @@ +import { filesize } from 'filesize' + import { DateTime } from 'luxon' export function fromNow(value: string) { @@ -14,3 +16,7 @@ export function formatDt(value?: string, format: string = 'fff') { if (!dt.isValid) return value return dt.toFormat(format) } + +export function formattedBytesSize(value: number) { + return filesize(value, { base: 2, standard: 'iec', precision: 3 }) // display in KiB, MiB,... instead of KB, MB,... +} diff --git a/frontend/src/views/BookView.vue b/frontend/src/views/BookView.vue index 084287d9..388eafe1 100644 --- a/frontend/src/views/BookView.vue +++ b/frontend/src/views/BookView.vue @@ -342,7 +342,9 @@ import { useAuthStore } from '@/stores/auth' import { useLoadingStore } from '@/stores/loading' import { useNotificationStore } from '@/stores/notification' import { useBookStore } from '@/stores/book' +import { useTitleStore } from '@/stores/title' import type { Book, ZimUrl } from '@/types/book' +import type { Title } from '@/types/title' import { formatDt, fromNow } from '@/utils/format' import { computed, onMounted, ref, watch } from 'vue' import { useDisplay } from 'vuetify' @@ -353,9 +355,11 @@ const loadingStore = useLoadingStore() const bookStore = useBookStore() const notificationStore = useNotificationStore() const authStore = useAuthStore() +const titleStore = useTitleStore() const error = ref(null) const book = ref(null) +const title = ref(null) const dataLoaded = ref(false) const loadingUrls = ref(false) const zimUrls = ref<ZimUrl[]>([]) @@ -375,14 +379,14 @@ const props = withDefaults(defineProps<Props>(), { selectedTab: 'info', }) +const currentTab = ref(props.selectedTab) + const locationHeaders = [ { title: 'Warehouse', value: 'warehouse_name', sortable: false }, { title: 'Folder', value: 'path', sortable: false }, { title: 'Filename', value: 'filename', sortable: false }, ] -const currentTab = ref(props.selectedTab) - const canEditBook = computed(() => { if (!book.value) return false return authStore.hasPermission('book', 'update') && !book.value.title_archived @@ -429,6 +433,20 @@ const canRecoverBook = computed(() => { ) }) +const loadTitleData = async () => { + if (!book.value?.title_id) { + title.value = null + return + } + + const data = await titleStore.fetchTitleById(book.value.title_id, false) + if (data) { + title.value = data + } else { + title.value = null + } +} + const loadData = async (forceReload: boolean = false) => { loadingStore.startLoading('Fetching book...') @@ -437,6 +455,7 @@ const loadData = async (forceReload: boolean = false) => { error.value = null book.value = data dataLoaded.value = true + await loadTitleData() } else { error.value = 'Failed to load book' for (const err of bookStore.errors) { diff --git a/frontend/src/views/TitleView.vue b/frontend/src/views/TitleView.vue index 4f9566c8..fecfa207 100644 --- a/frontend/src/views/TitleView.vue +++ b/frontend/src/views/TitleView.vue @@ -9,12 +9,6 @@ </div> <div v-if="dataLoaded && title"> - <div class="d-flex justify-end mb-4" v-if="canEditTitle"> - <v-btn color="primary" prepend-icon="mdi-pencil" @click="openEditDialog"> - Edit Title - </v-btn> - </div> - <v-tabs v-model="currentTab" class="mb-4" @@ -35,6 +29,19 @@ Info </v-tab> + <v-tab + base-color="primary" + v-if="canEditTitle" + value="edit" + :to="{ + name: 'title-detail-tab', + params: { id: title.name, selectedTab: 'edit' }, + }" + > + <v-icon class="mr-2">mdi-pencil</v-icon> + Edit + </v-tab> + <v-tab base-color="primary" v-if="canArchiveTitle" @@ -108,6 +115,125 @@ </v-row> <v-divider class="my-2"></v-divider> + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Title</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.title">{{ title.title }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Description</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.description">{{ title.description }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Long Description</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.long_description" style="white-space: pre-wrap">{{ + title.long_description + }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Creator</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.creator">{{ title.creator }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Publisher</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.publisher">{{ title.publisher }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Language</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.language">{{ title.language }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">License</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.license">{{ title.license }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Source</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.source">{{ title.source }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Relation</div> + </v-col> + <v-col cols="12" md="9"> + <span v-if="title.relation">{{ title.relation }}</span> + <span v-else class="text-grey">Not set</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + + <v-row no-gutters class="py-2"> + <v-col cols="12" md="3"> + <div class="text-subtitle-2">Illustration</div> + </v-col> + <v-col cols="12" md="9"> + <div v-if="title.illustration_48x48_at_1"> + <v-img + :src="getIllustrationSrc" + max-width="48" + max-height="48" + alt="Title illustration" + /> + </div> + <span v-else class="text-grey">No illustration</span> + </v-col> + </v-row> + <v-divider class="my-2"></v-divider> + <v-row no-gutters class="py-2"> <v-col cols="12" md="3"> <div class="text-subtitle-2">Events</div> @@ -140,6 +266,71 @@ </v-card> </v-window-item> + <!-- Edit Tab --> + <v-window-item value="edit"> + <div v-if="canEditTitle" class="pa-4"> + <v-card flat> + <div class="d-flex flex-column flex-sm-row justify-end ga-2 px-4 pt-4"> + <v-btn + :color="updating || !hasChanges ? undefined : 'default'" + variant="outlined" + @click="handleReset" + :disabled="updating || !hasChanges" + > + <v-icon class="mr-2">mdi-restore</v-icon> + Reset + </v-btn> + <v-btn + :color="!formValid || updating || !hasChanges ? undefined : 'primary'" + variant="elevated" + @click="handleUpdate" + :loading="updating" + :disabled="!formValid || updating || !hasChanges" + > + <v-icon class="mr-2">mdi-content-save</v-icon> + Save Changes + </v-btn> + </div> + + <v-card-text> + <TitleForm + ref="titleFormRef" + :title="title" + :latest-book="latestBook" + @update:valid="formValid = $event" + @update:has-changes="hasChanges = $event" + /> + + <v-alert v-if="updateError" type="error" class="mt-4" density="compact"> + {{ updateError }} + </v-alert> + </v-card-text> + + <div class="d-flex flex-column flex-sm-row justify-end ga-2 px-4 pb-4"> + <v-btn + :color="updating || !hasChanges ? undefined : 'default'" + variant="outlined" + @click="handleReset" + :disabled="updating || !hasChanges" + > + <v-icon class="mr-2">mdi-restore</v-icon> + Reset + </v-btn> + <v-btn + :color="!formValid || updating || !hasChanges ? undefined : 'primary'" + variant="elevated" + @click="handleUpdate" + :loading="updating" + :disabled="!formValid || updating || !hasChanges" + > + <v-icon class="mr-2">mdi-content-save</v-icon> + Save Changes + </v-btn> + </div> + </v-card> + </div> + </v-window-item> + <!-- Archive Tab --> <v-window-item value="archive"> <div v-if="canArchiveTitle" class="pa-4"> @@ -153,23 +344,21 @@ </v-window-item> </v-window> </div> - - <EditTitleDialog v-model="editDialogOpen" :title="title" @updated="handleTitleUpdated" /> </v-container> </template> <script setup lang="ts"> import BookTable from '@/components/BookTable.vue' -import EditTitleDialog from '@/components/EditTitleDialog.vue' import EventsList from '@/components/EventsList.vue' import ArchiveTitle from '@/components/ArchiveTitle.vue' +import TitleForm from '@/components/TitleForm.vue' import { useLoadingStore } from '@/stores/loading' import { useNotificationStore } from '@/stores/notification' import { useTitleStore } from '@/stores/title' import { useBookStore } from '@/stores/book' import { useAuthStore } from '@/stores/auth' import type { Title } from '@/types/title' -import type { ZimUrl } from '@/types/book' +import type { Book, ZimUrl } from '@/types/book' import { computed, onMounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { useDisplay } from 'vuetify' @@ -187,9 +376,16 @@ const { smAndDown } = useDisplay() const error = ref<string | null>(null) const title = ref<Title | null>(null) const dataLoaded = ref(false) -const editDialogOpen = ref(false) const loadingUrls = ref(false) const zimUrls = ref<Record<string, ZimUrl[]>>({}) +const latestBook = ref<Book | null>(null) + +// Edit form state +const titleFormRef = ref<InstanceType<typeof TitleForm>>() +const formValid = ref(false) +const hasChanges = ref(false) +const updating = ref(false) +const updateError = ref('') interface Props { id: string @@ -215,6 +411,19 @@ const canEditTitle = computed( const canArchiveTitle = computed(() => authStore.hasPermission('title', 'archive')) +// Helper to convert raw base64 to data URL +const getIllustrationSrc = computed(() => { + if (!title.value?.illustration_48x48_at_1) return '' + + const illustration = title.value.illustration_48x48_at_1 + + if (illustration.startsWith('data:')) { + return illustration + } + + return `data:image/png;base64,${illustration}` +}) + const currentTab = ref(props.selectedTab) const sortedBooks = computed(() => { @@ -224,6 +433,27 @@ const sortedBooks = computed(() => { ) }) +const loadLatestBook = async () => { + if (!title.value?.books || title.value.books.length === 0) { + latestBook.value = null + return + } + + const latestBookId = sortedBooks.value[0]?.id + if (!latestBookId) { + latestBook.value = null + return + } + + try { + const book = await bookStore.fetchBook(latestBookId, true) + latestBook.value = book + } catch (err) { + console.error('Failed to fetch latest book', err) + latestBook.value = null + } +} + const loadData = async (forceReload: boolean = false) => { loadingStore.startLoading('Fetching title...') @@ -296,33 +526,70 @@ const restoreTitle = async () => { } } -onMounted(async () => { - await loadData(true) -}) +const handleUpdate = async () => { + if (!formValid.value || !title.value) return -const openEditDialog = () => { - editDialogOpen.value = true -} + updating.value = true + updateError.value = '' + + try { + const updatePayload = titleFormRef.value?.getUpdatePayload() -const handleTitleUpdated = async (updatedTitle: { id: string; name: string }) => { - notificationStore.showSuccess('Title updated successfully!') + if (!updatePayload) { + throw new Error('Failed to get update payload') + } - // If the name changed, navigate to the new URL - if (updatedTitle.name !== props.id) { - await router.push({ name: 'title-detail', params: { id: updatedTitle.name } }) + const response = await titleStore.updateTitle(title.value.id, updatePayload) + if (!response) { + updateError.value = titleStore.errors.join(', ') || 'Failed to update title' + return + } + notificationStore.showSuccess('Title updated successfully!') + + // If the name changed, navigate to the new URL + if (response.name !== props.id) { + await router.push({ name: 'title-detail', params: { id: response.name } }) + } + + await loadData(true) + currentTab.value = 'details' + } catch (err) { + console.error('Failed to update title', err) + updateError.value = titleStore.errors.join(', ') || 'Failed to update title' + } finally { + updating.value = false } - await loadData(true) } +const handleReset = () => { + if (!title.value) return + titleFormRef.value?.resetFormToTitle(title.value) +} + +onMounted(async () => { + await loadData(true) + if (props.selectedTab === 'edit' && title.value) { + await titleFormRef.value?.fetchCollections() + await loadLatestBook() + titleFormRef.value?.resetFormToTitle(title.value) + } +}) + // Watch for tab changes watch( () => props.selectedTab, async (newTab) => { currentTab.value = newTab - // Only refresh data if we don't have any data yet, or if not archiving + if (!title.value || newTab != 'archive') { await loadData(true) } + + if (newTab === 'edit' && title.value) { + await titleFormRef.value?.fetchCollections() + await loadLatestBook() + titleFormRef.value?.resetFormToTitle(title.value) + } }, ) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e2b99722..b41dcd3e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1426,6 +1426,11 @@ check-error@^2.1.1: resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== +classnames@^2.2.6: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -1515,6 +1520,11 @@ de-indent@^1.0.2: resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== +debounce@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" @@ -1579,6 +1589,11 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +easy-bem@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/easy-bem/-/easy-bem-1.1.1.tgz#1bfcc10425498090bcfddc0f9c000aba91399e03" + integrity sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A== + editorconfig@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" @@ -1909,10 +1924,10 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" -filesize@^11.0.7: - version "11.0.7" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-11.0.7.tgz#0603fced3d92716e800a084aeaa8f2a8e4569751" - integrity sha512-HozRSaD6ZrUdUVdSI9kJPsI9TzDZb+OZNL0xOWlxkQGfOsjh5Fp1AAI7GObJutOpclSBycHBDEREzwYcXPo8Ew== +filesize@^11.0.17: + version "11.0.17" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-11.0.17.tgz#fea569a71f0f716129fd7f5110427b7868499823" + integrity sha512-oHLTvMLw6imZUl1se/RBQrFlyy50nXce4sU7yGR6Qc0JgCwqnfiFsAnEwotdGmfKLD7SArGUk2/5STU0k8LOBQ== fill-range@^7.1.1: version "7.1.1" @@ -3296,6 +3311,15 @@ vscode-uri@^3.0.8: resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== +vue-advanced-cropper@^2.8.9: + version "2.8.9" + resolved "https://registry.yarnpkg.com/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz#119ec7ade91dcf80fb22940ecbbf265ad0ae1bc4" + integrity sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw== + dependencies: + classnames "^2.2.6" + debounce "^1.2.0" + easy-bem "^1.0.2" + vue-component-type-helpers@^2.0.0: version "2.2.12" resolved "https://registry.yarnpkg.com/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz#5014787aad185a22f460ad469cc51f14524308bc"