wagtail-localize-intentional-blanks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Translation Editor Enhancements for Intentional Blanks
3
+ *
4
+ * Adds "Do Not Translate" buttons to wagtail-localize's translation editor.
5
+ */
6
+
7
+ (function() {
8
+ 'use strict';
9
+
10
+ // Configuration (can be overridden by Django template)
11
+ const config = {
12
+ apiBaseUrl: window.INTENTIONAL_BLANKS_API_URL || '/intentional-blanks/',
13
+ labelText: "Mark 'Do Not Translate'",
14
+ marker: '__DO_NOT_TRANSLATE__',
15
+ backupSeparator: '|backup|',
16
+ cssClasses: {
17
+ container: 'do-not-translate',
18
+ checkboxContainer: 'do-not-translate-checkbox-container',
19
+ checkbox: 'do-not-translate-checkbox',
20
+ label: 'do-not-translate-label'
21
+ }
22
+ };
23
+
24
+ /**
25
+ * Clean props data before React initializes.
26
+ * Replaces marker strings with actual values so React never sees the marker.
27
+ */
28
+ function cleanPropsData() {
29
+ const editorContainer = document.querySelector('.js-translation-editor');
30
+ if (!editorContainer) {
31
+ return false;
32
+ }
33
+
34
+ if (!editorContainer.dataset.props) {
35
+ return false;
36
+ }
37
+
38
+ try {
39
+ const props = JSON.parse(editorContainer.dataset.props);
40
+
41
+ if (!props.segments) {
42
+ return;
43
+ }
44
+
45
+ if (!props.initialStringTranslations) {
46
+ return;
47
+ }
48
+
49
+ // Build a map: StringSegment ID → source value
50
+ // This matches how wagtail-localize links translations to segments
51
+ const segmentMap = new Map();
52
+ props.segments.forEach(segment => {
53
+ if (segment.type === 'string') {
54
+ // segment.id is the StringSegment ID (primary key)
55
+ segmentMap.set(segment.id, segment.source);
56
+ }
57
+ });
58
+
59
+ let cleanedCount = 0;
60
+ props.initialStringTranslations.forEach((translation, index) => {
61
+ if (!translation.data) return;
62
+
63
+ const isMarker = translation.data === config.marker;
64
+ const hasBackup = translation.data.startsWith(config.marker + config.backupSeparator);
65
+
66
+ if (isMarker || hasBackup) {
67
+
68
+ if (hasBackup) {
69
+ // Extract original value from backup
70
+ const parts = translation.data.split(config.backupSeparator);
71
+ if (parts.length > 1) {
72
+ translation.data = parts[1];
73
+ }
74
+ } else {
75
+ // No backup, use source value from segment
76
+ // Use segment_id (not string_id) to look up the source value
77
+ const sourceValue = segmentMap.get(translation.segment_id);
78
+ translation.data = sourceValue || '';
79
+ }
80
+
81
+ // Clear translation metadata to prevent "Translated manually on..." text
82
+ translation.last_translated_by = null;
83
+ translation.comment = '';
84
+
85
+ cleanedCount++;
86
+ }
87
+ });
88
+
89
+
90
+ // Write cleaned data back
91
+ editorContainer.dataset.props = JSON.stringify(props);
92
+
93
+ return true;
94
+
95
+ } catch (e) {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ // CRITICAL: Clean props as early as possible, before React initializes
101
+ let propsAlreadyCleaned = false;
102
+
103
+ // Try immediately (synchronous)
104
+ if (cleanPropsData()) {
105
+ propsAlreadyCleaned = true;
106
+ } else {
107
+ // Container doesn't exist yet - watch for it to appear
108
+
109
+ const observer = new MutationObserver(() => {
110
+ const editorContainer = document.querySelector('.js-translation-editor');
111
+ if (editorContainer && editorContainer.dataset.props && !propsAlreadyCleaned) {
112
+ if (cleanPropsData()) {
113
+ propsAlreadyCleaned = true;
114
+ observer.disconnect();
115
+ }
116
+ }
117
+ });
118
+
119
+ // Watch for container to be added to DOM
120
+ if (document.documentElement) {
121
+ observer.observe(document.documentElement, {
122
+ childList: true,
123
+ subtree: true
124
+ });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Update props JSON after marking/unmarking a segment.
130
+ * Keeps React's data in sync with the backend.
131
+ */
132
+ function updatePropsAfterToggle(segmentId, doNotTranslate, sourceValue, translatedValue) {
133
+
134
+ const editorContainer = document.querySelector('.js-translation-editor');
135
+ if (!editorContainer || !editorContainer.dataset.props) {
136
+ return;
137
+ }
138
+
139
+ try {
140
+ const props = JSON.parse(editorContainer.dataset.props);
141
+
142
+ // Find the translation in initialStringTranslations by segment_id
143
+ // segmentId here is the StringSegment ID
144
+ const translation = props.initialStringTranslations?.find(t => t.segment_id === segmentId);
145
+
146
+ if (translation) {
147
+ if (doNotTranslate) {
148
+ // Marking - use source value and clear metadata
149
+ translation.data = sourceValue;
150
+ translation.last_translated_by = null;
151
+ translation.comment = '';
152
+ } else {
153
+ // Unmarking - use translated value or empty
154
+ translation.data = translatedValue || '';
155
+ // Metadata will be restored when user edits/saves the translation
156
+ }
157
+
158
+ editorContainer.dataset.props = JSON.stringify(props);
159
+ } else {
160
+ }
161
+ } catch (e) {
162
+ console.error('[updatePropsAfterToggle] Error updating props:', e);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Initialize everything in the correct order
168
+ */
169
+ function init() {
170
+ function initAll() {
171
+ // Try to clean props if not already done
172
+ if (!propsAlreadyCleaned) {
173
+ if (cleanPropsData()) {
174
+ propsAlreadyCleaned = true;
175
+ }
176
+ } else {
177
+ }
178
+ // Initialize our UI enhancements
179
+ initializeButtons();
180
+ }
181
+
182
+ if (document.readyState === 'loading') {
183
+ document.addEventListener('DOMContentLoaded', initAll);
184
+ } else {
185
+ // DOM already ready, run immediately
186
+ initAll();
187
+ }
188
+ }
189
+
190
+ // Track if buttons have been initialized to prevent duplicates
191
+ let buttonsInitialized = false;
192
+
193
+ /**
194
+ * Add "Do Not Translate" checkboxes to all translation segments
195
+ */
196
+ function initializeButtons() {
197
+ if (buttonsInitialized) {
198
+ return;
199
+ }
200
+
201
+ const editorContainer = document.querySelector('.js-translation-editor');
202
+ if (!editorContainer) {
203
+ return;
204
+ }
205
+
206
+ let segmentsData;
207
+ try {
208
+ const propsData = JSON.parse(editorContainer.dataset.props);
209
+ segmentsData = (propsData.segments || []).filter(seg => seg.type === 'string');
210
+ } catch (e) {
211
+ console.error('Failed to parse translation editor data:', e);
212
+ return;
213
+ }
214
+
215
+ if (segmentsData.length === 0) {
216
+ return;
217
+ }
218
+
219
+ // Wait for React to render segments
220
+ const observer = new MutationObserver(() => {
221
+ const segmentElements = document.querySelectorAll('li.incomplete, li.complete');
222
+
223
+ if (segmentElements.length > 0 && segmentElements.length === segmentsData.length && !buttonsInitialized) {
224
+ observer.disconnect();
225
+ buttonsInitialized = true;
226
+ attachButtonsToSegments(segmentElements, segmentsData);
227
+ }
228
+ });
229
+
230
+ observer.observe(document.body, { childList: true, subtree: true });
231
+
232
+ // Fallback if React has already rendered
233
+ setTimeout(() => {
234
+ if (!buttonsInitialized) {
235
+ const segmentElements = document.querySelectorAll('li.incomplete, li.complete');
236
+ if (segmentElements.length === segmentsData.length) {
237
+ observer.disconnect();
238
+ buttonsInitialized = true;
239
+ attachButtonsToSegments(segmentElements, segmentsData);
240
+ } else {
241
+ }
242
+ }
243
+ }, 1000);
244
+ }
245
+
246
+ /**
247
+ * Attach checkboxes to rendered segment elements
248
+ */
249
+ function attachButtonsToSegments(segmentElements, segmentsData) {
250
+ const translationId = getTranslationId();
251
+ if (!translationId) {
252
+ return;
253
+ }
254
+
255
+ segmentElements.forEach((container, index) => {
256
+ if (index >= segmentsData.length) return;
257
+
258
+ const segmentData = segmentsData[index];
259
+
260
+ // Use segment.id (StringSegment ID) - matches wagtail-localize's structure
261
+ // This is what the backend expects and what links to translations
262
+ const segmentId = segmentData.id;
263
+
264
+ if (!segmentId) return;
265
+
266
+ container.dataset.segmentId = segmentId;
267
+
268
+ const buttonContainer = container.querySelector('ul');
269
+ if (!buttonContainer) {
270
+ return;
271
+ }
272
+
273
+ const checkboxWrapper = createDoNotTranslateCheckbox(segmentId, translationId);
274
+ const listItem = document.createElement('li');
275
+ listItem.appendChild(checkboxWrapper);
276
+
277
+ // Always insert as the last child to maintain consistent position
278
+ // Mark it so we can identify it later
279
+ listItem.dataset.intentionalBlanksCheckbox = 'true';
280
+ buttonContainer.appendChild(listItem);
281
+
282
+ setupEditModeObserver(container, listItem);
283
+ });
284
+
285
+ checkAllSegmentsStatus(translationId);
286
+ }
287
+
288
+ /**
289
+ * Create a checkbox with label for "Do Not Translate"
290
+ */
291
+ function createDoNotTranslateCheckbox(segmentId, translationId) {
292
+ // Create container
293
+ const container = document.createElement('div');
294
+ container.className = config.cssClasses.checkboxContainer;
295
+
296
+ // Create checkbox
297
+ const checkbox = document.createElement('input');
298
+ checkbox.type = 'checkbox';
299
+ checkbox.id = `do-not-translate-${segmentId}`;
300
+ checkbox.className = config.cssClasses.checkbox;
301
+ checkbox.dataset.translationId = translationId;
302
+
303
+ // Create label
304
+ const label = document.createElement('label');
305
+ label.htmlFor = checkbox.id;
306
+ label.className = config.cssClasses.label;
307
+ label.textContent = config.labelText;
308
+
309
+ // Add change event listener
310
+ checkbox.addEventListener('change', function(e) {
311
+ toggleDoNotTranslate(checkbox, segmentId, translationId);
312
+ });
313
+
314
+ // Append checkbox and label to container
315
+ container.appendChild(checkbox);
316
+ container.appendChild(label);
317
+
318
+ return container;
319
+ }
320
+
321
+ /**
322
+ * Toggle "do not translate" status for a segment
323
+ */
324
+ function toggleDoNotTranslate(checkbox, segmentId, translationId) {
325
+ const container = checkbox.closest('[data-segment-id]');
326
+
327
+ if (!container) {
328
+ console.error('Container not found for segment', segmentId);
329
+ return;
330
+ }
331
+
332
+ const doNotTranslate = checkbox.checked;
333
+
334
+ // Disable checkbox during request
335
+ checkbox.disabled = true;
336
+
337
+ const url = `${config.apiBaseUrl}translations/${translationId}/segment/${segmentId}/do-not-translate/`;
338
+ const formData = new FormData();
339
+ formData.append('do_not_translate', doNotTranslate);
340
+ formData.append('csrfmiddlewaretoken', getCsrfToken());
341
+
342
+ fetch(url, {
343
+ method: 'POST',
344
+ body: formData,
345
+ })
346
+ .then(response => response.json())
347
+ .then(data => {
348
+ if (data.success) {
349
+ // Update React's props data first
350
+ updatePropsAfterToggle(segmentId, data.do_not_translate, data.source_value, data.translated_value);
351
+
352
+ // Then update the UI
353
+ updateSegmentUI(container, checkbox, data.do_not_translate, data.source_value, data.translated_value);
354
+ showNotification('success', data.message);
355
+ } else {
356
+ // Revert checkbox state on error
357
+ checkbox.checked = !doNotTranslate;
358
+ showNotification('error', data.error || 'Failed to update segment');
359
+ }
360
+ })
361
+ .catch(error => {
362
+ console.error('Error toggling do not translate:', error);
363
+ // Revert checkbox state on error
364
+ checkbox.checked = !doNotTranslate;
365
+ showNotification('error', 'Network error occurred');
366
+ })
367
+ .finally(() => {
368
+ checkbox.disabled = false;
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Check the status of all segments with a single API call
374
+ */
375
+ function checkAllSegmentsStatus(translationId) {
376
+ const url = `${config.apiBaseUrl}translations/${translationId}/status/?t=${Date.now()}`;
377
+
378
+ fetch(url, { cache: 'no-store' }) // Do not cache the API call
379
+ .then(response => response.json())
380
+ .then(data => {
381
+ if (data.success && data.segments) {
382
+ Object.keys(data.segments).forEach(segmentId => {
383
+ const segmentData = data.segments[segmentId];
384
+ const container = document.querySelector(`[data-segment-id="${segmentId}"]`);
385
+
386
+ if (container && segmentData.do_not_translate) {
387
+ const checkbox = container.querySelector(`.${config.cssClasses.checkbox}`);
388
+ if (checkbox) {
389
+ updateSegmentUI(container, checkbox, true, segmentData.source_text);
390
+ }
391
+ }
392
+ });
393
+ }
394
+ })
395
+ .catch(error => console.error('Error checking segments status:', error));
396
+ }
397
+
398
+ /**
399
+ * Set up observer to show/hide checkbox based on edit mode
400
+ */
401
+ function setupEditModeObserver(container, checkboxContainer) {
402
+ const observer = new MutationObserver(() => {
403
+ const textarea = container.querySelector('textarea');
404
+ const isEditMode = !!textarea;
405
+
406
+ // Hide checkbox in edit mode, show when not editing
407
+ if (checkboxContainer) {
408
+ checkboxContainer.style.display = isEditMode ? 'none' : '';
409
+ }
410
+
411
+ // Ensure checkbox stays at the end of the button list
412
+ // React may re-render buttons and insert them before our checkbox
413
+ const buttonContainer = container.querySelector('ul');
414
+ if (buttonContainer && checkboxContainer && checkboxContainer.parentNode === buttonContainer) {
415
+ // Check if checkbox is already the last child
416
+ if (buttonContainer.lastElementChild !== checkboxContainer) {
417
+ // Move it to the end
418
+ buttonContainer.appendChild(checkboxContainer);
419
+ }
420
+ }
421
+
422
+ // If textarea appears and we have a restored value, apply it
423
+ if (textarea && container.dataset.restoredValue) {
424
+ textarea.value = container.dataset.restoredValue;
425
+ delete container.dataset.restoredValue;
426
+ }
427
+ });
428
+
429
+ // Observe for edit mode changes
430
+ observer.observe(container, {
431
+ childList: true,
432
+ subtree: true
433
+ });
434
+
435
+ // Store observer
436
+ container._editModeObserver = observer;
437
+
438
+ // Set initial state
439
+ const textarea = container.querySelector('textarea');
440
+ if (checkboxContainer && textarea) {
441
+ checkboxContainer.style.display = 'none';
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Hide/show the Edit button based on marked state
447
+ */
448
+ function toggleEditButton(container, hide) {
449
+ // Find the "Translate" or "Edit" button - it's usually a button with specific text
450
+ const buttons = container.querySelectorAll('button');
451
+ buttons.forEach(button => {
452
+ const buttonText = button.textContent.trim();
453
+ if (buttonText === 'Translate' || buttonText === 'Edit') {
454
+ button.style.display = hide ? 'none' : '';
455
+ }
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Add "Using source value" badge after field name
461
+ */
462
+ function addDoNotTranslateBadge(container) {
463
+ // Check if badge already exists
464
+ if (container.querySelector('.do-not-translate-badge')) return;
465
+
466
+ // Create badge element
467
+ const badge = document.createElement('span');
468
+ badge.className = 'do-not-translate-badge';
469
+ badge.textContent = 'Using source value';
470
+
471
+ // Try to find the field name (h4 element) and append badge to it
472
+ const fieldName = container.querySelector('h4');
473
+ if (fieldName) {
474
+ fieldName.appendChild(badge);
475
+ return;
476
+ }
477
+
478
+ // Fallback: if no h4, insert before the p element
479
+ const paragraph = container.querySelector('p');
480
+ if (paragraph) {
481
+ paragraph.parentNode.insertBefore(badge, paragraph);
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Remove "Using source value" badge
487
+ */
488
+ function removeDoNotTranslateBadge(container) {
489
+ const badge = container.querySelector('.do-not-translate-badge');
490
+ if (badge) {
491
+ badge.remove();
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Update the UI to reflect segment status
497
+ */
498
+ function updateSegmentUI(container, checkbox, doNotTranslate, sourceValue, translatedValue) {
499
+ if (!container) return;
500
+
501
+ // Find the editable field (textarea) or the display element (p tag in non-editable state)
502
+ const textarea = container.querySelector('textarea');
503
+ const displayP = container.querySelector('div.sc-iCoGMd p');
504
+
505
+ // Update checkbox state
506
+ checkbox.checked = doNotTranslate;
507
+
508
+ if (doNotTranslate) {
509
+ // Mark as do not translate - show source value
510
+ container.classList.add(config.cssClasses.container);
511
+
512
+ // Update wagtail-localize status classes
513
+ container.classList.remove('incomplete');
514
+ container.classList.add('complete');
515
+
516
+ // Update textarea if in editable state
517
+ if (textarea) {
518
+ textarea.value = sourceValue;
519
+ textarea.readOnly = true;
520
+ textarea.style.opacity = '0.7';
521
+ textarea.style.backgroundColor = '#f5f5f5';
522
+ }
523
+
524
+ // Update display paragraph if in non-editable state
525
+ if (displayP) {
526
+ displayP.textContent = sourceValue;
527
+ }
528
+
529
+ // Add badge after field name
530
+ addDoNotTranslateBadge(container);
531
+
532
+ // Hide the Edit button when marked as "Do Not Translate"
533
+ toggleEditButton(container, true);
534
+ } else {
535
+ // Unmark - restore translated value or clear to allow editing
536
+ container.classList.remove(config.cssClasses.container);
537
+
538
+ // Update wagtail-localize status classes based on whether there's a translation
539
+ if (translatedValue !== null && translatedValue !== undefined) {
540
+ container.classList.remove('incomplete');
541
+ container.classList.add('complete');
542
+ } else {
543
+ container.classList.remove('complete');
544
+ container.classList.add('incomplete');
545
+ }
546
+
547
+ // Restore textarea if in editable state
548
+ if (textarea) {
549
+ if (translatedValue !== null && translatedValue !== undefined) {
550
+ textarea.value = translatedValue;
551
+ } else {
552
+ // No translation - clear the textarea
553
+ textarea.value = '';
554
+ }
555
+ textarea.readOnly = false;
556
+ textarea.style.opacity = '1';
557
+ textarea.style.backgroundColor = '';
558
+ }
559
+
560
+ // Restore display paragraph if in non-editable state
561
+ if (displayP) {
562
+ if (translatedValue !== null && translatedValue !== undefined) {
563
+ displayP.textContent = translatedValue;
564
+ // Store value for when user clicks Edit (React will create textarea with stale data)
565
+ container.dataset.restoredValue = translatedValue;
566
+ } else {
567
+ // No translation - clear the display and store empty string
568
+ displayP.textContent = '';
569
+ // Store empty string to override React's stale data
570
+ container.dataset.restoredValue = '';
571
+ }
572
+ }
573
+
574
+ // Remove badge
575
+ removeDoNotTranslateBadge(container);
576
+
577
+ // Show the Edit button when unmarked
578
+ toggleEditButton(container, false);
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Get translation ID from page context
584
+ */
585
+ function getTranslationId() {
586
+ // Try multiple methods to find translation ID
587
+
588
+ // 1. From URL
589
+ const match = window.location.pathname.match(/\/translations\/(\d+)\//);
590
+ if (match) return match[1];
591
+
592
+ // 2. From data attribute
593
+ const editor = document.querySelector('[data-translation-id]');
594
+ if (editor) return editor.dataset.translationId;
595
+
596
+ // 3. From global variable (set by Django template)
597
+ if (window.TRANSLATION_ID) return window.TRANSLATION_ID;
598
+
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * Get CSRF token from page
604
+ */
605
+ function getCsrfToken() {
606
+ const input = document.querySelector('[name=csrfmiddlewaretoken]');
607
+ if (input) return input.value;
608
+
609
+ const cookie = document.cookie.split('; ')
610
+ .find(row => row.startsWith('csrftoken='));
611
+ if (cookie) return cookie.split('=')[1];
612
+
613
+ return '';
614
+ }
615
+
616
+ /**
617
+ * Show a notification message
618
+ */
619
+ function showNotification(type, message) {
620
+ // Try to use Wagtail's notification system
621
+ if (window.wagtail && window.wagtail.messages) {
622
+ window.wagtail.messages.add({
623
+ type: type,
624
+ text: message
625
+ });
626
+ } else if (window.messages && window.messages.add) {
627
+ // Older Wagtail versions
628
+ window.messages.add(message, type);
629
+ } else {
630
+ // Fallback to console
631
+ console.log(`[${type.toUpperCase()}] ${message}`);
632
+ }
633
+ }
634
+
635
+ // Initialize on load
636
+ init();
637
+
638
+ // Export for potential external use
639
+ window.IntentionalBlanks = {
640
+ init: init,
641
+ config: config
642
+ };
643
+
644
+ })();
@@ -0,0 +1,17 @@
1
+ {% extends "wagtail_localize/admin/edit_translation.html" %}
2
+ {% load static %}
3
+
4
+ {% block extra_css %}
5
+ {{ block.super }}
6
+ <link rel="stylesheet" href="{% static 'wagtail_localize_intentional_blanks/css/translation-editor.css' %}">
7
+ {% endblock %}
8
+
9
+ {% block extra_js %}
10
+ {{ block.super }}
11
+ <script>
12
+ // Pass configuration from Django to JavaScript
13
+ window.INTENTIONAL_BLANKS_API_URL = '/intentional-blanks/';
14
+ window.TRANSLATION_ID = {{ translation.id|default:"null" }};
15
+ </script>
16
+ <script src="{% static 'wagtail_localize_intentional_blanks/js/translation-editor.js' %}"></script>
17
+ {% endblock %}
@@ -0,0 +1 @@
1
+ # Template tags module for wagtail-localize-intentional-blanks