Repository: openzim/wp1
Pull request #855: feat: add title field and metadata handling for ZIM file creation
Author: login: nicolapace
Created: 2025-04-15T09:44:24Z | Merged: 2025-04-22T22:57:06Z

Fixes #677 

I added a `Title` field in the Zim creation page. This field is automatically filled with the `Selection Title` reduced to 30 characters. In this way users can change it if they want. This field is required and is of max 30 chars. (I also added a char counter for both the title and the description).

I also added backend validation for the size of zim metadata (title (30) and description (80) as per https://wiki.openzim.org/wiki/Metadata).

I added new unit test for the new "title field" and the validation, for both the backend and the frontend. I also fixed some unit tests and functions to support the new `zimtitle` field.

Here is the Zim creation page with the new field:

![Screenshot](https://github.com/user-attachments/assets/309046a2-43d5-4b6f-ae63-a6be476cec2c)

Linked issue #677: Limit length of ZIM file `zimTitle` field
Currently, the `customZimTitle` field that we send to the zimfarm is copied directly from the Builder name, which has no restrictions on its length, besides being truncated to 255 characters in the database. ZIM titles, on the other hand, can only be 30 characters max.

A couple of options:

1. Inherit the ZIM restriction and limit Builder titles to 30 characters
1. Truncate the Builder title when creating a ZIM

I tend to prefer the second option, because I don't see why the limitations of a ZIM should limit the entire WP1 UI.
Linked issue comments: user: login: benoit74
created_at: 2023-11-08T10:19:52Z
body: For me option 2 is not very good, because it means that we can get ZIM titles with a last word truncated in the middle (kind of ugly) and also duplicate ZIM titles (for different selections) due to identical first 30 chars among them.

I tend to prefer either option 1 (with a clear warning for the user) or a third option: add the ZIM title to information the user must input when creating a ZIM (just like description and long description) and automatically populate it based on the truncated Builder title. This way, the user will be able to do whatever is needed to make it look good.
author_association: CONTRIBUTOR
user: login: audiodude
created_at: 2023-11-10T06:19:52Z
body: Yes I like this new "option 3" as well: query for a separate ZIM title on the ZIM creation page, that is auto populated with a (possibly truncated) version of the Builder name. Then the user can decide how best to untruncate it.

:+1: 
author_association: MEMBER
user: login: kelson42
created_at: 2025-04-04T15:40:11Z
body: It is not 30 characters but 30 graphemes, see https://wiki.openzim.org/wiki/Metadata. 

Description metadata should also be checked,

It is really important to implement this otherwise the system will fail to generate a ZIM file
author_association: COLLABORATOR
user: login: audiodude
created_at: 2025-04-16T01:23:02Z
body: @kelson42 

Sorry, I'm not sure what you mean by "grapheme". I've heard the word before, and read the Wikipedia article that is linked from the page you linked (https://wiki.openzim.org/wiki/Metadata), but that doesn't help me understand the distinction between a grapheme and a character.

I assume we're talking about the difference between bytes and characters? In ASCII, 1 character is always 1 byte, but in Unicode, a character can be multiple bytes. It would make sense that this is not a byte limit of 30, but a character limit of 30, so if the writing system is Chinese, it's still 30 characters which might be ~100 bytes.

On the other hand, a "grapheme limit" of 30 makes no sense to me. How are graphemes generally represented in Unicode?

I must say, I don't understand what a grapheme is or how it relates to bytes, characters and Unicode. Please explain further, or better yet, update the openzim wiki page with the information because I'm sure others in the future will be wondering the same thing.
author_association: MEMBER
user: login: kelson42
created_at: 2025-04-16T04:59:56Z
body: @audiodude I won't be able to explain better than Wikipedia what is a grapheme. IMHO what you miss is the tooling to count them as you don't understand how it relates to an encoding like UTF8.

To answer this latest question you can for example read https://stackoverflow.com/questions/27331819/whats-the-difference-between-a-character-a-code-point-a-glyph-and-a-grapheme#27331885. But to keep it short there is no straight way to know how many grpahemes you have based on the UTF8 string.

You probably better have to use a dedicated library like in MWoffliner (sorry don't have the exact reference, butbif you search for "grapheme" you will find it quickly).

This PR - like it is - will fail very often with title /descriptions in Non-latin alphabets. It will pass in WP1 and fail straight in MWoffliner.
author_association: COLLABORATOR
user: login: audiodude
created_at: 2025-04-16T05:37:40Z
body: Thanks for the deeper explanation and the link. It sounds like you're referring to the old "number of bytes != number of characters" issue that I mentioned in my comment, except using the more precise term _grapheme_ instead of _character_. I will reference the grapheme implementation specifically in MWoffliner, since that is the system we wish to integrate with.
author_association: MEMBER
user: login: benoit74
created_at: 2025-04-16T07:36:49Z
body: Node.JS library we use in mwoffliner is [split-by-grapheme](https://www.npmjs.com/package/split-by-grapheme)

You can find it in used at https://github.com/openzim/mwoffliner/blob/428b2084a2a52d3ea2cbd5839697e02983644925/src/util/metaData.ts#L27
author_association: CONTRIBUTOR
user: login: audiodude
created_at: 2025-04-16T15:44:11Z
body: I think Python is okay right? Because Python `str` is inherently Unicode, so Python "characters" are actually "graphemes" and counting them gives the right number:

```
$ python
Python 3.12.0 (main, Oct  3 2023, 17:47:52) [GCC 12.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> s = '请给我一杯咖啡。'
>>> len(s)
8
>>> b = s.encode('utf-8')
>>> len(b)
24
>>> 
```

Here we are counting 8 characters/graphemes, even though it encodes to 24 utf-8 bytes.
author_association: MEMBER
user: login: kelson42
created_at: 2025-04-16T16:04:41Z
body: @audiodude What is the result with the string of https://github.com/openzim/mwoffliner/issues/2022#issuecomment-2086225148 ?
author_association: COLLABORATOR
user: login: audiodude
created_at: 2025-04-16T16:16:00Z
body: ```
$ python
Python 3.12.0 (main, Oct  3 2023, 17:47:52) [GCC 12.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> s = "विकी मेड मेडिकल इनसाइक्लोपीडिया हिंदी में"
>>> len(s)
41
>>> for i, g in enumerate(s):
...   print(i, g)
... 
0 व
1 ि
2 क
3 ी
4  
5 म
6 े
7 ड
8  
9 म
10 े
11 ड
12 ि
13 क
14 ल
15  
16 इ
17 न
18 स
19 ा
20 इ
21 क
22 ्
23 ल
24 ो
25 प
26 ी
27 ड
28 ि
29 य
30 ा
31  
32 ह
33 ि
34 ं
35 द
36 ी
37  
38 म
39 े
40 ं
>>> b = s.encode('utf-8')
>>> len(b)
113
>>> 
```

Yes I see the problem now. Multiple unicode code points/code units/"characters" are combined to create one grapheme.
author_association: MEMBER
user: login: audiodude
created_at: 2025-04-16T16:16:38Z
body: I still think that the openzim docs should contain a better explanation/example with information from these discussions, but that's a fight for another day.
author_association: MEMBER
user: login: benoit74
created_at: 2025-04-17T07:44:06Z
body: Sorry, got confused by the fact that mwoffliner was mentioned, you need a Python version of course. Python has its own libraries for that. Best solution found so far for scrapers is to use the `regex` package (not the `re` stdlib) : https://github.com/openzim/python-scraperlib/blob/fef63f81fdb9dd6d2a5e17d9c8785e3fd22665e9/src/zimscraperlib/zim/metadata.py#L175-L177
author_association: CONTRIBUTOR
user: login: benoit74
created_at: 2025-04-17T07:58:42Z
body: And I've fixed openzim doc (or at least made it slightly better): https://wiki.openzim.org/wiki/Metadata#Graphemes
author_association: CONTRIBUTOR

Diff:
diff --git a/Pipfile b/Pipfile
index 63eb34389..f526b346e 100644
--- a/Pipfile
+++ b/Pipfile
@@ -31,6 +31,7 @@ pytest-cov = "*"
 supervisor = "~=4.2"
 yoyo-migrations = "==7.3.2"
 pysocks = "*"
+regex = "==2024.11.6"
 
 # Versions required for dependabot
 sqlparse = "~=0.5.0"
diff --git a/Pipfile.lock b/Pipfile.lock
index aa4d491ca..85430f3e0 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "77951d5696a36a9d94c5622c31e614dd28d90e4c9ec429045ccf578c62111871"
+            "sha256": "25a8a306bc4838017d523de3d4cc7d7a9ac8612e686e93c209b6d1ab4717a3b3"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -49,19 +49,19 @@
         },
         "boto3": {
             "hashes": [
-                "sha256:77ff13723ad5b836a565c382610c3994e14ce643144dc9c604bfe1efb3213739",
-                "sha256:78fb57556c2337e087d2eda419ee371b52843a2420861114413791113efeabe2"
+                "sha256:5a90d674830adbaf86456d6b27a18f5f11378277da5286511fa860d2e7b14261",
+                "sha256:751ed599c8fd9ca24896edcd6620e8a32b3db1b68efea3a90126312240e668a2"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==1.37.26"
+            "version": "==1.37.35"
         },
         "botocore": {
             "hashes": [
-                "sha256:7f6dc999e7a34c0917623aac67c9ea2389b741bb7babee1a88cf2cd04006ea7a",
-                "sha256:d499a617903cbcaae18380320125fa3a95cb625b613d746e6edc69c6f01f1326"
+                "sha256:197a9bf8251c45b9d882c405ec0d0ab40c10e2d2a55ee66960185daec4beb6ec",
+                "sha256:50839212e90650d0b0fa6b8f7514876bf802f6164f2775f3abcd4d53c98bb73c"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==1.37.26"
+            "version": "==1.37.35"
         },
         "cachelib": {
             "hashes": [
@@ -259,9 +259,9 @@
         },
         "crontab": {
             "hashes": [
-                "sha256:89477e3f93c81365e738d5ee2659509e6373bb2846de13922663e79aa74c6b91"
+                "sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92"
             ],
-            "version": "==1.0.1"
+            "version": "==1.0.4"
         },
         "fakeredis": {
             "hashes": [
@@ -552,12 +552,12 @@
         },
         "pytest-cov": {
             "hashes": [
-                "sha256:cd7e1d54981d5185ef2b8d64b50172ce97e6f357e6df5cb103e828c7f993e201",
-                "sha256:ec55e828c66755e5b74a21bd7cc03c303a9f928389c0563e50ba454a6dbe71db"
+                "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a",
+                "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.9'",
-            "version": "==6.1.0"
+            "version": "==6.1.1"
         },
         "python-dateutil": {
             "hashes": [
@@ -651,6 +651,107 @@
             "markers": "python_version >= '3.7'",
             "version": "==4.4.4"
         },
+        "regex": {
+            "hashes": [
+                "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c",
+                "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60",
+                "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d",
+                "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d",
+                "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67",
+                "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773",
+                "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0",
+                "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef",
+                "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad",
+                "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe",
+                "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3",
+                "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114",
+                "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4",
+                "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39",
+                "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e",
+                "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3",
+                "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7",
+                "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d",
+                "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e",
+                "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a",
+                "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7",
+                "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f",
+                "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0",
+                "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54",
+                "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b",
+                "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c",
+                "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd",
+                "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57",
+                "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34",
+                "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d",
+                "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f",
+                "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b",
+                "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519",
+                "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4",
+                "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a",
+                "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638",
+                "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b",
+                "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839",
+                "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07",
+                "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf",
+                "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff",
+                "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0",
+                "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f",
+                "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95",
+                "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4",
+                "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e",
+                "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13",
+                "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519",
+                "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2",
+                "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008",
+                "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9",
+                "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc",
+                "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48",
+                "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20",
+                "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89",
+                "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e",
+                "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf",
+                "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b",
+                "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd",
+                "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84",
+                "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29",
+                "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b",
+                "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3",
+                "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45",
+                "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3",
+                "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983",
+                "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e",
+                "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7",
+                "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4",
+                "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e",
+                "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467",
+                "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577",
+                "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001",
+                "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0",
+                "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55",
+                "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9",
+                "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf",
+                "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6",
+                "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e",
+                "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde",
+                "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62",
+                "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df",
+                "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51",
+                "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5",
+                "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86",
+                "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2",
+                "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2",
+                "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0",
+                "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c",
+                "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f",
+                "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6",
+                "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2",
+                "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9",
+                "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2024.11.6"
+        },
         "requests": {
             "hashes": [
                 "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
@@ -743,11 +844,11 @@
         },
         "urllib3": {
             "hashes": [
-                "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df",
-                "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"
+                "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466",
+                "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"
             ],
             "markers": "python_version >= '3.9'",
-            "version": "==2.3.0"
+            "version": "==2.4.0"
         },
         "validators": {
             "hashes": [
diff --git a/wp1-frontend/cypress/e2e/zimFile.cy.js b/wp1-frontend/cypress/e2e/zimFile.cy.js
index fe2c20f72..95983e3dd 100644
--- a/wp1-frontend/cypress/e2e/zimFile.cy.js
+++ b/wp1-frontend/cypress/e2e/zimFile.cy.js
@@ -31,6 +31,29 @@ describe('the zim file creation page', () => {
           cy.get('#longdesc').should('be.visible');
         });
 
+        it('validates the title input on losing focus', () => {
+          cy.get('#zimtitle').click();
+          cy.get('#zimtitle').clear();
+          cy.get('#longdesc').click();
+          cy.get('#zimtitle-group > .invalid-feedback').should('be.visible');
+        });
+
+        it('validates the title input to have max 30 graphemes', () => {
+          const longTitle = 'A'.repeat(31);
+          cy.get('#zimtitle').click();
+          cy.get('#zimtitle').clear();
+          cy.get('#zimtitle').type(longTitle);
+          cy.get('#zimtitle').should('have.value', longTitle.substring(0, 30));
+        });
+
+        it('handles graphems correctly', () => {
+          const longTitle = "में".repeat(30);
+          cy.get('#zimtitle').click();
+          cy.get('#zimtitle').clear();
+          cy.get('#zimtitle').type(longTitle);
+          cy.get('#zimtitle').should('have.value', "में".repeat(30));
+        });
+
         it('validates the description input on losing focus', () => {
           cy.get('#desc').click();
           cy.get('#longdesc').click();
@@ -39,10 +62,10 @@ describe('the zim file creation page', () => {
 
         it('displays the Request ZIM file button', () => {
           cy.get('#request').should('be.visible');
-          cy.get('#request').should('not.have.attr', 'disabled');
+          cy.get('#request').should('have.attr', 'disabled');
         });
 
-        describe('when the description is missing and the submit button is clicked', () => {
+        describe('when the description is missing', () => {
           beforeEach(() => {
             cy.intercept('POST', 'v1/builders/1/zim', () => {
               throw new Error(
@@ -51,14 +74,14 @@ describe('the zim file creation page', () => {
             }).as('request');
           });
 
-          it('does not submit the form', () => {
-            cy.get('#request').click();
-          });
+            it('disables the submit button', () => {
+            cy.get('#request').should('have.attr', 'disabled');
+            });
 
-          it('shows the error message', () => {
-            cy.get('#request').click();
+            it('shows the error message', () => {
+            cy.get('#request').click({ force: true });
             cy.get('#desc-group > .invalid-feedback').should('be.visible');
-          });
+            });
         });
 
         describe('when the Request ZIM file button is clicked', () => {
@@ -152,7 +175,7 @@ describe('the zim file creation page', () => {
 
         it('displays the Request ZIM file button', () => {
           cy.get('#request').should('be.visible');
-          cy.get('#request').should('not.have.attr', 'disabled');
+          cy.get('#request').should('have.attr', 'disabled');
         });
 
         describe('when the Request ZIM file button is clicked', () => {
@@ -194,7 +217,7 @@ describe('the zim file creation page', () => {
 
         it('displays the Request ZIM file button', () => {
           cy.get('#request').should('be.visible');
-          cy.get('#request').should('not.have.attr', 'disabled');
+          cy.get('#request').should('have.attr', 'disabled');
         });
       });
     });
diff --git a/wp1-frontend/package.json b/wp1-frontend/package.json
index d63bdc54b..ee4b7fa44 100644
--- a/wp1-frontend/package.json
+++ b/wp1-frontend/package.json
@@ -53,6 +53,7 @@
     "popper.js": "^1.16.0",
     "postcss": "^8.4.31",
     "serialize-javascript": "^3.1.0",
+    "split-by-grapheme": "^1.0.1",
     "vite": "^3.2.11",
     "vue": "^2.7.14",
     "vue-router": "^3.1.6",
diff --git a/wp1-frontend/src/components/ZimFile.vue b/wp1-frontend/src/components/ZimFile.vue
index 6b32c4097..b12d48887 100644
--- a/wp1-frontend/src/components/ZimFile.vue
+++ b/wp1-frontend/src/components/ZimFile.vue
@@ -20,7 +20,7 @@
           <h2>Create ZIM file</h2>
           <p v-if="status === 'NOT_REQUESTED'">
             Use this form to create a ZIM file from your selection, so that you
-            can browse the articles it contains offline. The
+            can browse the articles it contains offline. The &quot;Title&quot;,
             &quot;Description&quot; and &quot;Long Description&quot; fields are
             required, but generic defaults will be used if they're not provided.
           </p>
@@ -66,18 +66,38 @@
             class="needs-validation"
             novalidate
           >
+            <div id="zimtitle-group" ref="zimtitle_form_group" class="form-group">
+              <label for="zimtitle">Title</label>
+              <input
+                id="zimtitle"
+                ref="zimtitle"
+                v-on:blur="validationOnBlur"
+                v-on:input="validateInput('zimTitle', maxTitleLength)"
+                v-model="zimTitle"
+                class="form-control"
+                placeholder="ZIM title"
+                required
+              />
+              <small class="form-text" :class="{'text-muted': graphemeCount(zimTitle) < maxTitleLength, 'text-warning': graphemeCount(zimTitle) === maxTitleLength}">
+                {{ displayGraphemeLimitText(zimTitle, maxTitleLength) }}
+              </small>
+              <div class="invalid-feedback">Please provide a title</div>
+            </div>
             <div id="desc-group" ref="form_group" class="form-group">
               <label for="desc">Description</label>
               <input
                 id="desc"
                 ref="desc"
                 v-on:blur="validationOnBlur"
+                v-on:input="validateInput('description', maxDescriptionLength)"
                 v-model="description"
                 class="form-control"
-                maxlength="80"
                 placeholder="ZIM file created from a WP1 Selection"
                 required
               />
+              <small class="form-text" :class="{'text-muted': graphemeCount(description) < maxDescriptionLength, 'text-warning': graphemeCount(description) === maxDescriptionLength}">
+                {{ displayGraphemeLimitText(description, maxDescriptionLength) }}
+              </small>
               <div class="invalid-feedback">Please provide a description</div>
             </div>
             <div class="form-group">
@@ -86,11 +106,16 @@
                 id="longdesc"
                 ref="longdesc"
                 v-model="longDescription"
+                v-on:input="validateInput('longDescription', maxLongDescriptionLength)"
                 rows="6"
                 class="form-control"
-                maxlength="4000"
+                :class="{'is-invalid': !isLongDescriptionValid}"
                 placeholder="ZIM file created from a WP1 Selection"
               ></textarea>
+              <small class="form-text" :class="{'text-muted': graphemeCount(longDescription) < maxLongDescriptionLength, 'text-warning': graphemeCount(longDescription) === maxLongDescriptionLength}">
+                {{ displayGraphemeLimitText(longDescription, maxLongDescriptionLength) }}
+              </small>
+              <div class="invalid-feedback">Long description must differ from description and not be shorter</div>
             </div>
             <div v-if="!success" class="error-list errors">
               <p>The following errors occurred:</p>
@@ -107,7 +132,7 @@
               v-on:click.prevent="onSubmit"
               class="btn btn-primary"
               type="button"
-              :disabled="processing"
+              :disabled="processing || hasLengthErrors"
             >
               Request ZIM file
             </button>
@@ -151,6 +176,7 @@
 
 <script>
 import PulseLoader from 'vue-spinner/src/PulseLoader.vue';
+import { byGrapheme } from "split-by-grapheme";
 
 import SecondaryNav from './SecondaryNav.vue';
 import LoginRequired from './LoginRequired.vue';
@@ -160,6 +186,7 @@ export default {
   components: { SecondaryNav, LoginRequired, PulseLoader },
   data: function () {
     return {
+      zimTitle: '',
       description: '',
       errors: [],
       errorMessages: [],
@@ -175,6 +202,9 @@ export default {
       serverError: false,
       status: null,
       success: true,
+      maxTitleLength: 30,
+      maxDescriptionLength: 80,
+      maxLongDescriptionLength: 4000,
     };
   },
   created: async function () {
@@ -198,6 +228,10 @@ export default {
       } else {
         this.notFound = false;
         this.builder = await response.json();
+        // Set default ZIM title from builder name, truncated to maxTitleLength if necessary
+        if (this.builder && this.builder.name) {
+          this.zimTitle = this.truncateToLength(this.builder.name, this.maxTitleLength);
+        }
         this.$emit('onBuilderLoaded', this.builder);
       }
     },
@@ -214,8 +248,6 @@ export default {
 
       const data = await response.json();
       this.status = data.status;
-      this.description = data.description;
-      this.longDescription = data.long_description;
       this.isDeleted = data.is_deleted;
       if (this.status === 'FILE_READY') {
         this.stopProgressPolling();
@@ -229,6 +261,7 @@ export default {
       const form = this.$refs.form;
       if (!form.checkValidity()) {
         this.$refs.form_group.classList.add('was-validated');
+        this.$refs.zimtitle_form_group.classList.add('was-validated');
         return;
       }
 
@@ -242,6 +275,7 @@ export default {
         method: 'post',
         credentials: 'include',
         body: JSON.stringify({
+          title: this.zimTitle,
           description: this.description,
           long_description: this.longDescription,
         }),
@@ -257,6 +291,17 @@ export default {
 
       await this.getStatus();
     },
+    validateInput: function(field, maxLength) {
+      // If count exceeds maxLength, truncate the input to prevent further graphemes
+      if (this.graphemeCount(this[field]) > maxLength) {
+        this[field] = this.truncateToLength(this[field], maxLength);
+      }
+    },
+    truncateToLength: function(text, maxLength) {
+      // Split the text into graphemes and truncate to maxLength
+      const graphemes = text.split(byGrapheme);
+      return graphemes.slice(0, maxLength).join('');
+    },
     zimPathFor: function () {
       return `${import.meta.env.VITE_API_URL}/builders/${
         this.builderId
@@ -278,6 +323,13 @@ export default {
         event.target.classList.add('is-invalid');
       }
     },
+    graphemeCount: function (text) {
+      return text.split(byGrapheme).length;
+    },
+    displayGraphemeLimitText: function (text, maxLength) {
+      const count = this.graphemeCount(text);
+      return `${count}/${maxLength} graphemes${count === maxLength ? ' (max reached)' : ''}`;
+    },
   },
   computed: {
     isLoggedIn: function () {
@@ -293,6 +345,16 @@ export default {
         this.status === 'ENDED'
       );
     },
+    isLongDescriptionValid: function() {
+      if (!this.longDescription || !this.description) return true;
+      const longCount = this.graphemeCount(this.longDescription);
+      const descCount = this.graphemeCount(this.description);
+      return longCount >= descCount && this.longDescription !== this.description;
+    },
+    hasLengthErrors: function() {
+      // Prevent empty required fields and invalid long description
+      return !this.zimTitle || !this.description || !this.isLongDescriptionValid;
+    }
   },
   watch: {
     builderId: function () {
diff --git a/wp1-frontend/yarn.lock b/wp1-frontend/yarn.lock
index e3146b8cb..88256a4f1 100644
--- a/wp1-frontend/yarn.lock
+++ b/wp1-frontend/yarn.lock
@@ -2430,6 +2430,11 @@ source-map@^0.6.0, source-map@^0.6.1:
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
+split-by-grapheme@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/split-by-grapheme/-/split-by-grapheme-1.0.1.tgz#4d2b006b986a5fbe2c23357929e1566af7557dc1"
+  integrity sha512-aH+OtxnX+AOyWJvw2hNaQyY9wkzkG6o8URD+rRwFKWkqUduxMA+icOBJv1/aoLuKbb15f0qRHXM+rs94Q50blA==
+
 sprintf-js@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
diff --git a/wp1/exceptions.py b/wp1/exceptions.py
index 31f2ea7ed..1ae8ed2f2 100644
--- a/wp1/exceptions.py
+++ b/wp1/exceptions.py
@@ -18,6 +18,18 @@ class ZimFarmError(Wp1Error):
   pass
 
 
+class InvalidZimTitleError(ZimFarmError):
+  pass
+
+
+class InvalidZimDescriptionError(ZimFarmError):
+  pass
+
+
+class InvalidZimLongDescriptionError(ZimFarmError):
+  pass
+
+
 class ObjectNotFoundError(Wp1Error):
   pass
 
@@ -25,5 +37,6 @@ class ObjectNotFoundError(Wp1Error):
 class UserNotAuthorizedError(Wp1Error):
   pass
 
+
 class Wp1ScoreProcessingError(Wp1Error):
   pass
\ No newline at end of file
diff --git a/wp1/logic/builder.py b/wp1/logic/builder.py
index c518a1f72..3beaa2d73 100644
--- a/wp1/logic/builder.py
+++ b/wp1/logic/builder.py
@@ -393,8 +393,9 @@ def schedule_zim_file(s3,
                       wp10db,
                       builder_id,
                       user_id=None,
+                      title='',
                       description='',
-                      long_description=''):
+                      long_description=None):
   if isinstance(builder_id, str):
     builder_id = builder_id.encode('utf-8')
   builder = get_builder(wp10db, builder_id)
@@ -414,6 +415,7 @@ def schedule_zim_file(s3,
                                       redis,
                                       wp10db,
                                       builder,
+                                      title=title,
                                       description=description,
                                       long_description=long_description)
   selection = latest_selection_for(wp10db, builder_id,
diff --git a/wp1/logic/builder_test.py b/wp1/logic/builder_test.py
index ffb451b8b..5aa276624 100644
--- a/wp1/logic/builder_test.py
+++ b/wp1/logic/builder_test.py
@@ -4,7 +4,7 @@
 import attr
 
 from wp1.base_db_test import BaseWpOneDbTest
-from wp1.exceptions import ObjectNotFoundError, UserNotAuthorizedError, ZimFarmError
+from wp1.exceptions import ObjectNotFoundError, UserNotAuthorizedError, ZimFarmError, InvalidZimTitleError
 from wp1.logic import builder as logic_builder
 from wp1.models.wp10.builder import Builder
 
@@ -844,6 +844,7 @@ def test_schedule_zim_file(self, patched_utcnow, patched_schedule_zim_file):
                                     self.wp10db,
                                     builder_id,
                                     user_id=1234,
+                                    title='test_title',
                                     description='a',
                                     long_description='z')
 
@@ -851,6 +852,7 @@ def test_schedule_zim_file(self, patched_utcnow, patched_schedule_zim_file):
                                                       redis,
                                                       self.wp10db,
                                                       self.builder,
+                                                      title='test_title',
                                                       description='a',
                                                       long_description='z')
     with self.wp10db.cursor() as cursor:
@@ -859,13 +861,32 @@ def test_schedule_zim_file(self, patched_utcnow, patched_schedule_zim_file):
                      ' FROM zim_files'
                      ' WHERE z_selection_id = 1')
       data = cursor.fetchone()
-
     self.assertEqual(b'1234-a', data['z_task_id'])
     self.assertEqual(b'REQUESTED', data['z_status'])
     self.assertEqual(b'20221225000102', data['z_requested_at'])
     self.assertEqual(b'a', data['z_description'])
     self.assertEqual(b'z', data['z_long_description'])
 
+  @patch('wp1.logic.builder.utcnow',
+         return_value=datetime.datetime(2022, 12, 25, 0, 1, 2))
+  @patch('wp1.logic.builder.zimfarm.get_zimfarm_token',
+         return_value="test_token")
+  def test_schedule_zim_file_long_title(self, patched_utcnow, patched_get_zimfarm_token):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    builder_id = self._insert_builder()
+ 
+    with self.assertRaises(InvalidZimTitleError):
+      logic_builder.schedule_zim_file(s3,
+                                      redis,
+                                      self.wp10db,
+                                      builder_id,
+                                      user_id=1234,
+                                      title='A'*31,
+                                      description='a',
+                                      long_description='z')
+
   @patch('wp1.logic.builder.zimfarm.schedule_zim_file')
   def test_schedule_zim_file_404(self, patched_schedule_zim_file):
     redis = MagicMock()
diff --git a/wp1/web/builders.py b/wp1/web/builders.py
index f6b503759..e2b6b7811 100644
--- a/wp1/web/builders.py
+++ b/wp1/web/builders.py
@@ -148,10 +148,20 @@ def create_zim_file_for_builder(builder_id):
   user_id = flask.session['user']['identity']['sub']
 
   data = flask.request.get_json()
+
+  title = data.get('title')
   desc = data.get('description')
+  
+  error_messages = []
+  if not title:
+    error_messages.append('Title is required for ZIM file')
+  
   if not desc:
-    return flask.jsonify(
-        {'error_messages': 'Description is required for ZIM file'}), 400
+    error_messages.append('Description is required for ZIM file')
+  
+  if error_messages:
+    return flask.jsonify({'error_messages': error_messages}), 400
+  
   long_desc = data.get('long_description')
 
   try:
@@ -160,6 +170,7 @@ def create_zim_file_for_builder(builder_id):
                                               wp10db,
                                               builder_id,
                                               user_id=user_id,
+                                              title=title,
                                               description=desc,
                                               long_description=long_desc)
   except ObjectNotFoundError:
diff --git a/wp1/web/builders_test.py b/wp1/web/builders_test.py
index 85f923517..d0d903451 100644
--- a/wp1/web/builders_test.py
+++ b/wp1/web/builders_test.py
@@ -445,7 +445,7 @@ def test_create_zim_file_for_builder(self, patched_schedule_zim_file):
       with client.session_transaction() as sess:
         sess['user'] = self.USER
       rv = client.post('/v1/builders/%s/zim' % builder_id,
-                       json={'description': 'Test description'})
+                       json={'title': 'Test title', 'description': 'Test description'})
       self.assertEqual('204 NO CONTENT', rv.status)
 
     patched_schedule_zim_file.assert_called_once()
@@ -468,7 +468,7 @@ def test_create_zim_file_for_builder_not_found(self,
       with client.session_transaction() as sess:
         sess['user'] = self.USER
       rv = client.post('/v1/builders/1234-not-found/zim',
-                       json={'description': 'Test description'})
+                       json={'title': 'Test title', 'description': 'Test description'})
       self.assertEqual('404 NOT FOUND', rv.status)
 
   @patch('wp1.zimfarm.schedule_zim_file')
@@ -482,7 +482,7 @@ def test_create_zim_file_for_builder_unauthorized(self,
       with client.session_transaction() as sess:
         sess['user'] = self.UNAUTHORIZED_USER
       rv = client.post('/v1/builders/%s/zim' % builder_id,
-                       json={'description': 'Test description'})
+                       json={'title': 'Test title', 'description': 'Test description'})
       self.assertEqual('403 FORBIDDEN', rv.status)
 
   @patch('wp1.zimfarm.schedule_zim_file')
@@ -497,7 +497,7 @@ def test_create_zim_file_for_builder_500(self, patched_schedule_zim_file):
       with client.session_transaction() as sess:
         sess['user'] = self.USER
       rv = client.post('/v1/builders/%s/zim' % builder_id,
-                       json={'description': 'Test description'})
+                       json={'title': 'Test title', 'description': 'Test description'})
       self.assertEqual('500 INTERNAL SERVER ERROR', rv.status)
 
   @patch('wp1.zimfarm.schedule_zim_file')
@@ -512,6 +512,18 @@ def test_create_zim_file_for_builder_400(self, patched_schedule_zim_file):
       rv = client.post('/v1/builders/%s/zim' % builder_id, json={})
       self.assertEqual('400 BAD REQUEST', rv.status)
 
+  @patch('wp1.zimfarm.schedule_zim_file')
+  def test_create_zim_file_for_builder_no_title(self, patched_schedule_zim_file):
+    builder_id = self._insert_builder()
+    self._insert_selections(builder_id)
+
+    self.app = create_app()
+    with self.override_db(self.app), self.app.test_client() as client:
+      with client.session_transaction() as sess:
+        sess['user'] = self.USER
+      rv = client.post('/v1/builders/%s/zim' % builder_id, json={'description': 'Test description'})
+      self.assertEqual('400 BAD REQUEST', rv.status)
+
   @patch('wp1.web.builders.queues.poll_for_zim_file_status')
   def test_update_zimfarm_status(self, patched_poll):
     builder_id = self._insert_builder()
diff --git a/wp1/zimfarm.py b/wp1/zimfarm.py
index 5f00e9159..1eaa8c3f5 100644
--- a/wp1/zimfarm.py
+++ b/wp1/zimfarm.py
@@ -1,8 +1,7 @@
 import logging
-import time
+import regex
 import urllib.parse
-from datetime import datetime, timedelta
-from functools import wraps
+from datetime import datetime
 
 import requests
 
@@ -10,14 +9,18 @@
 import wp1.logic.selection as logic_selection
 from wp1.constants import WP1_USER_AGENT
 from wp1.credentials import CREDENTIALS, ENV
-from wp1.exceptions import ObjectNotFoundError, ZimFarmError
+from wp1.exceptions import ObjectNotFoundError, ZimFarmError, InvalidZimTitleError, InvalidZimDescriptionError, InvalidZimLongDescriptionError
 from wp1.logic import util
 from wp1.time import get_current_datetime
-from wp1.timestamp import utcnow
 
 MWOFFLINER_IMAGE = 'ghcr.io/openzim/mwoffliner:latest'
 REDIS_AUTH_KEY = 'zimfarm.auth'
 
+# ZIM metadata limits as per https://wiki.openzim.org/wiki/Metadata
+ZIM_TITLE_MAX_LENGTH = 30
+ZIM_DESCRIPTION_MAX_LENGTH = 80
+ZIM_LONG_DESCRIPTION_MAX_LENGTH = 4000
+
 logger = logging.getLogger(__name__)
 
 
@@ -121,7 +124,35 @@ def get_webhook_url():
                                                  urllib.parse.quote(token))
 
 
-def _get_params(s3, wp10db, builder, description='', long_description=''):
+def nb_grapheme_for(value: str) -> int:
+    """Number of graphemes (visually perceived characters) in a given string"""
+    return len(regex.findall(r"\X", value))
+
+
+def _validate_zim_metadata(title=None, description=None, long_description=None):
+  """Validate ZIM metadata fields against length limits."""
+  if title is not None and nb_grapheme_for(title) > ZIM_TITLE_MAX_LENGTH:
+    raise InvalidZimTitleError(
+        f"Title exceeds maximum length: {ZIM_TITLE_MAX_LENGTH} graphemes.")
+  
+  if description is not None and nb_grapheme_for(description) > ZIM_DESCRIPTION_MAX_LENGTH:
+    raise InvalidZimDescriptionError(
+        f"Description exceeds maximum length: {ZIM_DESCRIPTION_MAX_LENGTH} graphemes.")
+  
+  if long_description is not None and nb_grapheme_for(long_description) > ZIM_LONG_DESCRIPTION_MAX_LENGTH:
+    raise InvalidZimLongDescriptionError(
+        f"Long description exceeds maximum length: {ZIM_LONG_DESCRIPTION_MAX_LENGTH} graphemes.")
+  
+  if long_description is not None and nb_grapheme_for(long_description) < nb_grapheme_for(description):
+    raise InvalidZimLongDescriptionError(
+        f"Long description must be longer than the description.")
+  
+  if long_description is not None and long_description == description:
+    raise InvalidZimLongDescriptionError(
+        f"Long description must be different from the description.")
+
+
+def _get_params(s3, wp10db, builder, title='', description='', long_description=''):
   if builder is None:
     raise ObjectNotFoundError('Given builder was None: %r' % builder)
 
@@ -150,13 +181,11 @@ def _get_params(s3, wp10db, builder, description='', long_description=''):
           'articleList':
               logic_selection.url_for_selection(selection),
           'customZimTitle':
-              builder.b_name.decode('utf-8'),
+              title,
           'customZimDescription':
-              description
-              if description else 'ZIM file created from a WP1 Selection',
+              description,
           'customZimLongDescription':
-              long_description
-              if long_description else 'ZIM file created from a WP1 Selection',
+              long_description if long_description else f"ZIM file created from a WP1 Selection. {description}",
           'filenamePrefix':
               filename_prefix,
       }
@@ -199,18 +228,22 @@ def schedule_zim_file(s3,
                       redis,
                       wp10db,
                       builder,
+                      title='',
                       description='',
-                      long_description=''):
+                      long_description=None):
   token = get_zimfarm_token(redis)
   if token is None:
     raise ZimFarmError('Error retrieving auth token for request')
 
   if builder is None:
     raise ObjectNotFoundError('Cannot schedule for None builder')
+  
+  _validate_zim_metadata(title, description, long_description)
 
   params = _get_params(s3,
                        wp10db,
                        builder,
+                       title=title,
                        description=description,
                        long_description=long_description)
   base_url = get_zimfarm_url()
@@ -320,7 +353,7 @@ def cancel_zim_by_task_id(redis, task_id):
 
   token = get_zimfarm_token(redis)
   if token is None:
-    raise ZimfarmError('Error retrieving auth token for request')
+    raise ZimFarmError('Error retrieving auth token for request')
   base_url = get_zimfarm_url()
   headers = _get_zimfarm_headers(token)
 
diff --git a/wp1/zimfarm_test.py b/wp1/zimfarm_test.py
index dcccaf46d..bd1f1944d 100644
--- a/wp1/zimfarm_test.py
+++ b/wp1/zimfarm_test.py
@@ -7,8 +7,11 @@
 from wp1 import zimfarm
 from wp1.base_db_test import BaseWpOneDbTest
 from wp1.environment import Environment
-from wp1.exceptions import ObjectNotFoundError, ZimFarmError
+from wp1.exceptions import ObjectNotFoundError, ZimFarmError, InvalidZimTitleError, InvalidZimDescriptionError, InvalidZimLongDescriptionError
+from wp1 import zimfarm
+from wp1.base_db_test import BaseWpOneDbTest
 from wp1.models.wp10.builder import Builder
+from wp1.zimfarm import ZIM_TITLE_MAX_LENGTH, ZIM_DESCRIPTION_MAX_LENGTH, ZIM_LONG_DESCRIPTION_MAX_LENGTH
 
 
 class ZimFarmTest(BaseWpOneDbTest):
@@ -78,6 +81,7 @@ def test_get_params(self):
         s3,
         self.wp10db,
         self.builder,
+        title='My Builder',
         description='This is the short description',
         long_description='This is the long description')
 
@@ -183,7 +187,7 @@ def test_request_zimfarm_token_raises_for_status(self, mock_requests):
     mock_requests.post.return_value = mock_response
 
     with self.assertRaises(ZimFarmError):
-      actual = zimfarm.request_zimfarm_token(redis)
+      zimfarm.request_zimfarm_token(redis)
 
   @patch('wp1.zimfarm.CREDENTIALS', {Environment.TEST: {}})
   def test_request_zimfarm_token_no_creds(self):
@@ -230,7 +234,7 @@ def test_refresh_zimfarm_token_raises_for_status(self, mock_requests):
     mock_requests.post.return_value = mock_response
 
     with self.assertRaises(ZimFarmError):
-      actual = zimfarm.refresh_zimfarm_token(redis, '12345')
+      zimfarm.refresh_zimfarm_token(redis, '12345')
 
   @patch('wp1.zimfarm.CREDENTIALS', {Environment.TEST: {}})
   def test_refresh_zimfarm_token_no_creds(self):
@@ -322,13 +326,9 @@ def test_schedule_zim_file_missing_builder(self, get_token_mock,
 
     redis = MagicMock()
     s3 = MagicMock()
-    get_token_mock.return_value = 'abcdef'
-    mock_response = MagicMock()
-    mock_response.json.return_value = {'requested': ['9876']}
-    mock_requests.post.side_effect = (MagicMock(), mock_response, MagicMock())
 
     with self.assertRaises(ObjectNotFoundError):
-      actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, None)
+      zimfarm.schedule_zim_file(s3, redis, self.wp10db, None)
 
   @patch('wp1.zimfarm.requests')
   @patch('wp1.zimfarm.get_zimfarm_token')
@@ -343,7 +343,7 @@ def test_schedule_zim_file_post_requests(self, get_params_mock,
     mock_response.json.return_value = {'requested': ['9876']}
     mock_requests.post.side_effect = (MagicMock(), mock_response)
 
-    actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
+    actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description='b')
 
     schedule_create_call = call(
         'https://fake.farm/v1/schedules/',
@@ -378,17 +378,14 @@ def test_schedule_zim_file_schedule_create_raises(self, get_params_mock,
                                                     mock_requests):
     redis = MagicMock()
     s3 = MagicMock()
-    get_params_mock.return_value = {'name': 'bar'}
-    get_token_mock.return_value = 'abcdef'
     mock_response = MagicMock()
-    mock_response.json.return_value = {'requested': ['9876']}
     create_schedule_response = MagicMock()
     create_schedule_response.raise_for_status.side_effect = requests.exceptions.HTTPError
     mock_requests.exceptions.HTTPError = requests.exceptions.HTTPError
     mock_requests.post.side_effect = (create_schedule_response, mock_response)
 
     with self.assertRaises(ZimFarmError):
-      actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
+      zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description='b')
 
   @patch('wp1.zimfarm.requests')
   @patch('wp1.zimfarm.get_zimfarm_token')
@@ -397,8 +394,6 @@ def test_schedule_zim_file_task_create_raises(self, get_params_mock,
                                                 get_token_mock, mock_requests):
     redis = MagicMock()
     s3 = MagicMock()
-    get_params_mock.return_value = {'name': 'bar'}
-    get_token_mock.return_value = 'abcdef'
     mock_response = MagicMock()
     mock_response.json.side_effect = requests.exceptions.HTTPError
     mock_requests.exceptions.HTTPError = requests.exceptions.HTTPError
@@ -408,7 +403,94 @@ def test_schedule_zim_file_task_create_raises(self, get_params_mock,
     )
 
     with self.assertRaises(ZimFarmError):
-      actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
+      zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_valid_graphemes(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+    mock_response = MagicMock()
+    mock_response.json.return_value = {'requested': ['9876']}
+    mock_requests.post.side_effect = (MagicMock(), mock_response, MagicMock())
+
+    valid_title = "में" * (ZIM_TITLE_MAX_LENGTH)
+    actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title=valid_title, description='b')
+    self.assertEqual('9876', actual)
+  
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_long_title(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    wrong_title = "a" * (ZIM_TITLE_MAX_LENGTH+1)
+    with self.assertRaises(InvalidZimTitleError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title=wrong_title)
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_long_title(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    wrong_title = "a" * (ZIM_TITLE_MAX_LENGTH+1)
+    with self.assertRaises(InvalidZimTitleError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title=wrong_title)
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_long_description(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    too_long_description = "z" * (ZIM_DESCRIPTION_MAX_LENGTH+1)
+    with self.assertRaises(InvalidZimDescriptionError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description=too_long_description)
+
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_long_long_description(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    too_long_long_description = "z" * (ZIM_LONG_DESCRIPTION_MAX_LENGTH+1)
+    with self.assertRaises(InvalidZimLongDescriptionError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description='zz', long_description=too_long_long_description)
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_short_long_description(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    with self.assertRaises(InvalidZimLongDescriptionError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description='bb', long_description='z')
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_equal_descriptions(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+
+    with self.assertRaises(InvalidZimLongDescriptionError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description='bb', long_description='bb')
+
 
   @patch('wp1.zimfarm.requests')
   @patch('wp1.zimfarm.get_zimfarm_token')
@@ -417,8 +499,6 @@ def test_schedule_zim_file_delete_schedule_even_if_task_create_raises(
       self, get_params_mock, get_token_mock, mock_requests):
     redis = MagicMock()
     s3 = MagicMock()
-    get_params_mock.return_value = {'name': 'bar'}
-    get_token_mock.return_value = 'abcdef'
     mock_response = MagicMock()
     mock_response.json.side_effect = requests.exceptions.HTTPError
     mock_requests.post.side_effect = (
@@ -428,7 +508,7 @@ def test_schedule_zim_file_delete_schedule_even_if_task_create_raises(
     mock_requests.exceptions.HTTPError = requests.exceptions.HTTPError
 
     with self.assertRaises(ZimFarmError):
-      actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
+      zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
 
     mock_requests.delete.assert_called_once()
 
@@ -439,15 +519,13 @@ def test_schedule_zim_file_missing_task_id(self, get_params_mock,
                                              get_token_mock, mock_requests):
     redis = MagicMock()
     s3 = MagicMock()
-    get_params_mock.return_value = {'name': 'bar'}
-    get_token_mock.return_value = 'abcdef'
     mock_response = MagicMock()
     mock_response.json.return_value = {'requested': []}
     mock_requests.exceptions.HTTPError = requests.exceptions.HTTPError
     mock_requests.post.side_effect = (MagicMock(), mock_response, MagicMock())
 
     with self.assertRaises(ZimFarmError):
-      actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
+      zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
 
   @patch('wp1.zimfarm.requests.get')
   def test_is_zim_file_ready(self, patched_get):


Reviews:
user: login: audiodude
body: This looks really good, great work!

I think the handling of the error conditions on the backend needs to be improved however.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-16T01:40:14Z
user: login: nicolapace
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T13:22:38Z
user: login: nicolapace
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T13:39:29Z
user: login: audiodude
body: Looking good, just a few small comments.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-17T18:18:57Z
user: login: audiodude
body: Great work!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T22:48:10Z

Review comments:
diff_hunk: @@ -121,7 +125,23 @@ def get_webhook_url():
                                                  urllib.parse.quote(token))
 
 
-def _get_params(s3, wp10db, builder, description='', long_description=''):
+def validate_zim_metadata(title=None, description=None):
+  """Validate ZIM metadata fields against length limits."""
+  errors = []
+  print(f"Validating ZIM metadata: title='{title}', description='{description}'")
path: wp1/zimfarm.py
user: login: audiodude
body: nit: please remove print
created_at: 2025-04-16T01:28:13Z
author_association: MEMBER
diff_hunk: @@ -314,7 +343,7 @@ def cancel_zim_by_task_id(redis, task_id):
 
   token = get_zimfarm_token(redis)
   if token is None:
-    raise ZimfarmError('Error retrieving auth token for request')
+    raise ZimFarmError('Error retrieving auth token for request')
path: wp1/zimfarm.py
user: login: audiodude
body: Thanks :+1: 
created_at: 2025-04-16T01:33:24Z
author_association: MEMBER
diff_hunk: @@ -121,7 +125,23 @@ def get_webhook_url():
                                                  urllib.parse.quote(token))
 
 
-def _get_params(s3, wp10db, builder, description='', long_description=''):
+def validate_zim_metadata(title=None, description=None):
+  """Validate ZIM metadata fields against length limits."""
+  errors = []
+  print(f"Validating ZIM metadata: title='{title}', description='{description}'")
+  if title and len(title) > ZIM_TITLE_MAX_LENGTH:
+    errors.append(f"Title exceeds maximum length: {ZIM_TITLE_MAX_LENGTH} characters.")
+  
+  if description and len(description) > ZIM_DESCRIPTION_MAX_LENGTH:
+    errors.append(f"Description exceeds maximum length: {ZIM_DESCRIPTION_MAX_LENGTH} characters.")
+    
+  if errors:
+    return False, " ".join(errors)
path: wp1/zimfarm.py
user: login: audiodude
body: I think returning "status tuples" is an antipattern in Python. We should just raise an exception as soon as the invalid parameter is found. It would especially be nice to even have `InvalidTitleError` and `InvalidDescriptionError` as subclasses of `ZimFarmError`.

I understand that in general, it's better to report all of the validation errors you found in one response (and we do this in this codebase in multiple places). However, that is for user facing errors. The UI you implemented already takes care of "pre" validation of these fields, so the only time these errors would occurs is if:

1. The frontend is misconfigured. Could happen in development.
2. The user/client is using the API directly. This is why (as I'm sure you know) it's important to guard against these on the backend. However, if a program is calling this, it's fine for it to get an exception, try again, and get another exception. We don't need to be "user friendly" if there's no user.
created_at: 2025-04-16T01:39:43Z
author_association: MEMBER
diff_hunk: @@ -121,7 +125,23 @@ def get_webhook_url():
                                                  urllib.parse.quote(token))
 
 
-def _get_params(s3, wp10db, builder, description='', long_description=''):
+def validate_zim_metadata(title=None, description=None):
+  """Validate ZIM metadata fields against length limits."""
+  errors = []
+  print(f"Validating ZIM metadata: title='{title}', description='{description}'")
path: wp1/zimfarm.py
user: login: nicolapace
body: Sure :)
created_at: 2025-04-17T13:22:38Z
author_association: COLLABORATOR
diff_hunk: @@ -121,7 +125,23 @@ def get_webhook_url():
                                                  urllib.parse.quote(token))
 
 
-def _get_params(s3, wp10db, builder, description='', long_description=''):
+def validate_zim_metadata(title=None, description=None):
+  """Validate ZIM metadata fields against length limits."""
+  errors = []
+  print(f"Validating ZIM metadata: title='{title}', description='{description}'")
+  if title and len(title) > ZIM_TITLE_MAX_LENGTH:
+    errors.append(f"Title exceeds maximum length: {ZIM_TITLE_MAX_LENGTH} characters.")
+  
+  if description and len(description) > ZIM_DESCRIPTION_MAX_LENGTH:
+    errors.append(f"Description exceeds maximum length: {ZIM_DESCRIPTION_MAX_LENGTH} characters.")
+    
+  if errors:
+    return False, " ".join(errors)
path: wp1/zimfarm.py
user: login: nicolapace
body: Thanks! And yes, totally agree, makes sense. We handle user feedback on the frontend and just return errors on the backend calls. I’ve switched to raising specific exceptions like `InvalidZimTitleError` and `InvalidZimDescriptionError` (as subclasses of `ZimFarmError`) instead of using status tuples. (I've also added `InvalidZimLongDescriptionError` for `long_description` related errors). Thanks again for the feedback!
created_at: 2025-04-17T13:39:29Z
author_association: COLLABORATOR
diff_hunk: @@ -72,13 +72,15 @@
                 id="zimtitle"
                 ref="zimtitle"
                 v-on:blur="validationOnBlur"
+                v-on:input="validateInput('zimTitle', maxTitleLength)"
                 v-model="zimTitle"
                 class="form-control"
-                maxlength="30"
                 placeholder="ZIM title"
                 required
               />
-              <small class="form-text text-muted">{{ zimTitle.length }}/30 characters</small>
+              <small class="form-text" :class="{'text-muted': graphemeCount(zimTitle) < maxTitleLength, 'text-warning': graphemeCount(zimTitle) === maxTitleLength}">
+                {{ graphemeCount(zimTitle) }}/{{ maxTitleLength }} graphemes{{ graphemeCount(zimTitle) === maxTitleLength ? ' (max reached)' : '' }}
path: wp1-frontend/src/components/ZimFile.vue
user: login: audiodude
body: This is a lot of information/processing to be inside of a {{ ... }} block. Please refactor to a helper function. Specifically the last part with the ternary operator. I would  a full on `displayGraphemeLimitText()` helper.
created_at: 2025-04-17T18:08:42Z
author_association: MEMBER
diff_hunk: @@ -87,13 +89,15 @@
                 id="desc"
                 ref="desc"
                 v-on:blur="validationOnBlur"
+                v-on:input="validateInput('description', maxDescriptionLength)"
                 v-model="description"
                 class="form-control"
-                maxlength="80"
                 placeholder="ZIM file created from a WP1 Selection"
                 required
               />
-              <small class="form-text text-muted">{{ description.length }}/80 characters</small>
+              <small class="form-text" :class="{'text-muted': graphemeCount(description) < maxDescriptionLength, 'text-warning': graphemeCount(description) === maxDescriptionLength}">
+                {{ graphemeCount(description) }}/{{ maxDescriptionLength }} graphemes{{ graphemeCount(description) === maxDescriptionLength ? ' (max reached)' : '' }}
path: wp1-frontend/src/components/ZimFile.vue
user: login: audiodude
body: Same feedback here.
created_at: 2025-04-17T18:10:09Z
author_association: MEMBER
diff_hunk: @@ -314,6 +341,16 @@ export default {
         this.status === 'ENDED'
       );
     },
+    isLongDescriptionInvalid: function() {
path: wp1-frontend/src/components/ZimFile.vue
user: login: audiodude
body: nit: prefer booleans that test for positive attributes. So `isLongDescriptionValid()` and then use `!isLongDescriptionValid()` in your test
created_at: 2025-04-17T18:12:06Z
author_association: MEMBER
diff_hunk: @@ -408,11 +408,27 @@ def test_schedule_zim_file_task_create_raises(self, get_params_mock,
     with self.assertRaises(ZimFarmError):
       actual = zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder)
 
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_valid_graphemes(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+    get_params_mock.return_value = {'name': 'bar'}
path: wp1/zimfarm_test.py
user: login: audiodude
body: Optional: I don't think you need to set up all the response values for the mocks here. We just need to send in any mock as a requirement for the function, so that it gets to the point where it can raise the exception.

I recognize that other `test_invalid_foo` methods in this test do that, so it would be optional but nice to clean it up, especially as we are adding a few more methods of this type. Alternately you could simply fix it in the new methods and ignore the existing calls.
created_at: 2025-04-17T18:15:52Z
author_association: MEMBER
diff_hunk: @@ -423,7 +439,24 @@ def test_schedule_zim_file_long_name(self, get_params_mock, get_token_mock,
     mock_requests.post.side_effect = (MagicMock(), mock_response, MagicMock())
 
     wrong_title = "a" * (ZIM_TITLE_MAX_LENGTH+1)
-    with self.assertRaises(InvalidZimMetadataError):
+    with self.assertRaises(InvalidZimTitleError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title=wrong_title)
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_long_title(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+    get_params_mock.return_value = {'name': 'bar'}
path: wp1/zimfarm_test.py
user: login: audiodude
body: Same comment here.
created_at: 2025-04-17T18:16:19Z
author_association: MEMBER
diff_hunk: @@ -439,12 +472,60 @@ def test_schedule_zim_file_too_long_description(self, get_params_mock, get_token
     mock_response.json.return_value = {'requested': ['9876']}
     mock_requests.post.side_effect = (MagicMock(), mock_response, MagicMock())
 
-    wrong_description = "z" * (ZIM_DESCRIPTION_MAX_LENGTH+1)
-    with self.assertRaises(InvalidZimMetadataError):
-        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description=wrong_description)
+    too_long_description = "z" * (ZIM_DESCRIPTION_MAX_LENGTH+1)
+    with self.assertRaises(InvalidZimDescriptionError):
+        zimfarm.schedule_zim_file(s3, redis, self.wp10db, self.builder, title='a', description=too_long_description)
+
+
+  @patch('wp1.zimfarm.requests')
+  @patch('wp1.zimfarm.get_zimfarm_token')
+  @patch('wp1.zimfarm._get_params')
+  def test_schedule_zim_file_too_long_long_description(self, get_params_mock, get_token_mock,
+                             mock_requests):
+    redis = MagicMock()
+    s3 = MagicMock()
+    get_params_mock.return_value = {'name': 'bar'}
path: wp1/zimfarm_test.py
user: login: audiodude
body: Same
created_at: 2025-04-17T18:16:31Z
author_association: MEMBER

PR comments:
user: login: kelson42
created_at: 2025-04-15T13:18:01Z
body: @nicolapace Thank you for your PR! But the unity for counting is not char, but lexem. There is a javascript/typescript implementation of such a counter in openzim/mwoffliner.
author_association: COLLABORATOR
user: login: audiodude
created_at: 2025-04-16T01:24:03Z
body: > @nicolapace Thank you for your PR! But the unity for counting is not char, but lexem. There is a javascript/typescript implementation of such a counter in openzim/mwoffliner.

https://github.com/openzim/wp1/issues/677#issuecomment-2807939549

What is a lexem and how does it compare to a grapheme? I feel like I'm going to have to read a Chomsky book to implement this PR. :upside_down_face: 
author_association: MEMBER
user: login: kelson42
created_at: 2025-04-16T04:48:08Z
body: @audiodude I meant grapheme, sorry. See link given in the issue comment.
author_association: COLLABORATOR
user: login: nicolapace
created_at: 2025-04-17T13:28:41Z
body: @audiodude @kelson42 @benoit74 Thanks for the feedback and the suggestions!

I pushed a new commit that addresses the discussed issues, in particular:
 - I am now using `split-by-grapheme` npm package and `regex` python module to check for num. of graphemes in both frontend and backend as suggested by @kelson42 and @benoit74
 - as suggested by @audiodude I changed the `validate_zim_metadata()` function to Raise exceptions directly and I created new exceptions
 - I also added validation for the `long_description` field as described in https://wiki.openzim.org/wiki/Metadata#Graphemes
 - I disable the submit button if inputs are not valid
 - I changed unit tests to support the new changes and added some new unit tests (for the long description validation and the handling of graphemes)

(I pushed a new commit instead of amending the last one for better clarity. We can squash them later if you prefer.)
author_association: COLLABORATOR
user: login: codecov[bot]
created_at: 2025-04-22T22:50:54Z
body: ## [Codecov](https://app.codecov.io/gh/openzim/wp1/pull/855?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim) Report
Attention: Patch coverage is `97.14286%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 92.63%. Comparing base [(`9a19180`)](https://app.codecov.io/gh/openzim/wp1/commit/9a19180b0fb7e16151ef33af1a30decaf65bd5a8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim) to head [(`4d30fbd`)](https://app.codecov.io/gh/openzim/wp1/commit/4d30fbdc055fe0742ffb30de16e979e27606bdac?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim).
> Report is 5 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/openzim/wp1/pull/855?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim) | Patch % | Lines |
|---|---|---|
| [wp1/zimfarm.py](https://app.codecov.io/gh/openzim/wp1/pull/855?src=pr&el=tree&filepath=wp1%2Fzimfarm.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim#diff-d3AxL3ppbWZhcm0ucHk=) | 95.45% | [1 Missing :warning: ](https://app.codecov.io/gh/openzim/wp1/pull/855?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #855      +/-   ##
==========================================
+ Coverage   92.58%   92.63%   +0.05%     
==========================================
  Files          66       66              
  Lines        3546     3573      +27     
==========================================
+ Hits         3283     3310      +27     
  Misses        263      263              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openzim/wp1/pull/855?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openzim).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE