From cdd95f5eef155072e59d91fca0ece3fec01a0d57 Mon Sep 17 00:00:00 2001 From: Uchechukwu Orji Date: Mon, 25 May 2026 16:39:12 +0100 Subject: [PATCH 1/6] add metadata to title --- backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 | 285 +++++++++ backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 | 47 ++ backend/src/cms_backend/api/routes/fields.py | 12 + backend/src/cms_backend/api/routes/titles.py | 75 ++- backend/src/cms_backend/api/routes/utils.py | 18 +- backend/src/cms_backend/db/book.py | 32 + backend/src/cms_backend/db/books.py | 3 + backend/src/cms_backend/db/collection.py | 9 +- backend/src/cms_backend/db/models.py | 15 + backend/src/cms_backend/db/staging.py | 4 + backend/src/cms_backend/db/title.py | 190 +++++- .../e70e0c595eb9_add_metadata_to_title.py | 59 ++ .../src/cms_backend/mill/processors/title.py | 37 +- backend/src/cms_backend/schemas/orms.py | 11 + backend/src/cms_backend/utils/zim.py | 1 + backend/tests/api/routes/test_titles.py | 144 +++++ backend/tests/conftest.py | 31 +- backend/tests/db/test_book.py | 41 +- backend/tests/db/test_title.py | 73 +++ .../processors/test_zimfarm_notification.py | 135 ++++ .../mill/test_process_title_modifications.py | 2 + frontend/package.json | 3 +- frontend/src/components/BookStatus.vue | 29 +- .../components/BookToTitleMetadataSync.vue | 372 +++++++++++ frontend/src/components/EditTitleDialog.vue | 30 - frontend/src/components/ImageEditor.vue | 88 +++ frontend/src/components/InlineImageEditor.vue | 562 +++++++++++++++++ frontend/src/components/TitleForm.vue | 590 ++++++++++++++++++ frontend/src/components/TitleFormDialog.vue | 357 +---------- frontend/src/router/index.ts | 9 + frontend/src/stores/title.ts | 2 - frontend/src/types/book.ts | 1 + frontend/src/types/title.ts | 30 + frontend/src/utils/format.ts | 6 + frontend/src/views/BookView.vue | 563 ++++++++++------- frontend/src/views/TitleView.vue | 285 ++++++++- frontend/yarn.lock | 32 +- 37 files changed, 3518 insertions(+), 665 deletions(-) create mode 100644 backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 create mode 100644 backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 create mode 100644 backend/src/cms_backend/migrations/versions/e70e0c595eb9_add_metadata_to_title.py create mode 100644 frontend/src/components/BookToTitleMetadataSync.vue delete mode 100644 frontend/src/components/EditTitleDialog.vue create mode 100644 frontend/src/components/ImageEditor.vue create mode 100644 frontend/src/components/InlineImageEditor.vue create mode 100644 frontend/src/components/TitleForm.vue diff --git a/backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 b/backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 new file mode 100644 index 00000000..076fb557 --- /dev/null +++ b/backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 @@ -0,0 +1,285 @@ +{ + "id": "7389b26f-a00b-443c-a33d-b5bc9d26a955", + "status": "succeeded", + "timestamp": [ + ["requested", "2026-05-13T04:02:23Z"], + ["reserved", "2026-05-22T10:46:36Z"], + ["started", "2026-05-22T10:46:47Z"], + ["scraper_started", "2026-05-22T10:46:51Z"], + ["scraper_completed", "2026-05-25T07:32:37Z"], + ["succeeded", "2026-05-25T08:14:06Z"] + ], + "recipe_name": "wikisource_en", + "worker_name": "mwoffliner4", + "updated_at": "2026-05-25T08:14:06Z", + "requested_by": "period-scheduler", + "original_recipe_name": "wikisource_en", + "context": "wikimedia_long", + "priority": 0, + "config": { + "warehouse_path": "", + "resources": { + "cpu": 3, + "memory": 7516192768, + "disk": 32212254720, + "shm": null, + "cap_add": [], + "cap_drop": [] + }, + "offliner": { + "offliner_id": "mwoffliner", + "mwUrl": "https://en.wikisource.org/", + "adminEmail": "contact@kiwix.org", + "articleList": null, + "articleListToIgnore": null, + "customMainPage": null, + "customZimTitle": null, + "customZimDescription": "Wikisource is an library of public domain texts", + "customZimLongDescription": null, + "customZimFavicon": null, + "customZimTags": null, + "customZimLanguage": null, + "publisher": "openZIM", + "filenamePrefix": null, + "format": ["nopic:nopic", "novid:maxi"], + "customFlavour": null, + "optimisationCacheUrl": "************************", + "addNamespaces": "100", + "getCategories": null, + "keepEmptyParagraphs": null, + "minifyHtml": null, + "mwWikiPath": null, + "mwActionApiPath": null, + "mwRestApiPath": null, + "mwModulePath": null, + "mwIndexPhpPath": null, + "mwDomain": null, + "mwUsername": null, + "mwPassword": null, + "osTmpDir": "/dev/shm", + "outputDirectory": "/output", + "requestTimeout": null, + "speed": null, + "withoutZimFullTextIndex": null, + "verbose": "log", + "webp": true, + "forceRender": "ActionParse", + "forceSkin": null, + "insecure": null, + "langVariant": null + }, + "platform": "wikimedia", + "artifacts_globs": [], + "monitor": false, + "image": { "name": "ghcr.io/openzim/mwoffliner", "tag": "1.17.5" }, + "mount_point": "/output", + "command": [ + "mwoffliner", + "--mwUrl=https://en.wikisource.org/", + "--adminEmail=contact@kiwix.org", + "--customZimDescription='Wikisource is an library of public domain texts'", + "--publisher=openZIM", + "--format=nopic:nopic", + "--format=novid:maxi", + "--optimisationCacheUrl='************************'", + "--addNamespaces=100", + "--osTmpDir=/dev/shm", + "--outputDirectory=/output", + "--verbose=log", + "--webp", + "--forceRender=ActionParse" + ], + "str_command": "mwoffliner --mwUrl=https://en.wikisource.org/ --adminEmail=contact@kiwix.org --customZimDescription='Wikisource is an library of public domain texts' --publisher=openZIM --format=nopic:nopic --format=novid:maxi --optimisationCacheUrl='************************' --addNamespaces=100 --osTmpDir=/dev/shm --outputDirectory=/output --verbose=log --webp --forceRender=ActionParse" + }, + "events": [ + { "code": "requested", "timestamp": "2026-05-13T04:02:23Z" }, + { "code": "reserved", "timestamp": "2026-05-22T10:46:36Z" }, + { "code": "started", "timestamp": "2026-05-22T10:46:47Z" }, + { "code": "scraper_started", "timestamp": "2026-05-22T10:46:51Z" }, + { "code": "scraper_completed", "timestamp": "2026-05-25T07:32:37Z" }, + { "code": "succeeded", "timestamp": "2026-05-25T08:14:06Z" } + ], + "debug": { + "log": "[2026-05-22 10:46:46,531: INFO] starting zimfarm task-worker for 7389b26f-a00b-443c-a33d-b5bc9d26a955.\n[2026-05-22 10:46:46,531: INFO] configuration:\n\twokrker_name=mwoffliner4\n\twebapi_uris=['https://api.farm.openzim.org/v2']\n\tworkdir=/data\n\ttask_id=7389b26f-a00b-443c-a33d-b5bc9d26a955\n[2026-05-22 10:46:46,531: INFO] testing workdir at /data…\n[2026-05-22 10:46:46,531: INFO] \tworkdir is available and writable\n[2026-05-22 10:46:46,531: INFO] testing private key at /etc/ssh/keys/zimfarm…\n[2026-05-22 10:46:46,536: INFO] \tprivate key is available and readable (SHA256:bvIpNQHY3pruOe4/1wPsdAifK1Y0JH203Fu17wgWM9s)\n[2026-05-22 10:46:46,536: INFO] testing authentication with https://api.farm.openzim.org/v2…\n[2026-05-22 10:46:46,873: INFO] \tauthentication successful\n[2026-05-22 10:46:46,873: INFO] testing docker API on /var/run/docker.sock…\n[2026-05-22 10:46:46,936: INFO] \tdocker API access successful\n[2026-05-22 10:46:46,954: INFO] Hardware resources:\n\tCPU : 6 (total) ; 3 (avail)\n\tRAM : 30 GiB (total) ; 15 GiB (avail)\n\tDisk: 200 GiB (configured) ; 70 GiB (avail) ; 130 GiB (reserved) ; \n[2026-05-22 10:46:46,954: INFO] registering exit signals\n[2026-05-22 10:46:46,954: INFO] Fetching task details for 7389b26f-a00b-443c-a33d-b5bc9d26a955\n[2026-05-22 10:46:47,336: INFO] Updating task-status=started\n[2026-05-22 10:46:47,736: INFO] Setting-up workdir\n[2026-05-22 10:46:47,745: INFO] Starting DNS cache\n[2026-05-22 10:46:48,692: DEBUG] DNS Cache started using IPs: ['172.17.0.8']\n[2026-05-22 10:46:48,693: INFO] Starting scraper. Expects files at: /data/volume1/zimfarm/data/7389b26f-a00b-443c-a33d-b5bc9d26a955 \n[2026-05-22 10:46:48,696: DEBUG] Pulling image ghcr.io/openzim/mwoffliner:1.17.5\n[2026-05-22 10:46:50,698: INFO] Updating task-status=scraper_started\n[2026-05-23 19:20:33,677: INFO] Gathering ZIM metadata for /data/7389b26f-a00b-443c-a33d-b5bc9d26a955/8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:20:33,709: INFO] ZIM file created: 8598bf15-0e01-70a0-3675-0be035961159.zim, 11.01 GiB\n[2026-05-23 19:20:34,079: INFO] Starting zim uploader for /8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:20:35,093: INFO] Starting zim checker for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:29:35,866: INFO] Updating file-status=uploaded for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:58:42,345: INFO] Updating file check-result=1 for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:58:42,708: INFO] Zimcheck output written to 8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json\n[2026-05-23 19:58:42,756: INFO] Starting zimcheck uploader for 8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json\n[2026-05-23 19:59:42,882: INFO] Zimcheck Uploader for 8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json complete 0\n[2026-05-23 19:59:42,883: INFO] Updating file check-result-uploaded=8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-25 07:32:37,549: INFO] Updating task-status=scraper_completed. Exit code: 0\n[2026-05-25 07:32:38,050: DEBUG] Dumping docker logs to file…\n[2026-05-25 07:32:38,257: DEBUG] Starting log uploader container…\n[2026-05-25 07:32:39,514: DEBUG] No artifacts configured for upload\n[2026-05-25 07:32:39,525: INFO] Gathering ZIM metadata for /data/7389b26f-a00b-443c-a33d-b5bc9d26a955/950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 07:32:39,546: INFO] ZIM file created: 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim, 18.31 GiB\n[2026-05-25 07:32:39,940: INFO] Starting zim uploader for /950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 07:32:41,038: INFO] Starting zim checker for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 07:33:03,054: INFO] Scraper log upload complete: 0\n[2026-05-25 07:33:03,054: INFO] Sending scraper log filename: 7389b26f-a00b-443c-a33d-b5bc9d26a955_mwoffliner.log\n[2026-05-25 07:33:03,405: INFO] Stopping and removing log_uploader\n[2026-05-25 07:47:03,807: INFO] Updating file-status=uploaded for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:13:04,889: INFO] Updating file check-result=1 for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:13:05,378: INFO] Zimcheck output written to 950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json\n[2026-05-25 08:13:05,427: INFO] Starting zimcheck uploader for 950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json\n[2026-05-25 08:14:05,455: INFO] Zimcheck Uploader for 950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json complete 0\n[2026-05-25 08:14:05,455: INFO] Updating file check-result-uploaded=950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:14:05,907: INFO] Contents of /data/7389b26f-a00b-443c-a33d-b5bc9d26a955 (recursive):\n[2026-05-25 08:14:05,908: INFO] 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-25 08:14:05,909: INFO] 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:14:05,909: INFO] Updating task-status=succeeded\n" + }, + "canceled_by": null, + "container": { + "log": "7389b26f-a00b-443c-a33d-b5bc9d26a955_mwoffliner.log", + "image": "ghcr.io/openzim/mwoffliner:1.17.5", + "stats": { + "memory": { "max": 7516192768 }, + "cpu": { "max": 678.475183116077, "avg": 239.03 }, + "disk": { "max": 41753439045 } + }, + "artifacts": null, + "stderr": "[warn] [2026-05-25T07:12:26.105Z] Skipping redirect of 'Ultor_de_Lacy' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy' because target is not a known article\n[warn] [2026-05-25T07:13:02.076Z] Skipping redirect of 'The_Haunted_Baronet' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet' because target is not a known article\n[warn] [2026-05-25T07:13:03.522Z] Skipping redirect of 'Ghost_Stories_of_Chapelizod' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod' because target is not a known article\n[warn] [2026-05-25T07:14:05.307Z] Skipping redirect of 'The_Gallic_Wars' to 'Commentaries_on_the_Gallic_War' because target is not a known article\n[warn] [2026-05-25T07:14:05.912Z] Skipping redirect of 'The_Drunkard's_Dream' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Drunkard's_Dream' because target is not a known article\n[warn] [2026-05-25T07:15:06.017Z] Skipping redirect of 'Reports_on_the_Gallic_War' to 'Commentaries_on_the_Gallic_War' because target is not a known article\n[warn] [2026-05-25T07:15:07.583Z] Skipping redirect of 'Laura_Silver_Bell' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Laura_Silver_Bell' because target is not a known article\n[warn] [2026-05-25T07:16:13.080Z] Skipping redirect of 'The_Vision_of_Tom_Chuff' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_5/The_Vision_of_Tom_Chuff' because target is not a known article\n", + "stdout": "[log] [2026-05-25T07:11:53.893Z] 65000 redirects have been processed (49.2 %)\n[log] [2026-05-25T07:12:24.963Z] 70000 redirects have been processed (53 %)\n[log] [2026-05-25T07:12:26.082Z] 75000 redirects have been processed (56.8 %)\n[log] [2026-05-25T07:13:02.543Z] 80000 redirects have been processed (60.6 %)\n[log] [2026-05-25T07:13:03.797Z] 85000 redirects have been processed (64.4 %)\n[log] [2026-05-25T07:14:05.502Z] 90000 redirects have been processed (68.2 %)\n[log] [2026-05-25T07:14:06.658Z] 95000 redirects have been processed (72 %)\n[log] [2026-05-25T07:15:06.565Z] 100000 redirects have been processed (75.7 %)\n[log] [2026-05-25T07:15:07.735Z] 105000 redirects have been processed (79.5 %)\n[log] [2026-05-25T07:15:39.663Z] 110000 redirects have been processed (83.3 %)\n[log] [2026-05-25T07:15:40.816Z] 115000 redirects have been processed (87.1 %)\n[log] [2026-05-25T07:16:12.146Z] 120000 redirects have been processed (90.9 %)\n[log] [2026-05-25T07:16:13.265Z] 125000 redirects have been processed (94.7 %)\n[log] [2026-05-25T07:16:44.410Z] 130000 redirects have been processed (98.5 %)\n[log] [2026-05-25T07:16:44.840Z] Finishing ZIM Creation\nResolve redirect\nset index\n[log] [2026-05-25T07:32:35.909Z] Summary of scrape actions: {\n\t\"files\": {\n\t\t\"success\": 351845,\n\t\t\"fail\": 75\n\t},\n\t\"articles\": {\n\t\t\"success\": 4443232,\n\t\t\"hardFail\": 0,\n\t\t\"hardFailedArticleIds\": [],\n\t\t\"softFail\": 51,\n\t\t\"softFailedArticleIds\": [\n\t\t\t\"Portal:Johann_August_Ernesti\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXVII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_V\",\n\t\t\t\"DeCSS_Haiku\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_X\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XVIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXI\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod/The_Village_Bully\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/The_Vision_of_Tom_Chuff\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/The_Child_That_Went_with_the_Fairies\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_VIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Wicked_Captain_Walshawe,_of_Wauling/Chapter_III\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Mysterious_Lodger/Part_II\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod/The_Spectre_Lovers\",\n\t\t\t\"Commentaries_on_the_Gallic_War/Book_6\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Mysterious_Lodger\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_IX\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_I\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_III\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Laura_Silver_Bell\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Wicked_Captain_Walshawe,_of_Wauling/Chapter_VI\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_II\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XI\",\n\t\t\t\"Commentaries_on_the_Gallic_War\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_VII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_1/Schalken_the_Painter\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_VII\",\n\t\t\t\"Madam_Crowl's_Ghost_and_the_Dead_Sexton\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Drunkard's_Dream\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Stories_of_Lough_Guir/The_Banshee\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_VI\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_VIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_IV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XIV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXIX\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Mysterious_Lodger/Part_I\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Wicked_Captain_Walshawe,_of_Wauling/Chapter_I\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXVIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/An_Authentic_Narrative_of_a_Haunted_House\",\n\t\t\t\"Commentaries_on_the_Gallic_War/Book_4\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy\",\n\t\t\t\"Two_Ghostly_Mysteries\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_IV\",\n\t\t\t\"Two_Ghostly_Mysteries/A_Chapter_in_the_History_of_a_Tyrone_Family\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_V\"\n\t\t]\n\t},\n\t\"redirects\": {\n\t\t\"written\": 132021\n\t}\n}\n[log] [2026-05-25T07:32:35.909Z] ZIM is ready at [/output/wikisource_en_all_maxi_2026-05.zim]\n[log] [2026-05-25T07:32:35.921Z] Finished dump\n[log] [2026-05-25T07:32:35.921Z] Closing HTTP agents...\n[log] [2026-05-25T07:32:35.921Z] All dumping(s) finished with success.\n[log] [2026-05-25T07:32:35.925Z] Flushing Redis DBs\n[log] [2026-05-25T07:32:35.926Z] Exiting with code [0]\n[log] [2026-05-25T07:32:35.926Z] Deleting temporary directory [/dev/shm/mwoffliner-1779446819566]\n", + "command": [ + "mwoffliner", + "--mwUrl=https://en.wikisource.org/", + "--adminEmail=contact@kiwix.org", + "--customZimDescription='Wikisource is an library of public domain texts'", + "--publisher=openZIM", + "--format=nopic:nopic", + "--format=novid:maxi", + "--optimisationCacheUrl='************************'", + "--addNamespaces=100", + "--osTmpDir=/dev/shm", + "--outputDirectory=/output", + "--verbose=log", + "--webp", + "--forceRender=ActionParse" + ], + "progress": null, + "exit_code": 0 + }, + "notification": null, + "files": { + "8598bf15-0e01-70a0-3675-0be035961159.zim": { + "name": "8598bf15-0e01-70a0-3675-0be035961159.zim", + "task_id": "7389b26f-a00b-443c-a33d-b5bc9d26a955", + "status": "check_results_uploaded", + "size": 11824699968, + "cms_on": "2026-05-23T20:00:18Z", + "cms_notified": true, + "created_timestamp": "2026-05-23T19:20:34Z", + "uploaded_timestamp": "2026-05-23T19:29:36Z", + "failed_timestamp": null, + "check_timestamp": "2026-05-23T19:58:42Z", + "check_result": 1, + "check_filename": "8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json", + "check_upload_timestamp": "2026-05-23T19:59:43Z", + "info": { + "id": "8598bf15-0e01-70a0-3675-0be035961159", + "size": 11824699968, + "counter": { + "font/ttf": 1, + "text/css": 30, + "image/png": 14, + "text/html": 4449189, + "image/jpeg": 1, + "image/svg+xml": 24, + "application/pdf": 2, + "text/javascript": 3, + "application/javascript": 4, + "text/html; charset=iso-8859-1": 1, + "image/svg+xml; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"": 70789 + }, + "metadata": { + "Date": "2026-05-22", + "Name": "wikisource_en_all", + "Tags": "wikisource;_category:wikisource;_pictures:no;_videos:no;_details:yes;_ftindex:yes", + "Title": "Wikisource", + "Source": "en.wikisource.org", + "Counter": "application/javascript=4;application/pdf=2;font/ttf=1;image/jpeg=1;image/png=14;image/svg+xml=24;image/svg+xml; charset=utf-8; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"=70789;text/css=30;text/html=4449189;text/html; charset=iso-8859-1=1;text/javascript=3", + "Creator": "Wikisource", + "Flavour": "nopic", + "Scraper": "mwoffliner 1.17.5", + "Language": "eng", + "Publisher": "openZIM", + "Description": "Wikisource is an library of public domain texts", + "Illustration_48x48@1": "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" + }, + "media_count": 70828, + "article_count": 4575267 + }, + "zim_urls": [ + { + "kind": "download", + "url": "https://lbo.download.kiwix.org/zim/wikisource/wikisource_en_all_nopic_2026-05.zim", + "collection": "Kiwix" + }, + { + "kind": "view", + "url": "https://browse.library.kiwix.org/viewer#wikisource_en_all_nopic_2026-05", + "collection": "Kiwix" + } + ] + }, + "950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim": { + "name": "950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim", + "task_id": "7389b26f-a00b-443c-a33d-b5bc9d26a955", + "status": "check_results_uploaded", + "size": 19663666206, + "cms_on": "2026-05-25T08:14:30Z", + "cms_notified": true, + "created_timestamp": "2026-05-25T07:32:39Z", + "uploaded_timestamp": "2026-05-25T07:47:04Z", + "failed_timestamp": null, + "check_timestamp": "2026-05-25T08:13:05Z", + "check_result": 1, + "check_filename": "950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json", + "check_upload_timestamp": "2026-05-25T08:14:05Z", + "info": { + "id": "950dfcf0-873e-1938-2bc0-ddfe4bea7674", + "size": 19663666206, + "counter": { + "font/ttf": 1, + "text/css": 30, + "image/gif": 1142, + "image/png": 14, + "text/html": 4449187, + "image/jpeg": 1, + "image/webp": 278082, + "image/svg+xml": 24, + "application/pdf": 2, + "text/javascript": 3, + "application/javascript": 4, + "text/html; charset=iso-8859-1": 1, + "image/svg+xml; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"": 72582 + }, + "metadata": { + "Date": "2026-05-22", + "Name": "wikisource_en_all", + "Tags": "wikisource;_category:wikisource;_pictures:yes;_videos:no;_details:yes;_ftindex:yes", + "Title": "Wikisource", + "Source": "en.wikisource.org", + "Counter": "application/javascript=4;application/pdf=2;font/ttf=1;image/gif=1142;image/jpeg=1;image/png=14;image/svg+xml=24;image/svg+xml; charset=utf-8; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"=72582;image/webp=278082;text/css=30;text/html=4449187;text/html; charset=iso-8859-1=1;text/javascript=3", + "Creator": "Wikisource", + "Flavour": "maxi", + "Scraper": "mwoffliner 1.17.5", + "Language": "eng", + "Publisher": "openZIM", + "Description": "Wikisource is an library of public domain texts", + "Illustration_48x48@1": "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" + }, + "media_count": 351845, + "article_count": 4575253 + }, + "zim_urls": [ + { + "kind": "download", + "url": "https://lbo.download.kiwix.org/zim/wikisource/wikisource_en_all_maxi_2026-05.zim", + "collection": "Kiwix" + }, + { + "kind": "view", + "url": "https://browse.library.kiwix.org/viewer#wikisource_en_all_maxi_2026-05", + "collection": "Kiwix" + } + ] + } + }, + "upload": { + "zim": { + "expiration": 0, + "upload_uri": "sftp://uploader@warehouse-b.farm.openzim.org:30222/zim", + "zimcheck": "--all", + "disable_warehouse_path": true + }, + "logs": { + "expiration": 60, + "upload_uri": "s3://s3.us-west-1.wasabisys.com/?keyId=************************&secretAccessKey=************************&bucketName=org-kiwix-zimfarm-logs" + }, + "artifacts": { + "expiration": 30, + "upload_uri": "s3://s3.us-west-1.wasabisys.com/?keyId=************************&secretAccessKey=************************&bucketName=org-kiwix-zimfarm-artifacts" + }, + "check": { + "expiration": 0, + "upload_uri": "s3://s3.us-west-1.wasabisys.com/?keyId=************************&secretAccessKey=************************&bucketName=org-kiwix-zimfarm-zimcheck-results" + } + }, + "offliner": "mwoffliner", + "version": "1.17.5" +} diff --git a/backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 b/backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 new file mode 100644 index 00000000..cc05925c --- /dev/null +++ b/backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 @@ -0,0 +1,47 @@ +{ + "id": "d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0", + "title_id": "fa8c7867-1bd0-4838-a409-96bf974c1d10", + "location_kind": "prod", + "needs_processing": false, + "has_error": false, + "needs_file_operation": false, + "deletion_date": null, + "created_at": "2026-04-23T07:47:09Z", + "name": "alpinelinux_en_all", + "date": "2026-04-21", + "flavour": "maxi", + "warnings": [], + "article_count": 1007, + "media_count": 76, + "size": 3047126, + "zimcheck_result_url": null, + "zim_metadata": { + "Date": "2026-04-21", + "Name": "alpinelinux_en_all", + "Tags": "Linux distro;alpinelinux;_pictures:yes;_videos:no;_details:yes;_ftindex:yes", + "Title": "Alpine Linux Wiki", + "Source": "wiki.alpinelinux.org", + "Counter": "application/javascript=4;image/png=1;image/svg+xml=11;image/webp=64;text/css=12;text/html=786;text/javascript=3", + "Creator": "Alpinelinux", + "Flavour": "maxi", + "Scraper": "mwoffliner 1.17.5", + "Language": "eng", + "Publisher": "openZIM", + "Description": "Alpine Linux is a security-oriented, lightweight Linux distribution", + "Illustration_48x48@1": "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" + }, + "events": [ + "2026-04-23 07:47:09.831459: maintenance script: book created from existing ZIM file", + "2026-04-23 07:47:09.834079: maintenance script: added current location: other/alpinelinux_en_all_maxi_2026-04.zim" + ], + "current_locations": [ + { + "warehouse_name": "download/zim", + "path": "other", + "filename": "alpinelinux_en_all_maxi_2026-04.zim", + "status": "current" + } + ], + "target_locations": [], + "title_archived": false +} 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..cce9e75b 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,10 @@ 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 @model_validator(mode="after") def validate_unique_collection_titles(self) -> Self: @@ -70,11 +79,23 @@ def validate_unique_collection_titles(self) -> Self: class TitleCreateSchema(BaseTitleCreateUpdateSchema): name: NotEmptyString maturity: Literal["unstable", "stable"] = "unstable" + title: NotEmptyString + creator: NotEmptyString + publisher: NotEmptyString + language: NotEmptyString + description: NotEmptyString + illustration_48x48_at_1: Base64Str class TitleUpdateSchema(BaseTitleCreateUpdateSchema): name: NotEmptyString | None = None maturity: Literal["unstable", "stable"] | 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 @router.get("") @@ -133,13 +154,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 +177,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 +241,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 +257,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 e94b70ba..814867aa 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, + warnings=book.warnings, article_count=book.article_count, media_count=book.media_count, size=book.size, @@ -337,3 +338,34 @@ def recover_book(session: OrmSession, book_id: UUID) -> Book: session.add(book) session.flush() 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]] diff --git a/backend/src/cms_backend/db/books.py b/backend/src/cms_backend/db/books.py index 777bb2f5..dfc25365 100644 --- a/backend/src/cms_backend/db/books.py +++ b/backend/src/cms_backend/db/books.py @@ -43,6 +43,7 @@ def get_books( Book.name, Book.date, Book.flavour, + Book.warnings, ).order_by( Book.has_error.desc(), Book.location_kind, @@ -114,6 +115,7 @@ def get_books( name=name, date=date, flavour=flavour, + warnings=book_warnings, ) for ( book_id_result, @@ -127,6 +129,7 @@ def get_books( name, date, flavour, + book_warnings, ) 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..c7402f1e 100644 --- a/backend/src/cms_backend/db/models.py +++ b/backend/src/cms_backend/db/models.py @@ -138,6 +138,11 @@ class Book(Base): location_kind: Mapped[str] = mapped_column( init=False, default="quarantine", server_default="quarantine" ) + # should be used for storing warning messages about a book. ideally, + # these warning messsages should not prevent a book from being acted upon + warnings: 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 +201,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..169f1337 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, + warnings=book.warnings, ) 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, + creator: str, + publisher: str, + language: str, + description: str, + 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..1ace7ee8 --- /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( + "warnings", + 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", "warnings") + # ### 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..fbdf75b8 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.warnings = ["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..f2093fab 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 + warnings: 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..32ef68ae 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", + "warnings", } 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 d5966d8f..79947663 100644 --- a/backend/tests/db/test_book.py +++ b/backend/tests/db/test_book.py @@ -1,8 +1,11 @@ +from collections.abc import Callable + from faker import Faker from sqlalchemy.orm import Session as OrmSession from cms_backend.db.book import create_book as db_create_book -from cms_backend.db.models import ZimfarmNotification +from cms_backend.db.book import get_differing_metadata_keys +from cms_backend.db.models import Book, Title, ZimfarmNotification def test_create_book( @@ -32,3 +35,39 @@ def test_create_book( assert any( event for event in book.events if "created from Zimfarm notification" in event ) + + +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"} 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..db807b6e 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.warnings) == 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.warnings) == 1 + assert set(book.warnings) == {"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.warnings) == 1 + assert set(book.warnings) == {"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/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..8e422ec8 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 hasWarnings = computed(() => props.book.warnings && props.book.warnings.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/BookToTitleMetadataSync.vue b/frontend/src/components/BookToTitleMetadataSync.vue new file mode 100644 index 00000000..a1b6e8ac --- /dev/null +++ b/frontend/src/components/BookToTitleMetadataSync.vue @@ -0,0 +1,372 @@ + + + + + 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..bc9ceba9 --- /dev/null +++ b/frontend/src/components/TitleForm.vue @@ -0,0 +1,590 @@ + + + + + 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/router/index.ts b/frontend/src/router/index.ts index 9f13301a..29a12372 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -83,6 +83,15 @@ const routes = [ title: (to: RouteLocationNormalized) => `CMS | Book • ${to.params.id}`, }, }, + { + path: '/book/:id/:selectedTab', + name: 'book-detail-tab', + component: BookView, + props: true, + meta: { + title: (to: RouteLocationNormalized) => `CMS | Book • ${to.params.id}`, + }, + }, { path: '/zimfarm-notification/:id', name: 'zimfarm-notification-detail', 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..6253a3e5 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 + warnings: string[] deletion_date?: string name?: string date?: string diff --git a/frontend/src/types/title.ts b/frontend/src/types/title.ts index 29d0fabe..871f4fe5 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 + creator: string + publisher: string + description: string + language: string + illustration_48x48_at_1: string + 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 61e37979..374132aa 100644 --- a/frontend/src/views/BookView.vue +++ b/frontend/src/views/BookView.vue @@ -38,234 +38,284 @@ -
- - -
Id
-
- - {{ book.id }} - -
- - - - -
Title Id
-
- - - {{ book.title_id }} - - None - -
- - - - -
Status
-
- - - -
- - - - -
Created
-
- - - - {{ formatDt(book.created_at) }} - - -
- - - - -
Name
-
- - {{ book.name }} - - - -
- - - - -
Flavour
-
- - {{ book.flavour }} - - - -
- - - - -
Date
-
- - {{ book.date }} - - - -
- - - - -
URLs
-
- - - -
- - - - -
Article Count
-
- - {{ book.article_count.toLocaleString() }} - -
- - - - -
Media Count
-
- - {{ book.media_count.toLocaleString() }} - -
- - - - -
Size
-
- - {{ formatBytes(book.size) }} - -
- - - - -
- ZIM Metadata - - mdi-content-copy - Copy - -
-
- -
-
{{ JSON.stringify(book.zim_metadata, null, 2) }}
-
-
-
- - - - -
- Zimcheck Result - - mdi-content-copy - Copy - -
-
- -
-
{{ JSON.stringify(book.zimcheck_result, null, 2) }}
-
-
-
- - - - -
Current Locations
-
- - - - - No current locations - -
- - - - -
Target Locations
-
- - - - - No target locations - -
- - - - -
Events
-
- - - -
-
+ + + mdi-information + Info + + + + mdi-sync + Sync Metadata + + + + + + + + +
+ + +
Id
+
+ + {{ book.id }} + +
+ + + + +
Title Id
+
+ + + {{ book.title_id }} + + None + +
+ + + + +
Status
+
+ + + +
+ + + + +
Created
+
+ + + + {{ formatDt(book.created_at) }} + + +
+ + + + +
Name
+
+ + {{ book.name }} + - + +
+ + + + +
Flavour
+
+ + {{ book.flavour }} + - + +
+ + + + +
Date
+
+ + {{ book.date }} + - + +
+ + + + +
URLs
+
+ + + +
+ + + + +
Article Count
+
+ + {{ book.article_count.toLocaleString() }} + +
+ + + + +
Media Count
+
+ + {{ book.media_count.toLocaleString() }} + +
+ + + + +
Size
+
+ + {{ formatBytes(book.size) }} + +
+ + + + +
+ ZIM Metadata + + mdi-content-copy + Copy + +
+
+ +
+
{{ JSON.stringify(book.zim_metadata, null, 2) }}
+
+
+
+ + + + +
+ Zimcheck Result + + mdi-content-copy + Copy + +
+
+ +
+
{{ JSON.stringify(book.zimcheck_result, null, 2) }}
+
+
+
+ + + + +
Current Locations
+
+ + + + + No current locations + +
+ + + + +
Target Locations
+
+ + + + + No target locations + +
+ + + + +
Events
+
+ + + +
+
+
+
+
+ + + +
+ +
+
+
@@ -276,6 +326,7 @@ diff --git a/frontend/src/views/TitleView.vue b/frontend/src/views/TitleView.vue index 4f9566c8..6b5073d3 100644 --- a/frontend/src/views/TitleView.vue +++ b/frontend/src/views/TitleView.vue @@ -9,12 +9,6 @@
-
- - Edit Title - -
- + + mdi-pencil + Edit + + + + +
Title
+
+ + {{ title.title }} + Not set + +
+ + + + +
Description
+
+ + {{ title.description }} + Not set + +
+ + + + +
Long Description
+
+ + {{ + title.long_description + }} + Not set + +
+ + + + +
Creator
+
+ + {{ title.creator }} + Not set + +
+ + + + +
Publisher
+
+ + {{ title.publisher }} + Not set + +
+ + + + +
Language
+
+ + {{ title.language }} + Not set + +
+ + + + +
License
+
+ + {{ title.license }} + Not set + +
+ + + + +
Source
+
+ + {{ title.source }} + Not set + +
+ + + + +
Relation
+
+ + {{ title.relation }} + Not set + +
+ + + + +
Illustration
+
+ +
+ +
+ No illustration +
+
+ +
Events
@@ -140,6 +266,73 @@ + + +
+ + + + + mdi-restore + Reset + + + mdi-content-save + Save Changes + + + + + + + + {{ updateError }} + + + + + + + + mdi-restore + Reset + + + mdi-content-save + Save Changes + + + +
+
+
@@ -153,16 +346,14 @@
- - diff --git a/frontend/src/components/TitleForm.vue b/frontend/src/components/TitleForm.vue index bc9ceba9..57017e24 100644 --- a/frontend/src/components/TitleForm.vue +++ b/frontend/src/components/TitleForm.vue @@ -14,11 +14,19 @@ +
+
Latest book has:
+
+ {{ bookMetadata?.title }} + + Use this + +
+
@@ -27,21 +35,37 @@ +
+
Latest book has:
+
+ {{ bookMetadata?.creator }} + + Use this + +
+
+
+
Latest book has:
+
+ {{ bookMetadata?.publisher }} + + Use this + +
+
@@ -50,11 +74,19 @@ +
+
Latest book has:
+
+ {{ bookMetadata?.language }} + + Use this + +
+
+
+
Latest book has:
+
+ + + Use this + +
+
@@ -83,12 +136,20 @@ +
+
Latest book has:
+
+ {{ bookMetadata?.description }} + + Use this + +
+
@@ -102,6 +163,20 @@ rows="5" clearable /> +
+
Latest book has:
+
+ {{ bookMetadata?.long_description }} + + Use this + +
+
@@ -114,6 +189,15 @@ density="comfortable" clearable /> +
+
Latest book has:
+
+ {{ bookMetadata?.license }} + + Use this + +
+
(), { title: null, inDialog: false, + latestBook: null, }) const emit = defineEmits<{ @@ -294,6 +381,97 @@ const isStable = computed({ }, }) +// Extract book metadata for comparison +type BookMetadataFields = { + title: string | undefined + creator: string | undefined + publisher: string | undefined + description: string | undefined + long_description: string | undefined + language: string | undefined + license: string | undefined + illustration_48x48_at_1: string | undefined +} + +const bookMetadata = computed(() => { + if (!props.latestBook?.zim_metadata) return null + const metadata = props.latestBook.zim_metadata + return { + title: metadata.Title as string | undefined, + creator: metadata.Creator as string | undefined, + publisher: metadata.Publisher as string | undefined, + description: metadata.Description as string | undefined, + long_description: metadata.LongDescription as string | undefined, + language: metadata.Language as string | undefined, + license: metadata.License as string | undefined, + illustration_48x48_at_1: metadata['Illustration_48x48@1'] as string | undefined, + } +}) + +const isFieldDifferent = (field: keyof BookMetadataFields) => { + if (!bookMetadata.value || !isEditMode.value) return false + const bookValue = bookMetadata.value[field] + const titleValue = formData.value[field as keyof typeof formData.value] + + // If book has no value, don't show hint + if (bookValue === undefined || bookValue === null) return false + + // If values are the same, don't show hint + if (bookValue === titleValue) return false + + return true +} + +const hasAnyDifferences = computed(() => { + if (!bookMetadata.value || !isEditMode.value) return false + const fields: (keyof BookMetadataFields)[] = [ + 'title', + 'creator', + 'publisher', + 'description', + 'long_description', + 'language', + 'license', + 'illustration_48x48_at_1', + ] + return fields.some((field) => isFieldDifferent(field)) +}) + +const useBookValue = (field: keyof BookMetadataFields) => { + if (!bookMetadata.value) return + const value = bookMetadata.value[field] + if (value !== undefined && value !== null) { + ;(formData.value[field as keyof typeof formData.value] as string | null) = value + } +} + +const useAllBookValues = () => { + if (!bookMetadata.value) return + const fields: (keyof BookMetadataFields)[] = [ + 'title', + 'creator', + 'publisher', + 'description', + 'long_description', + 'language', + 'license', + 'illustration_48x48_at_1', + ] + fields.forEach((field) => { + if (isFieldDifferent(field)) { + useBookValue(field) + } + }) +} + +const getImageDataUrl = (base64String: string | undefined): string | undefined => { + if (!base64String) return undefined + if (base64String.startsWith('data:') || base64String.startsWith('http')) { + return base64String + } + return `data:image/png;base64,${base64String}` +} + const collectionNames = computed(() => { return collectionsStore.collections.map((collection) => collection.name) }) @@ -490,18 +668,18 @@ async function fetchCollections() { } function getFormData(): TitleUpdate { - // For creation (non-edit mode), ensure required fields are not null/undefined + // For creation (non-edit mode), include all fields with their current values (null if not set) if (!isEditMode.value) { return { name: formData.value.name || '', maturity: formData.value.maturity || 'unstable', collection_titles: formData.value.collection_titles, - title: formData.value.title || '', - creator: formData.value.creator || '', - publisher: formData.value.publisher || '', - description: formData.value.description || '', - language: formData.value.language || '', - illustration_48x48_at_1: formData.value.illustration_48x48_at_1 || '', + title: formData.value.title || null, + creator: formData.value.creator || null, + publisher: formData.value.publisher || null, + description: formData.value.description || null, + language: formData.value.language || null, + illustration_48x48_at_1: formData.value.illustration_48x48_at_1 || null, long_description: formData.value.long_description, license: formData.value.license, relation: formData.value.relation, @@ -580,6 +758,8 @@ defineExpose({ getUpdatePayload, formValid, formData, + hasAnyDifferences, + useAllBookValues, }) diff --git a/frontend/src/types/book.ts b/frontend/src/types/book.ts index 6253a3e5..bfca9add 100644 --- a/frontend/src/types/book.ts +++ b/frontend/src/types/book.ts @@ -21,7 +21,7 @@ export interface BookLight { needs_file_operation: boolean location_kind: LocationKind created_at: string - warnings: 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 871f4fe5..be497e46 100644 --- a/frontend/src/types/title.ts +++ b/frontend/src/types/title.ts @@ -42,12 +42,12 @@ export interface TitleCreate { name: string maturity: string collection_titles: BaseTitleCollection[] - title: string - creator: string - publisher: string - description: string - language: string - illustration_48x48_at_1: string + 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 diff --git a/frontend/src/views/BookView.vue b/frontend/src/views/BookView.vue index 374132aa..a9fcfdc7 100644 --- a/frontend/src/views/BookView.vue +++ b/frontend/src/views/BookView.vue @@ -57,19 +57,6 @@ mdi-information Info
- - - mdi-sync - Sync Metadata -
@@ -308,13 +295,6 @@ - - - -
- -
-
@@ -326,7 +306,6 @@ diff --git a/frontend/src/views/TitleView.vue b/frontend/src/views/TitleView.vue index 6b5073d3..0bc8b07f 100644 --- a/frontend/src/views/TitleView.vue +++ b/frontend/src/views/TitleView.vue @@ -270,23 +270,38 @@
- - + + mdi-download + Use Metadata from Latest Book + + + mdi-restore Reset mdi-content-save Save Changes @@ -297,6 +312,7 @@ @@ -307,23 +323,38 @@ - - + + + mdi-download + Use Metadata from Latest Book + + mdi-restore Reset mdi-content-save Save Changes @@ -360,7 +391,7 @@ 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' @@ -380,6 +411,7 @@ const title = ref(null) const dataLoaded = 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>>() @@ -434,6 +466,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...') @@ -546,8 +599,17 @@ const handleReset = () => { titleFormRef.value?.resetFormToTitle(title.value) } +const handleUseLatestBook = () => { + titleFormRef.value?.useAllBookValues() +} + 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 @@ -555,15 +617,16 @@ watch( () => props.selectedTab, async (newTab) => { currentTab.value = newTab - // Load collections and reset form when switching to edit tab + + if (!title.value || newTab != 'archive') { + await loadData(true) + } + if (newTab === 'edit' && title.value) { await titleFormRef.value?.fetchCollections() + await loadLatestBook() titleFormRef.value?.resetFormToTitle(title.value) } - // Only refresh data if we don't have any data yet, or if not archiving - if (!title.value || newTab != 'archive') { - await loadData(true) - } }, ) From bd8463a01c76f5112ac59f06b36f5beaf915375c Mon Sep 17 00:00:00 2001 From: Uchechukwu Orji <orjiuchechukwu52@yahoo.com> Date: Wed, 27 May 2026 12:23:51 +0100 Subject: [PATCH 3/6] add maint-script to update titles metadata using latest books --- .../update_titles_from_latest_books.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100755 backend/maint-scripts/update_titles_from_latest_books.py 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() From 9f0800ca54b7acabc791ff9a5043da844d71a975 Mon Sep 17 00:00:00 2001 From: Uchechukwu Orji <orjiuchechukwu52@yahoo.com> Date: Wed, 27 May 2026 12:49:14 +0100 Subject: [PATCH 4/6] reorder title form --- frontend/src/components/TitleForm.vue | 612 +++++++++++++------------- frontend/src/views/TitleView.vue | 44 +- 2 files changed, 322 insertions(+), 334 deletions(-) diff --git a/frontend/src/components/TitleForm.vue b/frontend/src/components/TitleForm.vue index 57017e24..68e5c794 100644 --- a/frontend/src/components/TitleForm.vue +++ b/frontend/src/components/TitleForm.vue @@ -1,317 +1,341 @@ <template> <v-form ref="formRef" v-model="formValid"> - <v-row> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.name" - label="Title Name" - :rules="[rules.required]" - variant="outlined" - density="comfortable" - /> - </v-col> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.title" - label="Title" - variant="outlined" - density="comfortable" - clearable - /> - <div v-if="!inDialog && isFieldDifferent('title')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.title }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('title')"> - Use this - </v-btn> + <!-- Basic Settings Section --> + <div class="mb-6"> + <h3 class="text-h6 mb-4">Basic Settings</h3> + <v-row> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.name" + label="Title Name" + :rules="[rules.required]" + variant="outlined" + density="comfortable" + /> + </v-col> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-switch + v-model="isStable" + color="primary" + density="comfortable" + :hint="maturityHint" + persistent-hint + > + <template #label> + <span class="text-subtitle-1" + >Maturity: <strong>{{ formData.maturity }}</strong></span + > + </template> + </v-switch> + </v-col> + </v-row> + </div> + + <v-divider class="my-6" /> + + <!-- Metadata Section --> + <div class="mb-6"> + <div class="d-flex align-center justify-space-between mb-4"> + <h3 class="text-h6">Metadata</h3> + <v-btn + v-if="!inDialog && hasAnyDifferences" + color="primary" + variant="elevated" + size="small" + prepend-icon="mdi-download" + @click="useAllBookValues" + > + Use All from Latest Book + </v-btn> + </div> + + <v-row> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.title" + label="Title" + variant="outlined" + density="comfortable" + clearable + /> + <div v-if="!inDialog && isFieldDifferent('title')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.title }}</strong> + <v-btn size="small" variant="text" color="primary" @click="useBookValue('title')"> + Use this + </v-btn> + </div> </div> - </div> - </v-col> - </v-row> - - <v-row> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.creator" - label="Creator" - variant="outlined" - density="comfortable" - clearable - /> - <div v-if="!inDialog && isFieldDifferent('creator')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.creator }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('creator')"> - Use this - </v-btn> + </v-col> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.creator" + label="Creator" + variant="outlined" + density="comfortable" + clearable + /> + <div v-if="!inDialog && isFieldDifferent('creator')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.creator }}</strong> + <v-btn size="small" variant="text" color="primary" @click="useBookValue('creator')"> + Use this + </v-btn> + </div> </div> - </div> - </v-col> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.publisher" - label="Publisher" - variant="outlined" - density="comfortable" - clearable - /> - <div v-if="!inDialog && isFieldDifferent('publisher')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.publisher }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('publisher')"> - Use this - </v-btn> + </v-col> + </v-row> + + <v-row> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.publisher" + label="Publisher" + variant="outlined" + density="comfortable" + clearable + /> + <div v-if="!inDialog && isFieldDifferent('publisher')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.publisher }}</strong> + <v-btn size="small" variant="text" color="primary" @click="useBookValue('publisher')"> + Use this + </v-btn> + </div> </div> - </div> - </v-col> - </v-row> - - <v-row> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.language" - label="Language" - variant="outlined" - density="comfortable" - clearable - /> - <div v-if="!inDialog && isFieldDifferent('language')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.language }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('language')"> - Use this - </v-btn> + </v-col> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.language" + label="Language" + variant="outlined" + density="comfortable" + clearable + /> + <div v-if="!inDialog && isFieldDifferent('language')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.language }}</strong> + <v-btn size="small" variant="text" color="primary" @click="useBookValue('language')"> + Use this + </v-btn> + </div> </div> - </div> - </v-col> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.relation" - label="Relation" - variant="outlined" - density="comfortable" - clearable - /> - </v-col> - </v-row> - - <v-row> - <v-col cols="12"> - <ImageEditor - v-model="formData.illustration_48x48_at_1" - label="Illustration" - description="Upload a 48x48 pixel illustration image" - /> - <div - v-if="!inDialog && isFieldDifferent('illustration_48x48_at_1')" - class="text-body-2 mt-2 mb-2" - > - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between mb-2"> - <v-img - :src="getImageDataUrl(bookMetadata?.illustration_48x48_at_1)" - width="48" - height="48" - class="rounded border" - /> - <v-btn - size="small" - variant="text" - color="primary" - @click="useBookValue('illustration_48x48_at_1')" - > - Use this - </v-btn> + </v-col> + </v-row> + + <v-row> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.license" + label="License" + variant="outlined" + density="comfortable" + clearable + /> + <div v-if="!inDialog && isFieldDifferent('license')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.license }}</strong> + <v-btn size="small" variant="text" color="primary" @click="useBookValue('license')"> + Use this + </v-btn> + </div> </div> - </div> - </v-col> - </v-row> - - <v-row> - <v-col cols="12"> - <v-textarea - v-model="formData.description" - label="Description" - variant="outlined" - density="comfortable" - rows="3" - clearable - /> - <div v-if="!inDialog && isFieldDifferent('description')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.description }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('description')"> - Use this - </v-btn> + </v-col> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.relation" + label="Relation" + variant="outlined" + density="comfortable" + clearable + /> + </v-col> + </v-row> + + <v-row> + <v-col cols="12" :md="inDialog ? 12 : 6"> + <v-text-field + v-model="formData.source" + label="Source" + variant="outlined" + density="comfortable" + clearable + /> + </v-col> + </v-row> + + <v-row> + <v-col cols="12"> + <ImageEditor + v-model="formData.illustration_48x48_at_1" + label="Illustration" + description="Upload a 48x48 pixel illustration image" + /> + <div + v-if="!inDialog && isFieldDifferent('illustration_48x48_at_1')" + class="text-body-2 mt-2 mb-2" + > + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between mb-2"> + <v-img + :src="getImageDataUrl(bookMetadata?.illustration_48x48_at_1)" + width="48" + height="48" + class="rounded border" + /> + <v-btn + size="small" + variant="text" + color="primary" + @click="useBookValue('illustration_48x48_at_1')" + > + Use this + </v-btn> + </div> </div> - </div> - </v-col> - </v-row> - - <v-row v-if="!inDialog"> - <v-col cols="12"> - <v-textarea - v-model="formData.long_description" - label="Long Description" - variant="outlined" - density="comfortable" - rows="5" - clearable - /> - <div v-if="isFieldDifferent('long_description')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.long_description }}</strong> - <v-btn - size="small" - variant="text" - color="primary" - @click="useBookValue('long_description')" - > - Use this - </v-btn> + </v-col> + </v-row> + + <v-row> + <v-col cols="12"> + <v-textarea + v-model="formData.description" + label="Description" + variant="outlined" + density="comfortable" + rows="3" + clearable + /> + <div v-if="!inDialog && isFieldDifferent('description')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.description }}</strong> + <v-btn + size="small" + variant="text" + color="primary" + @click="useBookValue('description')" + > + Use this + </v-btn> + </div> </div> - </div> - </v-col> - </v-row> - - <v-row> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.license" - label="License" - variant="outlined" - density="comfortable" - clearable - /> - <div v-if="!inDialog && isFieldDifferent('license')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> - <div class="d-flex align-center justify-space-between"> - <strong>{{ bookMetadata?.license }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('license')"> - Use this - </v-btn> + </v-col> + </v-row> + + <v-row v-if="!inDialog"> + <v-col cols="12"> + <v-textarea + v-model="formData.long_description" + label="Long Description" + variant="outlined" + density="comfortable" + rows="5" + clearable + /> + <div v-if="isFieldDifferent('long_description')" class="text-body-2 mt-n2 mb-2"> + <div class="mb-1">Latest book has:</div> + <div class="d-flex align-center justify-space-between"> + <strong>{{ bookMetadata?.long_description }}</strong> + <v-btn + size="small" + variant="text" + color="primary" + @click="useBookValue('long_description')" + > + Use this + </v-btn> + </div> </div> + </v-col> + </v-row> + </div> + + <v-divider class="my-6" /> + + <!-- Collections Section --> + <div> + <div class="d-flex align-center justify-space-between mb-4"> + <h3 class="text-h6">Collections</h3> + <v-btn + color="primary" + variant="text" + size="small" + prepend-icon="mdi-plus" + @click="addCollectionTitle" + :disabled="loadingCollections || !canAddMoreCollections" + > + Add Collection + </v-btn> + </div> + + <v-alert + v-if="formData.collection_titles.length === 0" + type="info" + density="compact" + class="mb-4" + > + No collections added. + </v-alert> + + <div + v-for="(collectionTitle, index) in formData.collection_titles" + :key="index" + class="mb-4 pa-3 border rounded" + > + <div class="d-flex align-center mb-2"> + <span class="text-subtitle-2 flex-grow-1">Collection #{{ index + 1 }}</span> + <v-btn + icon="mdi-delete" + size="x-small" + variant="text" + color="error" + @click="removeCollectionTitle(index)" + /> </div> - </v-col> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <v-text-field - v-model="formData.source" - label="Source" + + <v-select + v-model="collectionTitle.collection_name" + label="Collection" + :items="getAvailableCollections(index)" + :rules="[rules.required]" variant="outlined" density="comfortable" - clearable + class="mb-2" + :loading="loadingCollections" + @update:model-value="handleCollectionChange(index)" /> - </v-col> - </v-row> - - <v-row> - <v-col cols="12" :md="inDialog ? 12 : 6"> - <!-- Empty column for layout in edit tab --> - </v-col> - </v-row> - - <v-row> - <v-col cols="12"> - <v-switch - v-model="isStable" - color="primary" + + <v-select + v-model="collectionTitle.path" + label="Path" + :items="getAvailablePaths(collectionTitle.collection_name)" + :rules="[rules.required]" + variant="outlined" density="comfortable" - :class="inDialog ? 'mb-2' : ''" - :hint="maturityHint" + :disabled="!collectionTitle.collection_name" + :hint="!collectionTitle.collection_name ? 'Please select a collection first' : ''" persistent-hint - > - <template #label> - <span class="text-subtitle-1" - >Maturity: <strong>{{ formData.maturity }}</strong></span - > - </template> - </v-switch> - </v-col> - </v-row> - - <v-divider class="my-4" /> - - <div class="d-flex align-center justify-space-between mb-3"> - <h3 class="text-subtitle-1">Collection Paths</h3> - <v-btn - color="primary" - variant="text" - size="small" - prepend-icon="mdi-plus" - @click="addCollectionTitle" - :disabled="loadingCollections || !canAddMoreCollections" - > - Add Collection - </v-btn> - </div> - - <v-alert - v-if="formData.collection_titles.length === 0" - type="info" - density="compact" - class="mb-4" - > - No collections added. - </v-alert> - - <div - v-for="(collectionTitle, index) in formData.collection_titles" - :key="index" - class="mb-4 pa-3 border rounded" - > - <div class="d-flex align-center mb-2"> - <span class="text-subtitle-2 flex-grow-1">Collection #{{ index + 1 }}</span> - <v-btn - icon="mdi-delete" - size="x-small" - variant="text" - color="error" - @click="removeCollectionTitle(index)" /> </div> - <v-select - v-model="collectionTitle.collection_name" - label="Collection" - :items="getAvailableCollections(index)" - :rules="[rules.required]" - variant="outlined" - density="comfortable" - class="mb-2" - :loading="loadingCollections" - @update:model-value="handleCollectionChange(index)" - /> - - <v-select - v-model="collectionTitle.path" - label="Path" - :items="getAvailablePaths(collectionTitle.collection_name)" - :rules="[rules.required]" - variant="outlined" - density="comfortable" - :disabled="!collectionTitle.collection_name" - :hint="!collectionTitle.collection_name ? 'Please select a collection first' : ''" - persistent-hint - /> + <v-alert + v-if="isEditMode && hasCollectionChanges" + type="warning" + density="compact" + class="mt-4" + icon="mdi-alert" + > + 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. + </v-alert> </div> - - <v-alert - v-if="isEditMode && hasCollectionChanges" - type="warning" - density="compact" - class="mt-4" - icon="mdi-alert" - > - 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. - </v-alert> </v-form> </template> diff --git a/frontend/src/views/TitleView.vue b/frontend/src/views/TitleView.vue index 0bc8b07f..b4ce272d 100644 --- a/frontend/src/views/TitleView.vue +++ b/frontend/src/views/TitleView.vue @@ -270,27 +270,12 @@ <v-window-item value="edit"> <div v-if="canEditTitle" class="pa-4"> <v-card flat> - <v-card-actions class="pa-4 pb-0 d-flex flex-column flex-md-row"> - <v-btn - v-if="titleFormRef?.hasAnyDifferences" - :color="updating ? undefined : 'primary'" - variant="elevated" - @click="handleUseLatestBook" - :disabled="updating" - :block="smAndDown" - class="mb-2 mb-md-0" - > - <v-icon class="mr-2">mdi-download</v-icon> - Use Metadata from Latest Book - </v-btn> - <v-spacer class="d-none d-md-flex" /> + <div class="d-flex flex-column flex-sm-row justify-end ga-2"> <v-btn :color="updating || !hasChanges ? undefined : 'default'" variant="outlined" @click="handleReset" :disabled="updating || !hasChanges" - :block="smAndDown" - class="mb-2 mb-md-0 mr-md-2" > <v-icon class="mr-2">mdi-restore</v-icon> Reset @@ -301,12 +286,11 @@ @click="handleUpdate" :loading="updating" :disabled="!formValid || updating || !hasChanges" - :block="smAndDown" > <v-icon class="mr-2">mdi-content-save</v-icon> Save Changes </v-btn> - </v-card-actions> + </div> <v-card-text> <TitleForm @@ -323,27 +307,12 @@ </v-card-text> <!-- Action Buttons at Bottom --> - <v-card-actions class="pa-4 pt-0 d-flex flex-column flex-md-row"> - <v-btn - v-if="titleFormRef?.hasAnyDifferences" - :color="updating ? undefined : 'primary'" - variant="elevated" - @click="handleUseLatestBook" - :disabled="updating" - :block="smAndDown" - class="mb-2 mb-md-0" - > - <v-icon class="mr-2">mdi-download</v-icon> - Use Metadata from Latest Book - </v-btn> - <v-spacer class="d-none d-md-flex" /> + <div class="d-flex flex-column flex-sm-row justify-end ga-2"> <v-btn :color="updating || !hasChanges ? undefined : 'default'" variant="outlined" @click="handleReset" :disabled="updating || !hasChanges" - :block="smAndDown" - class="mb-2 mb-md-0 mr-md-2" > <v-icon class="mr-2">mdi-restore</v-icon> Reset @@ -354,12 +323,11 @@ @click="handleUpdate" :loading="updating" :disabled="!formValid || updating || !hasChanges" - :block="smAndDown" > <v-icon class="mr-2">mdi-content-save</v-icon> Save Changes </v-btn> - </v-card-actions> + </div> </v-card> </div> </v-window-item> @@ -599,10 +567,6 @@ const handleReset = () => { titleFormRef.value?.resetFormToTitle(title.value) } -const handleUseLatestBook = () => { - titleFormRef.value?.useAllBookValues() -} - onMounted(async () => { await loadData(true) if (props.selectedTab === 'edit' && title.value) { From 78ccf7cfae2ca65364ba6afbf9a6db8db1e6605e Mon Sep 17 00:00:00 2001 From: Uchechukwu Orji <orjiuchechukwu52@yahoo.com> Date: Thu, 28 May 2026 13:08:52 +0100 Subject: [PATCH 5/6] modify dev scripts to wipe only dev entries and create dummy books for any existing books in db --- dev/docker-compose.yml | 2 +- dev/scripts/setup_books.py | 47 +++++++++++++++++ dev/scripts/setup_collections.py | 4 +- dev/scripts/setup_notifications.py | 14 ++--- dev/scripts/setup_titles.py | 8 +-- dev/scripts/setup_warehouses.py | 6 +-- dev/scripts/wipe.py | 82 +++++++++++++++++++++--------- 7 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 dev/scripts/setup_books.py 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}") From e8dc77b20683b9699774f9d18c1b5cc03878a6d3 Mon Sep 17 00:00:00 2001 From: Uchechukwu Orji <orjiuchechukwu52@yahoo.com> Date: Thu, 28 May 2026 15:51:49 +0100 Subject: [PATCH 6/6] add emphasis to different title metadata fields --- backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 | 285 ------------------- backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 | 47 --- dev/README.md | 39 +++ frontend/src/components/TitleForm.vue | 101 +++++-- frontend/src/views/TitleView.vue | 5 +- 5 files changed, 123 insertions(+), 354 deletions(-) delete mode 100644 backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 delete mode 100644 backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 diff --git a/backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 b/backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 deleted file mode 100644 index 076fb557..00000000 --- a/backend/7389b26f-a00b-443c-a33d-b5bc9d26a955 +++ /dev/null @@ -1,285 +0,0 @@ -{ - "id": "7389b26f-a00b-443c-a33d-b5bc9d26a955", - "status": "succeeded", - "timestamp": [ - ["requested", "2026-05-13T04:02:23Z"], - ["reserved", "2026-05-22T10:46:36Z"], - ["started", "2026-05-22T10:46:47Z"], - ["scraper_started", "2026-05-22T10:46:51Z"], - ["scraper_completed", "2026-05-25T07:32:37Z"], - ["succeeded", "2026-05-25T08:14:06Z"] - ], - "recipe_name": "wikisource_en", - "worker_name": "mwoffliner4", - "updated_at": "2026-05-25T08:14:06Z", - "requested_by": "period-scheduler", - "original_recipe_name": "wikisource_en", - "context": "wikimedia_long", - "priority": 0, - "config": { - "warehouse_path": "", - "resources": { - "cpu": 3, - "memory": 7516192768, - "disk": 32212254720, - "shm": null, - "cap_add": [], - "cap_drop": [] - }, - "offliner": { - "offliner_id": "mwoffliner", - "mwUrl": "https://en.wikisource.org/", - "adminEmail": "contact@kiwix.org", - "articleList": null, - "articleListToIgnore": null, - "customMainPage": null, - "customZimTitle": null, - "customZimDescription": "Wikisource is an library of public domain texts", - "customZimLongDescription": null, - "customZimFavicon": null, - "customZimTags": null, - "customZimLanguage": null, - "publisher": "openZIM", - "filenamePrefix": null, - "format": ["nopic:nopic", "novid:maxi"], - "customFlavour": null, - "optimisationCacheUrl": "************************", - "addNamespaces": "100", - "getCategories": null, - "keepEmptyParagraphs": null, - "minifyHtml": null, - "mwWikiPath": null, - "mwActionApiPath": null, - "mwRestApiPath": null, - "mwModulePath": null, - "mwIndexPhpPath": null, - "mwDomain": null, - "mwUsername": null, - "mwPassword": null, - "osTmpDir": "/dev/shm", - "outputDirectory": "/output", - "requestTimeout": null, - "speed": null, - "withoutZimFullTextIndex": null, - "verbose": "log", - "webp": true, - "forceRender": "ActionParse", - "forceSkin": null, - "insecure": null, - "langVariant": null - }, - "platform": "wikimedia", - "artifacts_globs": [], - "monitor": false, - "image": { "name": "ghcr.io/openzim/mwoffliner", "tag": "1.17.5" }, - "mount_point": "/output", - "command": [ - "mwoffliner", - "--mwUrl=https://en.wikisource.org/", - "--adminEmail=contact@kiwix.org", - "--customZimDescription='Wikisource is an library of public domain texts'", - "--publisher=openZIM", - "--format=nopic:nopic", - "--format=novid:maxi", - "--optimisationCacheUrl='************************'", - "--addNamespaces=100", - "--osTmpDir=/dev/shm", - "--outputDirectory=/output", - "--verbose=log", - "--webp", - "--forceRender=ActionParse" - ], - "str_command": "mwoffliner --mwUrl=https://en.wikisource.org/ --adminEmail=contact@kiwix.org --customZimDescription='Wikisource is an library of public domain texts' --publisher=openZIM --format=nopic:nopic --format=novid:maxi --optimisationCacheUrl='************************' --addNamespaces=100 --osTmpDir=/dev/shm --outputDirectory=/output --verbose=log --webp --forceRender=ActionParse" - }, - "events": [ - { "code": "requested", "timestamp": "2026-05-13T04:02:23Z" }, - { "code": "reserved", "timestamp": "2026-05-22T10:46:36Z" }, - { "code": "started", "timestamp": "2026-05-22T10:46:47Z" }, - { "code": "scraper_started", "timestamp": "2026-05-22T10:46:51Z" }, - { "code": "scraper_completed", "timestamp": "2026-05-25T07:32:37Z" }, - { "code": "succeeded", "timestamp": "2026-05-25T08:14:06Z" } - ], - "debug": { - "log": "[2026-05-22 10:46:46,531: INFO] starting zimfarm task-worker for 7389b26f-a00b-443c-a33d-b5bc9d26a955.\n[2026-05-22 10:46:46,531: INFO] configuration:\n\twokrker_name=mwoffliner4\n\twebapi_uris=['https://api.farm.openzim.org/v2']\n\tworkdir=/data\n\ttask_id=7389b26f-a00b-443c-a33d-b5bc9d26a955\n[2026-05-22 10:46:46,531: INFO] testing workdir at /data…\n[2026-05-22 10:46:46,531: INFO] \tworkdir is available and writable\n[2026-05-22 10:46:46,531: INFO] testing private key at /etc/ssh/keys/zimfarm…\n[2026-05-22 10:46:46,536: INFO] \tprivate key is available and readable (SHA256:bvIpNQHY3pruOe4/1wPsdAifK1Y0JH203Fu17wgWM9s)\n[2026-05-22 10:46:46,536: INFO] testing authentication with https://api.farm.openzim.org/v2…\n[2026-05-22 10:46:46,873: INFO] \tauthentication successful\n[2026-05-22 10:46:46,873: INFO] testing docker API on /var/run/docker.sock…\n[2026-05-22 10:46:46,936: INFO] \tdocker API access successful\n[2026-05-22 10:46:46,954: INFO] Hardware resources:\n\tCPU : 6 (total) ; 3 (avail)\n\tRAM : 30 GiB (total) ; 15 GiB (avail)\n\tDisk: 200 GiB (configured) ; 70 GiB (avail) ; 130 GiB (reserved) ; \n[2026-05-22 10:46:46,954: INFO] registering exit signals\n[2026-05-22 10:46:46,954: INFO] Fetching task details for 7389b26f-a00b-443c-a33d-b5bc9d26a955\n[2026-05-22 10:46:47,336: INFO] Updating task-status=started\n[2026-05-22 10:46:47,736: INFO] Setting-up workdir\n[2026-05-22 10:46:47,745: INFO] Starting DNS cache\n[2026-05-22 10:46:48,692: DEBUG] DNS Cache started using IPs: ['172.17.0.8']\n[2026-05-22 10:46:48,693: INFO] Starting scraper. Expects files at: /data/volume1/zimfarm/data/7389b26f-a00b-443c-a33d-b5bc9d26a955 \n[2026-05-22 10:46:48,696: DEBUG] Pulling image ghcr.io/openzim/mwoffliner:1.17.5\n[2026-05-22 10:46:50,698: INFO] Updating task-status=scraper_started\n[2026-05-23 19:20:33,677: INFO] Gathering ZIM metadata for /data/7389b26f-a00b-443c-a33d-b5bc9d26a955/8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:20:33,709: INFO] ZIM file created: 8598bf15-0e01-70a0-3675-0be035961159.zim, 11.01 GiB\n[2026-05-23 19:20:34,079: INFO] Starting zim uploader for /8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:20:35,093: INFO] Starting zim checker for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:29:35,866: INFO] Updating file-status=uploaded for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:58:42,345: INFO] Updating file check-result=1 for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-23 19:58:42,708: INFO] Zimcheck output written to 8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json\n[2026-05-23 19:58:42,756: INFO] Starting zimcheck uploader for 8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json\n[2026-05-23 19:59:42,882: INFO] Zimcheck Uploader for 8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json complete 0\n[2026-05-23 19:59:42,883: INFO] Updating file check-result-uploaded=8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json for 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-25 07:32:37,549: INFO] Updating task-status=scraper_completed. Exit code: 0\n[2026-05-25 07:32:38,050: DEBUG] Dumping docker logs to file…\n[2026-05-25 07:32:38,257: DEBUG] Starting log uploader container…\n[2026-05-25 07:32:39,514: DEBUG] No artifacts configured for upload\n[2026-05-25 07:32:39,525: INFO] Gathering ZIM metadata for /data/7389b26f-a00b-443c-a33d-b5bc9d26a955/950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 07:32:39,546: INFO] ZIM file created: 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim, 18.31 GiB\n[2026-05-25 07:32:39,940: INFO] Starting zim uploader for /950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 07:32:41,038: INFO] Starting zim checker for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 07:33:03,054: INFO] Scraper log upload complete: 0\n[2026-05-25 07:33:03,054: INFO] Sending scraper log filename: 7389b26f-a00b-443c-a33d-b5bc9d26a955_mwoffliner.log\n[2026-05-25 07:33:03,405: INFO] Stopping and removing log_uploader\n[2026-05-25 07:47:03,807: INFO] Updating file-status=uploaded for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:13:04,889: INFO] Updating file check-result=1 for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:13:05,378: INFO] Zimcheck output written to 950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json\n[2026-05-25 08:13:05,427: INFO] Starting zimcheck uploader for 950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json\n[2026-05-25 08:14:05,455: INFO] Zimcheck Uploader for 950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json complete 0\n[2026-05-25 08:14:05,455: INFO] Updating file check-result-uploaded=950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json for 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:14:05,907: INFO] Contents of /data/7389b26f-a00b-443c-a33d-b5bc9d26a955 (recursive):\n[2026-05-25 08:14:05,908: INFO] 8598bf15-0e01-70a0-3675-0be035961159.zim\n[2026-05-25 08:14:05,909: INFO] 950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim\n[2026-05-25 08:14:05,909: INFO] Updating task-status=succeeded\n" - }, - "canceled_by": null, - "container": { - "log": "7389b26f-a00b-443c-a33d-b5bc9d26a955_mwoffliner.log", - "image": "ghcr.io/openzim/mwoffliner:1.17.5", - "stats": { - "memory": { "max": 7516192768 }, - "cpu": { "max": 678.475183116077, "avg": 239.03 }, - "disk": { "max": 41753439045 } - }, - "artifacts": null, - "stderr": "[warn] [2026-05-25T07:12:26.105Z] Skipping redirect of 'Ultor_de_Lacy' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy' because target is not a known article\n[warn] [2026-05-25T07:13:02.076Z] Skipping redirect of 'The_Haunted_Baronet' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet' because target is not a known article\n[warn] [2026-05-25T07:13:03.522Z] Skipping redirect of 'Ghost_Stories_of_Chapelizod' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod' because target is not a known article\n[warn] [2026-05-25T07:14:05.307Z] Skipping redirect of 'The_Gallic_Wars' to 'Commentaries_on_the_Gallic_War' because target is not a known article\n[warn] [2026-05-25T07:14:05.912Z] Skipping redirect of 'The_Drunkard's_Dream' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Drunkard's_Dream' because target is not a known article\n[warn] [2026-05-25T07:15:06.017Z] Skipping redirect of 'Reports_on_the_Gallic_War' to 'Commentaries_on_the_Gallic_War' because target is not a known article\n[warn] [2026-05-25T07:15:07.583Z] Skipping redirect of 'Laura_Silver_Bell' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Laura_Silver_Bell' because target is not a known article\n[warn] [2026-05-25T07:16:13.080Z] Skipping redirect of 'The_Vision_of_Tom_Chuff' to 'J._S._Le_Fanu's_Ghostly_Tales/Volume_5/The_Vision_of_Tom_Chuff' because target is not a known article\n", - "stdout": "[log] [2026-05-25T07:11:53.893Z] 65000 redirects have been processed (49.2 %)\n[log] [2026-05-25T07:12:24.963Z] 70000 redirects have been processed (53 %)\n[log] [2026-05-25T07:12:26.082Z] 75000 redirects have been processed (56.8 %)\n[log] [2026-05-25T07:13:02.543Z] 80000 redirects have been processed (60.6 %)\n[log] [2026-05-25T07:13:03.797Z] 85000 redirects have been processed (64.4 %)\n[log] [2026-05-25T07:14:05.502Z] 90000 redirects have been processed (68.2 %)\n[log] [2026-05-25T07:14:06.658Z] 95000 redirects have been processed (72 %)\n[log] [2026-05-25T07:15:06.565Z] 100000 redirects have been processed (75.7 %)\n[log] [2026-05-25T07:15:07.735Z] 105000 redirects have been processed (79.5 %)\n[log] [2026-05-25T07:15:39.663Z] 110000 redirects have been processed (83.3 %)\n[log] [2026-05-25T07:15:40.816Z] 115000 redirects have been processed (87.1 %)\n[log] [2026-05-25T07:16:12.146Z] 120000 redirects have been processed (90.9 %)\n[log] [2026-05-25T07:16:13.265Z] 125000 redirects have been processed (94.7 %)\n[log] [2026-05-25T07:16:44.410Z] 130000 redirects have been processed (98.5 %)\n[log] [2026-05-25T07:16:44.840Z] Finishing ZIM Creation\nResolve redirect\nset index\n[log] [2026-05-25T07:32:35.909Z] Summary of scrape actions: {\n\t\"files\": {\n\t\t\"success\": 351845,\n\t\t\"fail\": 75\n\t},\n\t\"articles\": {\n\t\t\"success\": 4443232,\n\t\t\"hardFail\": 0,\n\t\t\"hardFailedArticleIds\": [],\n\t\t\"softFail\": 51,\n\t\t\"softFailedArticleIds\": [\n\t\t\t\"Portal:Johann_August_Ernesti\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXVII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_V\",\n\t\t\t\"DeCSS_Haiku\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_X\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XVIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXI\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod/The_Village_Bully\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/The_Vision_of_Tom_Chuff\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/The_Child_That_Went_with_the_Fairies\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_VIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Wicked_Captain_Walshawe,_of_Wauling/Chapter_III\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Mysterious_Lodger/Part_II\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod/The_Spectre_Lovers\",\n\t\t\t\"Commentaries_on_the_Gallic_War/Book_6\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Mysterious_Lodger\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_IX\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_I\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_III\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Laura_Silver_Bell\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Wicked_Captain_Walshawe,_of_Wauling/Chapter_VI\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_II\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XI\",\n\t\t\t\"Commentaries_on_the_Gallic_War\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_VII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_1/Schalken_the_Painter\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_VII\",\n\t\t\t\"Madam_Crowl's_Ghost_and_the_Dead_Sexton\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Drunkard's_Dream\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Stories_of_Lough_Guir/The_Banshee\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_VI\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_VIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_IV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XIV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXIX\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/The_Mysterious_Lodger/Part_I\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_5/Wicked_Captain_Walshawe,_of_Wauling/Chapter_I\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XXVIII\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_XV\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/An_Authentic_Narrative_of_a_Haunted_House\",\n\t\t\t\"Commentaries_on_the_Gallic_War/Book_4\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy\",\n\t\t\t\"Two_Ghostly_Mysteries\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_2/Ultor_de_Lacy/Chapter_IV\",\n\t\t\t\"Two_Ghostly_Mysteries/A_Chapter_in_the_History_of_a_Tyrone_Family\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_4/Ghost_Stories_of_Chapelizod\",\n\t\t\t\"J._S._Le_Fanu's_Ghostly_Tales/Volume_3/The_Haunted_Baronet/Chapter_V\"\n\t\t]\n\t},\n\t\"redirects\": {\n\t\t\"written\": 132021\n\t}\n}\n[log] [2026-05-25T07:32:35.909Z] ZIM is ready at [/output/wikisource_en_all_maxi_2026-05.zim]\n[log] [2026-05-25T07:32:35.921Z] Finished dump\n[log] [2026-05-25T07:32:35.921Z] Closing HTTP agents...\n[log] [2026-05-25T07:32:35.921Z] All dumping(s) finished with success.\n[log] [2026-05-25T07:32:35.925Z] Flushing Redis DBs\n[log] [2026-05-25T07:32:35.926Z] Exiting with code [0]\n[log] [2026-05-25T07:32:35.926Z] Deleting temporary directory [/dev/shm/mwoffliner-1779446819566]\n", - "command": [ - "mwoffliner", - "--mwUrl=https://en.wikisource.org/", - "--adminEmail=contact@kiwix.org", - "--customZimDescription='Wikisource is an library of public domain texts'", - "--publisher=openZIM", - "--format=nopic:nopic", - "--format=novid:maxi", - "--optimisationCacheUrl='************************'", - "--addNamespaces=100", - "--osTmpDir=/dev/shm", - "--outputDirectory=/output", - "--verbose=log", - "--webp", - "--forceRender=ActionParse" - ], - "progress": null, - "exit_code": 0 - }, - "notification": null, - "files": { - "8598bf15-0e01-70a0-3675-0be035961159.zim": { - "name": "8598bf15-0e01-70a0-3675-0be035961159.zim", - "task_id": "7389b26f-a00b-443c-a33d-b5bc9d26a955", - "status": "check_results_uploaded", - "size": 11824699968, - "cms_on": "2026-05-23T20:00:18Z", - "cms_notified": true, - "created_timestamp": "2026-05-23T19:20:34Z", - "uploaded_timestamp": "2026-05-23T19:29:36Z", - "failed_timestamp": null, - "check_timestamp": "2026-05-23T19:58:42Z", - "check_result": 1, - "check_filename": "8598bf15-0e01-70a0-3675-0be035961159_zimcheck.json", - "check_upload_timestamp": "2026-05-23T19:59:43Z", - "info": { - "id": "8598bf15-0e01-70a0-3675-0be035961159", - "size": 11824699968, - "counter": { - "font/ttf": 1, - "text/css": 30, - "image/png": 14, - "text/html": 4449189, - "image/jpeg": 1, - "image/svg+xml": 24, - "application/pdf": 2, - "text/javascript": 3, - "application/javascript": 4, - "text/html; charset=iso-8859-1": 1, - "image/svg+xml; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"": 70789 - }, - "metadata": { - "Date": "2026-05-22", - "Name": "wikisource_en_all", - "Tags": "wikisource;_category:wikisource;_pictures:no;_videos:no;_details:yes;_ftindex:yes", - "Title": "Wikisource", - "Source": "en.wikisource.org", - "Counter": "application/javascript=4;application/pdf=2;font/ttf=1;image/jpeg=1;image/png=14;image/svg+xml=24;image/svg+xml; charset=utf-8; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"=70789;text/css=30;text/html=4449189;text/html; charset=iso-8859-1=1;text/javascript=3", - "Creator": "Wikisource", - "Flavour": "nopic", - "Scraper": "mwoffliner 1.17.5", - "Language": "eng", - "Publisher": "openZIM", - "Description": "Wikisource is an library of public domain texts", - "Illustration_48x48@1": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAASzElEQVR4nK2ZB1hU17bHF4M9zxufSXy5eflyU7zGaDRWZCBRjNLExsyZASkiiAJ21GBssYAwjRk6EjWKLcYuooB0oyYxxFhQ6QNDHYZiNAU1+r/fPjOMokYw7+3z/b/N7HPYZ/32WnufXcjCwoLa1ZXUq1cvyj97gdfu3bspVBlGa2MiaaV8d7/lEbtc/OUnNvko0lJ8FGnXfBRpOh9FWo2PIq3YR5GWGyA7Fr06NGn26tCkt9bJkgXrVTG0KS6Kzp4/y8vR2YEE3S1I0M2SiLoZxcyyIBIQkaVJHWz+uwB5+efp4N5syyWKPTaz1fv2eUVmGbwjz8BHlY45yk7V5qc4kzdP/Y2nLDHqpbyzucQ0yWGS0VKBxVMA9H8F+ODdd8nRbirZO0+h6P25FHsgb9TnW7PT5oWdvOcrS4Wv4hT8lKcxR5EFP0U65qhS4aPMgbc6Bf6KVHhHpsNLnY4FihMIVJ7Ayi17IT964eGqoz/cjD+Q57lzb163wIBF5GA/gWxtRvLvFDCaxwBMdH8PwNn+U9oQsZyWy6J7zFeeCvWXnfx1bkQKmBiAjyKNb93AiHSExKZiY3I6/OXHME95GEHK0/BTHsIizQnIki7iQkUVWn65jx+bgFUZxZgXn/Pnsoj01DVhX765JjyMFgTPM7f2IwCB2SsvBNCzew/q0aMHOUx3oFWK5FcXyE9l+IUf5w03KpUH8JUbATbFZUDXCvwCIPNaOdaojyNwSwoSj+Si+fc/8Msd4C6AB/eB9NJfsCZPi5VnSjGXeSfiUPUi+Tbh3JUrqGcPAfXu3o0suj0O8YIAlpaWFCNXUFiUioJj9v3vfM2py3PkKZgjO81rflg6fCPSMUuVipWaIwhPzoGu8Tew9ABAoR74oQQ4cu46fudLf8efD2EUgF3X/8S6nHqsy9RiUVQ2/CMyMF+ReTs4ar9TeKyCNOFhNGz40Ce8IHgxgLAoBYVE7RsQoDl50U+VAl/FSb61mbyUmfBVfYPd315E6x2gjVndnh4CRbeBL7IqkKttMQOwR5juPgS2X2nD6sxaHmBpbC4PwEPIjrWGRCdPDNNE0PCRwx/r2F0EsLOzo/Xr19O6DV9QzJHM7kGyr4+wePZXHoef4hj8FCd4uWlysHLbKfz64E/gwQOA5Q8f8mJG/nwX2JhfiKwb5bjfTmVK7H7ypSaEZN/CquwmBCaehY/sOLzlR+ETfgyBEQd08r37/6VQRdG6LzbTvIBAsyc6BQgODqaysjIqKaug7ILKhfvTfjQkny4w7Ei73LQz/aphZ/rlpl3plwzbMgoN6ReLbt2+8wfu3r2Pe/f+NOvuvYdo+Q2oa76H39semu/fZ5wAbjGAn1oebsiqhyarHLvO/NSyI/OKYXv21abkMz8avsn43nAi/9Lxm0VVlhXaBjqVltHugM4BFi4Kop+vXKXrxdp3i8tq7tws0eFamR5XKlpwVduKQm0zCrWtKCrXo6S8FkUV9bxKtA18fqO0FtfLq3GlqgJXq7UoqqxBWWWdWUXaahRVNeGHmzrkFlbhQlEdikvL8bO2CZcqW3C1Uofi8goUl7Swd8y7dKWMDh053LkHBKZrxMghJPIJpNmf79ixPPTr5uDN+5s37Dn328LteW0Lt59tW771XNvyrWfNWvHlt22fbfuuLWTHD3zOfjMtSzQqePuFNrfg5LbZi3a1zVrSUW5Ld7VxwUbNWvoVL49lO9vmhOz7NTgivXlh2NGbUt9l/abPnEZEPXh1DvDRKOI8Nw22dVX/MUaSCKaIvVfgrcrCZFUBpqjPPyXnyG/53FF1Fg7KfF7tZeLwsxgjiseoiXIMnyTHsMnGfPgkOYZOlmOQoxyDnRQYbm/USHsVRjqp8bF4K6zFari4rVnmMkPUdYCBI2xoors6VihKhJV0K8aKExCadAyJqdcglqVDLD8NV8VpSENz4So7iamqR8a6KM7CRZaPKfJ8HoaVi2TfwsZzB2/w+/ayDhrkGIGBTqvgE6JG4v4ccAuSMNhxC4a4KDFaGo/xM6JgL9pcbOcys1eXQ+iNIY59hG4qnVCqhlCSACtRHMRzk3CjugEXi6qQ9m05r4wfanHkagVmRWbCWZ0NB1UmpiqzMEOehWnyTDirsnhNU2XDfvlBDHUKw2D7zR30vsNG2Hqsx1VdFarrDfiuSA+hOBYfuIRhFBcJm5lbYeua9GDcpMDxnXbivn3603+99A96a6T7RKE05oHQPQxCaQysuWiMdpdjzoYU7Dx2HaWNd1BqaEV1YyMqWpoh23MRUtlxzI5Kx7KdP2F6RC6myzJ5iOkKIwT7PYLTYNikzR304eTN8FqxDVX1BtTpGlHa0IiAdXn4yDEc1jM1GCuVwYpLxBjHZZF9+vbh7ftLAInrHHKVuJPQPSyUtTwzXsht5WUljsJYsRI2UhXWhqYgNa8YP1XU4Hp9A67qqlFwXYeS6ls4V9zKh9GMyHzMUGbzclVkQ6TKhUPIYQx1DMdg+zCzhjhEwNU/ERXVv0BXX43i5ltYl5iHjxwUsBXFY5xbFKykGti4b77k4eZp6SMKeg6AZCq5iueQUBqT9iQA84K1JBJWnBpWM/ZCKInDBO8YTPJNgvPcWJy/VAptw28oNjzE4ezLkLJWV2SZAXgIRTbsVhzCmzNlGOS0BUMctmCoowxjndYiI7sAVXU63DQ0I3z3j08DSNStHtK5A3yk0k4AOK8eQklcsRlAGmWUJA7WXDyvsSLWsePMshGpkZF/E9pGA7RNd1Dd0ordOVchjswBJ8/ANGU+piovYJryW0xX5MApLB12Sw/CJmAfbHz3Y030IdwoL4e+rgkVjb8jYlcBPnQO5xttnDQOVpJYXjMlPsMk0hmdAXj0E0ri6h8BqI2SxBk9IU7kZSVKMMtWFItTWdehNdSjtE6PsoYGVDTfQXJ+OSTyU7zxU1Tf87mLPJuXkyKfv7c09jgyCm9Ap6+FobYFlfo2hG6/iCFTwviWHydNgBUXjzHSWEzlvB1cuecBcNOZBwYIJQmtz/IAXyZJMHuiXbbiaBxPu4zKpgYU6+pwOCMP13QNKG8wQHXqGtzlGZityoen5gKclefgKsvCpMhczFgbj+uN9bjZWg2dvh76uhaU6+9gQ+J3GO4SDmuJhjeel4QHED8fQCxifWCAkNtqBuBjvxPZcpE4mHKRByjR1uH096XY9FUGzpc14EpjKy6XtuBGxW/Yk62Fk+Y8OMUpjF99FEvC9/GjT6Vej8qGVtTW30ZxSxM+j/kOQk5jlDielzXXdYD/FnJbG14UYP+RCyjX1+JmeQ0uaW9DtqcAGzWXkFdUiVJ9HQ93rdGAhfvSMV55GB8E7cS6uJPQNTR3AChtbcYSWdZfATg+F8DXx5u8fBb1snXbVmYMj8cNjTXGf4dyUwiJYpF87AJuNNWgsKqan4zlXSvDQuU+rIhJQ1ahHpX6ZugbalDUUIt5O/Pwjl8M1AdzUKdveASgb+JHMemSw2bDWT8YI9XwuUg8e5SEm/nXAPP9/cg/YCV9Ont37tMA8c8GECfyX849qT/wADeq63BDp8NlXS0UR3PgH3oMi+MyeQhtvQHaeh1CDhfgPf9YRB7MREND3WMAjbhcdRf23l+ZAcY8Argj5nz+6c65/jXAzGnTaeo0KY2btiXa2GHjOnrA3HFjO/xmHjiQWoDixhp+FCquqcW16hqkF1ZhoTIFHvIMLInPQVahAeX6euwtqMbA+XGQf30adfpHANX6FpwruvNY/Wz4jOY1TrS5yM7OuefETyd0Phd6c8h08SNjnxX3T49CR1IKoNXXotLARhI974XrVc0I23+B32bxk2UgJDYHF4u1KGm4g+mbdiMoeg+0hlozQKX+V5y+1Ipx7lsxWqLmW77dE2MnB+4iCzYREnQJ4HVrLr71RQBSM66hsrEeWn0ziqpreIBLJXXYmJzPr58ZwAJZCk6f/xHF9beRXViBcZ/vgLa1CVWGRlTpb6HK8BsSjv2Mj713wMo9ipcNl8DLyj5I2mWAfw8bTBOnJx5h4++z+8Bj3pFo+CnGwe+KUNZQg7KaBmPr62qw90I1ZoSnY054Gr9Y/yzmJEoa9ShpNGD/T3q4hh1EcZ0B2oZ63gNl9U1Q77sCuznb+VZnhgu5L2EnjtNPdnbsb2myr1OA9z8cQpMlIY5jOPmDpwA6QDwC2JN3hQcor9XzHmCj0bbcCgybn4TRQYkYv3IngrdnoLy5iYeIPnMTbrLDKNM/GkbZh0+2s6ADgI00AZPcVsdMmjKFGIBlVwAGDxtK0zwDLMe7hl98tgfaO9mjsPLdmIwbNTUor6tHSWUlLlTdwbzEXLw/PxEDA+Px3oI4eEUd5Vub6cuMS/gkZDdK6wx8563W30JB5W0slp3EBC/jtIVplIf691kL1wzkN35NGyudAnz40VDiZs2jmeLP7ay56IdPAfCtrjGPRHyouWuw/8RP0LL5jFaHyIwKvBf0Jd4NTOLF/nZXHkVNXS2vxDNX8V7gdsQcO4+ixvu4qb8H2dcF8A87ArvZsWaAiV7rIjn3AHJxnNz1Rf2Hw4cR5+lOrh6zLKy56J1dAWAfGonnDlwpNAIsTsrFO/O3PhOgurYGM2WneIARC5MQl14MdUohAmUp8Fq3D7azNO0AFZ4S735eUk+a4uTQdYCXevekV1/py8tB6vvqaHflTSGD+Ks+YLrHZqZrVfkoqauEb0KmGWDg/B0YNTcGy2Xb+XH/etN9jF9zAIOCtmHo/DjYLYrGbMVxzJadwfRVhzDGTQkrr8g/XDz8Ph045CN6+bU3qG///uZtn04B2mPNUkAkFknISbx0lFAc29gZANO4WRpknL2GiEPp+HDBVxjkvxWj5kZh/fZMnP1Zi5uGuzh07RcMDUriAYYHJoDbuA9zVCk8gNumU7Dxjv3TxW3ZYqkbZ/H6W+8RCXoSCSw7B3h8L54HsCAaP24sCa3saJz96km20rhbbF7+LAC2g8FGjhHSGMyYl4i9OVVYobkCW7ckWHMyeC5Lwr4T36OgRo9JG/Zg0KIkvLcoEfabj/BnCQyAbdFPXXnsgYM0dNMEa3uBnVBIL7/2Otuk7SJAe9Obt1GNhwzsGj5iFM3w3mRtxSlqnwXAln9MbIU2movDGEk8n1tJE2Ar1uATTg33ACWqq6uQekkHpw0HMHbZNrir2NlBKi+2cey+8XhawPzV3SypFzGRQNDBrr8NMHbYCJK6eZLjrIC3hVx0rrU40Tg6SSI7APCrJwnr1An8lIDpYy4KEyQxmLXia8QdLsIXiXlYkfA9vzXPvtDzFKm8/GUnEag5cGXxkqDeAjNA+9jZBYCORnczyXj16dmH+vXtR/369ScnT/9eTlzEEltRfIOVVGk2momFEQP5WByHT6QaXtZuCTwQP1qJjGtrtsrjPvsG88MzECA7zXQ/QLnjUIhK/eaC4MVmw9tPah5zwN8DaL+6W1qSm4cLeXhIadosvwGTp2vWT5wRq+sqQHv4TZoa9+s0qWrXvOC1kg2hMd4bwqKGRYRuFGzcEk6BwZ/9fwB0vNqTQCAgWytrElqPobHW42iklRWNGjuth7PU395ZvDLyE0590bimjmuzkSbcs3GLu2fttrVtnDTh148l6iIn11W7nbgg74mTPfoLJ9jSJ5+O5/eimGaJpxLHiWiykws9L3VhGH3a8OcldoYmkYhJIpGQhPNk6i7hPN/kxB6DObHHUE7s8TYn9ujLibxIJGbyoLfe/tdjFj2hx45UO55SvgCAsSN3EaB7L5KIfUjiNo3EbjNILBXzEklEJnGmfKZRIj966+13HhkmaB9EjGqPhr8L8A+yoFAS0AQSkBMRLSGisUTkRURvENFGInr1yQp79+7dJfV6qScvdiJPT7b2s7zxdzxAFuRHAvIkAfUmouNENNtkNNNcIhpARC+b8j6m8n5E9BoR9Tedh77DDvZNr2fPvWI8oaNXyYJeIQu20U//w+qxJBpgafzfbiaDXyILepcsqK/peVb/a2an/OV34FF6mSzoMFnwhiwnok2mcgbyPhGdI6J/E1EOEQ0ioggiGkJEMUTkS0RhRDTS9HwQEdkTEWeqi91bRkT/IKIEIhooIEoXEH1MRHuIaCgRLTLlIezIjog2E5HU9O4uAbC0hYg+MRlwiIheJyI3IupORMmmZ3YRkcbUOmQKN2si8iEiNRENI6K97ZFOREdN9bHQZIl5k6WdRORhMlhORIPbu5fpWbkJ4Pkh9ER6l4jSTBUsNr3kFRMAaymWdhCRq8lDrIKlRCQ0eeMDE+ghkyHdTL8lJm+wxIxmaTsRzSSi9SY5mspZCDsT0Rwi+icRvfUiACwxl7O4Y3HtZypjrZNERGOIKN5kaCARTSWidabOztzOwNjJnJUprFgLsrDoawoRZiSri3k2zlTPWlPoMU8woNGmull9DPajJwH+A4j3BOm0hcSZAAAAAElFTkSuQmCC" - }, - "media_count": 70828, - "article_count": 4575267 - }, - "zim_urls": [ - { - "kind": "download", - "url": "https://lbo.download.kiwix.org/zim/wikisource/wikisource_en_all_nopic_2026-05.zim", - "collection": "Kiwix" - }, - { - "kind": "view", - "url": "https://browse.library.kiwix.org/viewer#wikisource_en_all_nopic_2026-05", - "collection": "Kiwix" - } - ] - }, - "950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim": { - "name": "950dfcf0-873e-1938-2bc0-ddfe4bea7674.zim", - "task_id": "7389b26f-a00b-443c-a33d-b5bc9d26a955", - "status": "check_results_uploaded", - "size": 19663666206, - "cms_on": "2026-05-25T08:14:30Z", - "cms_notified": true, - "created_timestamp": "2026-05-25T07:32:39Z", - "uploaded_timestamp": "2026-05-25T07:47:04Z", - "failed_timestamp": null, - "check_timestamp": "2026-05-25T08:13:05Z", - "check_result": 1, - "check_filename": "950dfcf0-873e-1938-2bc0-ddfe4bea7674_zimcheck.json", - "check_upload_timestamp": "2026-05-25T08:14:05Z", - "info": { - "id": "950dfcf0-873e-1938-2bc0-ddfe4bea7674", - "size": 19663666206, - "counter": { - "font/ttf": 1, - "text/css": 30, - "image/gif": 1142, - "image/png": 14, - "text/html": 4449187, - "image/jpeg": 1, - "image/webp": 278082, - "image/svg+xml": 24, - "application/pdf": 2, - "text/javascript": 3, - "application/javascript": 4, - "text/html; charset=iso-8859-1": 1, - "image/svg+xml; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"": 72582 - }, - "metadata": { - "Date": "2026-05-22", - "Name": "wikisource_en_all", - "Tags": "wikisource;_category:wikisource;_pictures:yes;_videos:no;_details:yes;_ftindex:yes", - "Title": "Wikisource", - "Source": "en.wikisource.org", - "Counter": "application/javascript=4;application/pdf=2;font/ttf=1;image/gif=1142;image/jpeg=1;image/png=14;image/svg+xml=24;image/svg+xml; charset=utf-8; profile=\"https://www.mediawiki.org/wiki/Specs/SVG/1.0.0\"=72582;image/webp=278082;text/css=30;text/html=4449187;text/html; charset=iso-8859-1=1;text/javascript=3", - "Creator": "Wikisource", - "Flavour": "maxi", - "Scraper": "mwoffliner 1.17.5", - "Language": "eng", - "Publisher": "openZIM", - "Description": "Wikisource is an library of public domain texts", - "Illustration_48x48@1": "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" - }, - "media_count": 351845, - "article_count": 4575253 - }, - "zim_urls": [ - { - "kind": "download", - "url": "https://lbo.download.kiwix.org/zim/wikisource/wikisource_en_all_maxi_2026-05.zim", - "collection": "Kiwix" - }, - { - "kind": "view", - "url": "https://browse.library.kiwix.org/viewer#wikisource_en_all_maxi_2026-05", - "collection": "Kiwix" - } - ] - } - }, - "upload": { - "zim": { - "expiration": 0, - "upload_uri": "sftp://uploader@warehouse-b.farm.openzim.org:30222/zim", - "zimcheck": "--all", - "disable_warehouse_path": true - }, - "logs": { - "expiration": 60, - "upload_uri": "s3://s3.us-west-1.wasabisys.com/?keyId=************************&secretAccessKey=************************&bucketName=org-kiwix-zimfarm-logs" - }, - "artifacts": { - "expiration": 30, - "upload_uri": "s3://s3.us-west-1.wasabisys.com/?keyId=************************&secretAccessKey=************************&bucketName=org-kiwix-zimfarm-artifacts" - }, - "check": { - "expiration": 0, - "upload_uri": "s3://s3.us-west-1.wasabisys.com/?keyId=************************&secretAccessKey=************************&bucketName=org-kiwix-zimfarm-zimcheck-results" - } - }, - "offliner": "mwoffliner", - "version": "1.17.5" -} diff --git a/backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 b/backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 deleted file mode 100644 index cc05925c..00000000 --- a/backend/d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0 +++ /dev/null @@ -1,47 +0,0 @@ -{ - "id": "d3e1078a-7ace-26bd-7eb3-5c8dd3a4c3f0", - "title_id": "fa8c7867-1bd0-4838-a409-96bf974c1d10", - "location_kind": "prod", - "needs_processing": false, - "has_error": false, - "needs_file_operation": false, - "deletion_date": null, - "created_at": "2026-04-23T07:47:09Z", - "name": "alpinelinux_en_all", - "date": "2026-04-21", - "flavour": "maxi", - "warnings": [], - "article_count": 1007, - "media_count": 76, - "size": 3047126, - "zimcheck_result_url": null, - "zim_metadata": { - "Date": "2026-04-21", - "Name": "alpinelinux_en_all", - "Tags": "Linux distro;alpinelinux;_pictures:yes;_videos:no;_details:yes;_ftindex:yes", - "Title": "Alpine Linux Wiki", - "Source": "wiki.alpinelinux.org", - "Counter": "application/javascript=4;image/png=1;image/svg+xml=11;image/webp=64;text/css=12;text/html=786;text/javascript=3", - "Creator": "Alpinelinux", - "Flavour": "maxi", - "Scraper": "mwoffliner 1.17.5", - "Language": "eng", - "Publisher": "openZIM", - "Description": "Alpine Linux is a security-oriented, lightweight Linux distribution", - "Illustration_48x48@1": "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" - }, - "events": [ - "2026-04-23 07:47:09.831459: maintenance script: book created from existing ZIM file", - "2026-04-23 07:47:09.834079: maintenance script: added current location: other/alpinelinux_en_all_maxi_2026-04.zim" - ], - "current_locations": [ - { - "warehouse_name": "download/zim", - "path": "other", - "filename": "alpinelinux_en_all_maxi_2026-04.zim", - "status": "current" - } - ], - "target_locations": [], - "title_archived": 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/frontend/src/components/TitleForm.vue b/frontend/src/components/TitleForm.vue index 68e5c794..ac2df24c 100644 --- a/frontend/src/components/TitleForm.vue +++ b/frontend/src/components/TitleForm.vue @@ -57,12 +57,22 @@ variant="outlined" density="comfortable" clearable + :color="!inDialog && isFieldDifferent('title') ? 'warning' : undefined" + :base-color="!inDialog && isFieldDifferent('title') ? 'warning' : undefined" /> <div v-if="!inDialog && isFieldDifferent('title')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.title }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('title')"> + <v-btn + size="small" + variant="outlined" + color="warning" + class="ml-3" + @click="useBookValue('title')" + > Use this </v-btn> </div> @@ -75,12 +85,22 @@ variant="outlined" density="comfortable" clearable + :color="!inDialog && isFieldDifferent('creator') ? 'warning' : undefined" + :base-color="!inDialog && isFieldDifferent('creator') ? 'warning' : undefined" /> <div v-if="!inDialog && isFieldDifferent('creator')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.creator }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('creator')"> + <v-btn + size="small" + variant="outlined" + color="warning" + class="ml-3" + @click="useBookValue('creator')" + > Use this </v-btn> </div> @@ -96,12 +116,22 @@ variant="outlined" density="comfortable" clearable + :color="!inDialog && isFieldDifferent('publisher') ? 'warning' : undefined" + :base-color="!inDialog && isFieldDifferent('publisher') ? 'warning' : undefined" /> <div v-if="!inDialog && isFieldDifferent('publisher')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.publisher }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('publisher')"> + <v-btn + size="small" + variant="outlined" + color="warning" + class="ml-3" + @click="useBookValue('publisher')" + > Use this </v-btn> </div> @@ -114,12 +144,22 @@ variant="outlined" density="comfortable" clearable + :color="!inDialog && isFieldDifferent('language') ? 'warning' : undefined" + :base-color="!inDialog && isFieldDifferent('language') ? 'warning' : undefined" /> <div v-if="!inDialog && isFieldDifferent('language')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.language }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('language')"> + <v-btn + size="small" + variant="outlined" + color="warning" + class="ml-3" + @click="useBookValue('language')" + > Use this </v-btn> </div> @@ -135,12 +175,22 @@ variant="outlined" density="comfortable" clearable + :color="!inDialog && isFieldDifferent('license') ? 'warning' : undefined" + :base-color="!inDialog && isFieldDifferent('license') ? 'warning' : undefined" /> <div v-if="!inDialog && isFieldDifferent('license')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.license }}</strong> - <v-btn size="small" variant="text" color="primary" @click="useBookValue('license')"> + <v-btn + size="small" + variant="outlined" + color="warning" + class="ml-3" + @click="useBookValue('license')" + > Use this </v-btn> </div> @@ -180,7 +230,9 @@ v-if="!inDialog && isFieldDifferent('illustration_48x48_at_1')" class="text-body-2 mt-2 mb-2" > - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between mb-2"> <v-img :src="getImageDataUrl(bookMetadata?.illustration_48x48_at_1)" @@ -190,8 +242,9 @@ /> <v-btn size="small" - variant="text" - color="primary" + variant="outlined" + color="warning" + class="ml-3" @click="useBookValue('illustration_48x48_at_1')" > Use this @@ -210,15 +263,20 @@ density="comfortable" rows="3" clearable + :color="!inDialog && isFieldDifferent('description') ? 'warning' : undefined" + :base-color="!inDialog && isFieldDifferent('description') ? 'warning' : undefined" /> <div v-if="!inDialog && isFieldDifferent('description')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.description }}</strong> <v-btn size="small" - variant="text" - color="primary" + variant="outlined" + color="warning" + class="ml-3" @click="useBookValue('description')" > Use this @@ -237,15 +295,20 @@ density="comfortable" rows="5" clearable + :color="isFieldDifferent('long_description') ? 'warning' : undefined" + :base-color="isFieldDifferent('long_description') ? 'warning' : undefined" /> <div v-if="isFieldDifferent('long_description')" class="text-body-2 mt-n2 mb-2"> - <div class="mb-1">Latest book has:</div> + <div class="mb-1 text-warning font-weight-medium"> + Different from latest book which has: + </div> <div class="d-flex align-center justify-space-between"> <strong>{{ bookMetadata?.long_description }}</strong> <v-btn size="small" - variant="text" - color="primary" + variant="outlined" + color="warning" + class="ml-3" @click="useBookValue('long_description')" > Use this diff --git a/frontend/src/views/TitleView.vue b/frontend/src/views/TitleView.vue index b4ce272d..fecfa207 100644 --- a/frontend/src/views/TitleView.vue +++ b/frontend/src/views/TitleView.vue @@ -270,7 +270,7 @@ <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"> + <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" @@ -306,8 +306,7 @@ </v-alert> </v-card-text> - <!-- Action Buttons at Bottom --> - <div class="d-flex flex-column flex-sm-row justify-end ga-2"> + <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"