Skip to content

Commit fbe059a

Browse files
committed
feat: add title field and metadata handling for ZIM file creation
1 parent b873155 commit fbe059a

File tree

9 files changed

+170
-15
lines changed

9 files changed

+170
-15
lines changed

wp1-frontend/cypress/e2e/zimFile.cy.js

+15
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ describe('the zim file creation page', () => {
3131
cy.get('#longdesc').should('be.visible');
3232
});
3333

34+
it('validates the title input on losing focus', () => {
35+
cy.get('#zimtitle').click();
36+
cy.get('#zimtitle').clear();
37+
cy.get('#longdesc').click();
38+
cy.get('#zimtitle-group > .invalid-feedback').should('be.visible');
39+
});
40+
41+
it('validates the title input to have max 30 characters', () => {
42+
const longTitle = 'A'.repeat(31);
43+
cy.get('#zimtitle').click();
44+
cy.get('#zimtitle').clear();
45+
cy.get('#zimtitle').type(longTitle);
46+
cy.get('#zimtitle').should('have.value', longTitle.substring(0, 30));
47+
});
48+
3449
it('validates the description input on losing focus', () => {
3550
cy.get('#desc').click();
3651
cy.get('#longdesc').click();

wp1-frontend/src/components/ZimFile.vue

+24-3
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
<h2>Create ZIM file</h2>
2121
<p v-if="status === 'NOT_REQUESTED'">
2222
Use this form to create a ZIM file from your selection, so that you
23-
can browse the articles it contains offline. The
23+
can browse the articles it contains offline. The &quot;Title&quot;,
2424
&quot;Description&quot; and &quot;Long Description&quot; fields are
2525
required, but generic defaults will be used if they're not provided.
2626
</p>
@@ -66,6 +66,21 @@
6666
class="needs-validation"
6767
novalidate
6868
>
69+
<div id="zimtitle-group" ref="zimtitle_form_group" class="form-group">
70+
<label for="zimtitle">Title</label>
71+
<input
72+
id="zimtitle"
73+
ref="zimtitle"
74+
v-on:blur="validationOnBlur"
75+
v-model="zimTitle"
76+
class="form-control"
77+
maxlength="30"
78+
placeholder="ZIM title"
79+
required
80+
/>
81+
<small class="form-text text-muted">{{ zimTitle.length }}/30 characters</small>
82+
<div class="invalid-feedback">Please provide a title</div>
83+
</div>
6984
<div id="desc-group" ref="form_group" class="form-group">
7085
<label for="desc">Description</label>
7186
<input
@@ -78,6 +93,7 @@
7893
placeholder="ZIM file created from a WP1 Selection"
7994
required
8095
/>
96+
<small class="form-text text-muted">{{ description.length }}/80 characters</small>
8197
<div class="invalid-feedback">Please provide a description</div>
8298
</div>
8399
<div class="form-group">
@@ -160,6 +176,7 @@ export default {
160176
components: { SecondaryNav, LoginRequired, PulseLoader },
161177
data: function () {
162178
return {
179+
zimTitle: '',
163180
description: '',
164181
errors: [],
165182
errorMessages: [],
@@ -198,6 +215,10 @@ export default {
198215
} else {
199216
this.notFound = false;
200217
this.builder = await response.json();
218+
// Set default ZIM title from builder name, truncated to 30 chars if necessary
219+
if (this.builder && this.builder.name) {
220+
this.zimTitle = this.builder.name.substring(0, 30);
221+
}
201222
this.$emit('onBuilderLoaded', this.builder);
202223
}
203224
},
@@ -214,8 +235,6 @@ export default {
214235
215236
const data = await response.json();
216237
this.status = data.status;
217-
this.description = data.description;
218-
this.longDescription = data.long_description;
219238
this.isDeleted = data.is_deleted;
220239
if (this.status === 'FILE_READY') {
221240
this.stopProgressPolling();
@@ -229,6 +248,7 @@ export default {
229248
const form = this.$refs.form;
230249
if (!form.checkValidity()) {
231250
this.$refs.form_group.classList.add('was-validated');
251+
this.$refs.zimtitle_form_group.classList.add('was-validated');
232252
return;
233253
}
234254
@@ -242,6 +262,7 @@ export default {
242262
method: 'post',
243263
credentials: 'include',
244264
body: JSON.stringify({
265+
title: this.zimTitle,
245266
description: this.description,
246267
long_description: this.longDescription,
247268
}),

wp1/exceptions.py

+3
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,8 @@ class ObjectNotFoundError(Wp1Error):
2525
class UserNotAuthorizedError(Wp1Error):
2626
pass
2727

28+
class InvalidZimMetadataError(Wp1Error):
29+
pass
30+
2831
class Wp1ScoreProcessingError(Wp1Error):
2932
pass

wp1/logic/builder.py

+2
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ def schedule_zim_file(s3,
393393
wp10db,
394394
builder_id,
395395
user_id=None,
396+
title='',
396397
description='',
397398
long_description=''):
398399
if isinstance(builder_id, str):
@@ -414,6 +415,7 @@ def schedule_zim_file(s3,
414415
redis,
415416
wp10db,
416417
builder,
418+
title=title,
417419
description=description,
418420
long_description=long_description)
419421
selection = latest_selection_for(wp10db, builder_id,

wp1/logic/builder_test.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import attr
55

66
from wp1.base_db_test import BaseWpOneDbTest
7-
from wp1.exceptions import ObjectNotFoundError, UserNotAuthorizedError, ZimFarmError
7+
from wp1.exceptions import ObjectNotFoundError, UserNotAuthorizedError, ZimFarmError, InvalidZimMetadataError
88
from wp1.logic import builder as logic_builder
99
from wp1.models.wp10.builder import Builder
1010

@@ -844,13 +844,15 @@ def test_schedule_zim_file(self, patched_utcnow, patched_schedule_zim_file):
844844
self.wp10db,
845845
builder_id,
846846
user_id=1234,
847+
title='test_title',
847848
description='a',
848849
long_description='z')
849850

850851
patched_schedule_zim_file.assert_called_once_with(s3,
851852
redis,
852853
self.wp10db,
853854
self.builder,
855+
title='test_title',
854856
description='a',
855857
long_description='z')
856858
with self.wp10db.cursor() as cursor:
@@ -866,6 +868,26 @@ def test_schedule_zim_file(self, patched_utcnow, patched_schedule_zim_file):
866868
self.assertEqual(b'a', data['z_description'])
867869
self.assertEqual(b'z', data['z_long_description'])
868870

871+
@patch('wp1.logic.builder.utcnow',
872+
return_value=datetime.datetime(2022, 12, 25, 0, 1, 2))
873+
@patch('wp1.logic.builder.zimfarm.get_zimfarm_token',
874+
return_value="test_token")
875+
def test_schedule_zim_file_long_name(self, patched_utcnow, patched_get_zimfarm_token):
876+
redis = MagicMock()
877+
s3 = MagicMock()
878+
879+
builder_id = self._insert_builder()
880+
881+
with self.assertRaises(InvalidZimMetadataError):
882+
logic_builder.schedule_zim_file(s3,
883+
redis,
884+
self.wp10db,
885+
builder_id,
886+
user_id=1234,
887+
title='A'*31,
888+
description='a',
889+
long_description='z')
890+
869891
@patch('wp1.logic.builder.zimfarm.schedule_zim_file')
870892
def test_schedule_zim_file_404(self, patched_schedule_zim_file):
871893
redis = MagicMock()

wp1/web/builders.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,20 @@ def create_zim_file_for_builder(builder_id):
148148
user_id = flask.session['user']['identity']['sub']
149149

150150
data = flask.request.get_json()
151+
152+
title = data.get('title')
151153
desc = data.get('description')
154+
155+
error_messages = []
156+
if not title:
157+
error_messages.append('Title is required for ZIM file')
158+
152159
if not desc:
153-
return flask.jsonify(
154-
{'error_messages': 'Description is required for ZIM file'}), 400
160+
error_messages.append('Description is required for ZIM file')
161+
162+
if error_messages:
163+
return flask.jsonify({'error_messages': error_messages}), 400
164+
155165
long_desc = data.get('long_description')
156166

157167
try:
@@ -160,6 +170,7 @@ def create_zim_file_for_builder(builder_id):
160170
wp10db,
161171
builder_id,
162172
user_id=user_id,
173+
title=title,
163174
description=desc,
164175
long_description=long_desc)
165176
except ObjectNotFoundError:

wp1/web/builders_test.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ def test_create_zim_file_for_builder(self, patched_schedule_zim_file):
445445
with client.session_transaction() as sess:
446446
sess['user'] = self.USER
447447
rv = client.post('/v1/builders/%s/zim' % builder_id,
448-
json={'description': 'Test description'})
448+
json={'title': 'Test title', 'description': 'Test description'})
449449
self.assertEqual('204 NO CONTENT', rv.status)
450450

451451
patched_schedule_zim_file.assert_called_once()
@@ -468,7 +468,7 @@ def test_create_zim_file_for_builder_not_found(self,
468468
with client.session_transaction() as sess:
469469
sess['user'] = self.USER
470470
rv = client.post('/v1/builders/1234-not-found/zim',
471-
json={'description': 'Test description'})
471+
json={'title': 'Test title', 'description': 'Test description'})
472472
self.assertEqual('404 NOT FOUND', rv.status)
473473

474474
@patch('wp1.zimfarm.schedule_zim_file')
@@ -482,7 +482,7 @@ def test_create_zim_file_for_builder_unauthorized(self,
482482
with client.session_transaction() as sess:
483483
sess['user'] = self.UNAUTHORIZED_USER
484484
rv = client.post('/v1/builders/%s/zim' % builder_id,
485-
json={'description': 'Test description'})
485+
json={'title': 'Test title', 'description': 'Test description'})
486486
self.assertEqual('403 FORBIDDEN', rv.status)
487487

488488
@patch('wp1.zimfarm.schedule_zim_file')
@@ -497,7 +497,7 @@ def test_create_zim_file_for_builder_500(self, patched_schedule_zim_file):
497497
with client.session_transaction() as sess:
498498
sess['user'] = self.USER
499499
rv = client.post('/v1/builders/%s/zim' % builder_id,
500-
json={'description': 'Test description'})
500+
json={'title': 'Test title', 'description': 'Test description'})
501501
self.assertEqual('500 INTERNAL SERVER ERROR', rv.status)
502502

503503
@patch('wp1.zimfarm.schedule_zim_file')
@@ -512,6 +512,18 @@ def test_create_zim_file_for_builder_400(self, patched_schedule_zim_file):
512512
rv = client.post('/v1/builders/%s/zim' % builder_id, json={})
513513
self.assertEqual('400 BAD REQUEST', rv.status)
514514

515+
@patch('wp1.zimfarm.schedule_zim_file')
516+
def test_create_zim_file_for_builder_no_title(self, patched_schedule_zim_file):
517+
builder_id = self._insert_builder()
518+
self._insert_selections(builder_id)
519+
520+
self.app = create_app()
521+
with self.override_db(self.app), self.app.test_client() as client:
522+
with client.session_transaction() as sess:
523+
sess['user'] = self.USER
524+
rv = client.post('/v1/builders/%s/zim' % builder_id, json={'description': 'Test description'})
525+
self.assertEqual('400 BAD REQUEST', rv.status)
526+
515527
@patch('wp1.web.builders.queues.poll_for_zim_file_status')
516528
def test_update_zimfarm_status(self, patched_poll):
517529
builder_id = self._insert_builder()

wp1/zimfarm.py

+33-4
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
import wp1.logic.selection as logic_selection
1111
from wp1.constants import WP1_USER_AGENT
1212
from wp1.credentials import CREDENTIALS, ENV
13-
from wp1.exceptions import ObjectNotFoundError, ZimFarmError
13+
from wp1.exceptions import ObjectNotFoundError, ZimFarmError, InvalidZimMetadataError
1414
from wp1.logic import util
1515
from wp1.time import get_current_datetime
1616
from wp1.timestamp import utcnow
1717

1818
MWOFFLINER_IMAGE = 'ghcr.io/openzim/mwoffliner:latest'
1919
REDIS_AUTH_KEY = 'zimfarm.auth'
2020

21+
# ZIM metadata limits as per https://wiki.openzim.org/wiki/Metadata
22+
ZIM_TITLE_MAX_LENGTH = 30
23+
ZIM_DESCRIPTION_MAX_LENGTH = 80
24+
2125
logger = logging.getLogger(__name__)
2226

2327

@@ -121,7 +125,23 @@ def get_webhook_url():
121125
urllib.parse.quote(token))
122126

123127

124-
def _get_params(s3, wp10db, builder, description='', long_description=''):
128+
def validate_zim_metadata(title=None, description=None):
129+
"""Validate ZIM metadata fields against length limits."""
130+
errors = []
131+
print(f"Validating ZIM metadata: title='{title}', description='{description}'")
132+
if title and len(title) > ZIM_TITLE_MAX_LENGTH:
133+
errors.append(f"Title exceeds maximum length: {ZIM_TITLE_MAX_LENGTH} characters.")
134+
135+
if description and len(description) > ZIM_DESCRIPTION_MAX_LENGTH:
136+
errors.append(f"Description exceeds maximum length: {ZIM_DESCRIPTION_MAX_LENGTH} characters.")
137+
138+
if errors:
139+
return False, " ".join(errors)
140+
141+
return True, None
142+
143+
144+
def _get_params(s3, wp10db, builder, title='', description='', long_description=''):
125145
if builder is None:
126146
raise ObjectNotFoundError('Given builder was None: %r' % builder)
127147

@@ -150,7 +170,7 @@ def _get_params(s3, wp10db, builder, description='', long_description=''):
150170
'articleList':
151171
logic_selection.url_for_selection(selection),
152172
'customZimTitle':
153-
builder.b_name.decode('utf-8'),
173+
title,
154174
'customZimDescription':
155175
description
156176
if description else 'ZIM file created from a WP1 Selection',
@@ -193,6 +213,7 @@ def schedule_zim_file(s3,
193213
redis,
194214
wp10db,
195215
builder,
216+
title='',
196217
description='',
197218
long_description=''):
198219
token = get_zimfarm_token(redis)
@@ -201,10 +222,18 @@ def schedule_zim_file(s3,
201222

202223
if builder is None:
203224
raise ObjectNotFoundError('Cannot schedule for None builder')
225+
226+
valid_metadata, error_message = validate_zim_metadata(
227+
title=title,
228+
description=description,
229+
)
230+
if not valid_metadata:
231+
raise InvalidZimMetadataError(error_message)
204232

205233
params = _get_params(s3,
206234
wp10db,
207235
builder,
236+
title=title,
208237
description=description,
209238
long_description=long_description)
210239
base_url = get_zimfarm_url()
@@ -314,7 +343,7 @@ def cancel_zim_by_task_id(redis, task_id):
314343

315344
token = get_zimfarm_token(redis)
316345
if token is None:
317-
raise ZimfarmError('Error retrieving auth token for request')
346+
raise ZimFarmError('Error retrieving auth token for request')
318347
base_url = get_zimfarm_url()
319348
headers = _get_zimfarm_headers(token)
320349

0 commit comments

Comments
 (0)