Skip to content

Commit 2b5f94c

Browse files
authored
Merge pull request learningequality#5906 from learningequality/hotfixes
Hotfixes into unstable
2 parents f80529f + 89a2796 commit 2b5f94c

19 files changed

Lines changed: 428 additions & 97 deletions

File tree

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ repos:
8181
contentcuration/kolibri_public/migrations/0004_auto_20240612_1847.py|
8282
contentcuration/kolibri_public/migrations/0006_auto_20250417_1516.py|
8383
)$
84+
# Only checks the root Makefile. Extend if nested Makefiles get added.
85+
- repo: local
86+
hooks:
87+
- id: makefile-syntax
88+
name: Makefile syntax check
89+
entry: make -n
90+
language: system
91+
files: ^Makefile$
92+
pass_filenames: false
8493
# Always keep black as the final hook so it reformats any other reformatting.
8594
- repo: https://github.com/python/black
8695
rev: 20.8b1

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ migrate:
3939
# 4) Remove the management command from this `deploy-migrate` recipe
4040
# 5) Repeat!
4141
deploy-migrate:
42-
python contentcuration/manage.py ensure_versioned_databases_exist & python contentcuration/manage.py create_channel_versions & wait
42+
echo "Nothing to do here!"
4343

4444
contentnodegc:
4545
python contentcuration/manage.py garbage_collect

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,14 @@
386386
// Categories that can overflow (in order of overflow priority)
387387
const OVERFLOW_CATEGORIES = [
388388
'insert',
389-
'script',
389+
// Perseus flavoured markdown does not support super and sub script,
390+
// so we disable this for now until we stop using markdown as the primary target
391+
// 'script',
390392
'lists',
391393
'clearFormat',
392-
'align',
394+
// Perseus flavoured markdown does not support alignment,
395+
// so we disable this for now until we stop using markdown as the primary target
396+
// 'align',
393397
'clipboard',
394398
'textFormat',
395399
];

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,17 @@
8181
:is-active="action.isActive"
8282
@click="action.handler"
8383
/>
84+
<!-- Perseus flavoured markdown does not support alignment,
85+
so we disable this for now until we stop using markdown as the primary target
8486
<ToolbarDivider />
8587
<ToolbarButton
8688
:title="alignAction.title"
8789
:icon="alignAction.icon"
8890
:is-active="alignAction.isActive"
8991
@click="alignAction.handler"
90-
/>
92+
/> -->
93+
<!-- Perseus flavoured markdown does not support super and sub script,
94+
so we disable this for now until we stop using markdown as the primary target
9195
<ToolbarDivider />
9296
<ToolbarButton
9397
v-for="action in scriptActions"
@@ -96,7 +100,7 @@
96100
:icon="action.icon"
97101
:is-active="action.isActive"
98102
@click="action.handler"
99-
/>
103+
/> -->
100104
<ToolbarDivider />
101105
<ToolbarButton
102106
v-for="tool in insertTools"
@@ -138,8 +142,7 @@
138142
textFormattingToolbar$,
139143
} = getTipTapEditorStrings();
140144
141-
const { textActions, listActions, scriptActions, insertTools, alignAction } =
142-
useToolbarActions(emit);
145+
const { textActions, listActions, insertTools } = useToolbarActions(emit);
143146
144147
const { canIncreaseFormat, canDecreaseFormat, increaseFormat, decreaseFormat } =
145148
useFormatControls();
@@ -206,9 +209,7 @@
206209
keyboardOffset,
207210
textActions,
208211
listActions,
209-
scriptActions,
210212
insertTools,
211-
alignAction,
212213
toggleToolbar,
213214
canIncreaseFormat,
214215
canDecreaseFormat,

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight
1010
import { CustomLink } from '../extensions/Link';
1111
import { Math } from '../extensions/Math';
1212
import { createCustomMarkdownSerializer } from '../utils/markdownSerializer';
13+
import { transformPastedHTML } from '../utils/pasteTransform';
1314

1415
export function useEditor() {
1516
const editor = ref(null);
@@ -42,6 +43,7 @@ export function useEditor() {
4243
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl focus:outline-none',
4344
dir: 'auto',
4445
},
46+
transformPastedHTML: html => transformPastedHTML(html),
4547
},
4648
onCreate: () => {
4749
isReady.value = true;

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { computed, inject } from 'vue';
22
import { getTipTapEditorStrings } from '../TipTapEditorStrings';
3-
import { sanitizePastedHTML } from '../utils/markdown';
3+
import { transformPastedHTML } from '../utils/pasteTransform';
44

55
export function useToolbarActions(emit) {
66
const editor = inject('editor', null);
@@ -165,7 +165,7 @@ export function useToolbarActions(emit) {
165165
if (item.types.includes('text/html')) {
166166
const htmlBlob = await item.getType('text/html');
167167
const html = await htmlBlob.text();
168-
const cleaned = sanitizePastedHTML(html);
168+
const cleaned = transformPastedHTML(html);
169169

170170
editor.value.chain().focus().insertContent(cleaned).run();
171171
return;

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js

Lines changed: 7 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export const paramsToImageMd = ({ src, alt, width, height, permanentSrc, textAli
3939
return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName}${alignSuffix})`;
4040
};
4141

42+
// --- Underline Translation ---
43+
// Perseus simple-markdown treats __text__ as <u>; marked treats it as <strong>.
44+
// Rewrite before marked runs so the round-trip preserves underline.
45+
export const UNDERLINE_REGEX = /__([\s\S]+?)__(?!_)/g;
46+
4247
// --- Math/Formula Translation ---
4348
export const MATH_REGEX = /\$\$([^$]+)\$\$/g;
4449

@@ -53,67 +58,6 @@ export const paramsToMathMd = ({ latex }) => {
5358
return `$$${latex || ''}$$`;
5459
};
5560

56-
export function sanitizePastedHTML(html) {
57-
if (!html) return '';
58-
// This code ine 55 to 66 is geneted with the help of LLM with the prompt
59-
// "Create a function that sanitizes HTML pasted from Microsoft
60-
// Word by removing Word-specific tags, styles, and classes while preserving other formatting."
61-
let cleaned = html;
62-
cleaned = cleaned.replace(/<!--\[if.*?endif\]-->/gis, '');
63-
cleaned = cleaned.replace(/<\/?(w|m|o|v):[^>]*>/gis, '');
64-
const parser = new DOMParser();
65-
const doc = parser.parseFromString(cleaned, 'text/html');
66-
doc.querySelectorAll('*').forEach(el => {
67-
if (el.hasAttribute('style')) {
68-
const style = el.getAttribute('style') || '';
69-
const filtered = style
70-
.split(';')
71-
.map(s => s.trim())
72-
.filter(s => s && !s.toLowerCase().startsWith('mso-'))
73-
.join('; ');
74-
if (filtered) {
75-
el.setAttribute('style', filtered);
76-
} else {
77-
el.removeAttribute('style');
78-
}
79-
}
80-
if (el.hasAttribute('class')) {
81-
const cls = el
82-
.getAttribute('class')
83-
.split(/\s+/)
84-
.filter(c => c && !/^Mso/i.test(c))
85-
.join(' ');
86-
if (cls) {
87-
el.setAttribute('class', cls);
88-
} else {
89-
el.removeAttribute('class');
90-
}
91-
}
92-
});
93-
const strikeElements = doc.querySelectorAll('s, strike, del');
94-
strikeElements.forEach(el => {
95-
const nestedLists = el.querySelectorAll('ul, ol');
96-
if (nestedLists.length > 0) {
97-
nestedLists.forEach(list => {
98-
el.parentNode.insertBefore(list, el.nextSibling);
99-
});
100-
}
101-
});
102-
const lists = doc.querySelectorAll('ul, ol');
103-
lists.forEach(list => {
104-
const items = list.querySelectorAll(':scope > li');
105-
items.forEach(item => {
106-
const nestedLists = Array.from(item.children).filter(
107-
child => child.tagName === 'UL' || child.tagName === 'OL',
108-
);
109-
nestedLists.forEach(nestedList => {
110-
item.appendChild(nestedList);
111-
});
112-
});
113-
});
114-
return doc.body.innerHTML;
115-
}
116-
11761
/**
11862
* Pre-processes a raw Markdown string to convert custom syntax into HTML tags
11963
* that Tiptap's extensions can understand. This is our custom "loader".
@@ -151,5 +95,7 @@ export function preprocessMarkdown(markdown) {
15195
return `<span data-latex="${params.latex}"></span>`;
15296
});
15397

98+
processedMarkdown = processedMarkdown.replace(UNDERLINE_REGEX, '<u>$1</u>');
99+
154100
return marked(processedMarkdown);
155101
}

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const createCustomMarkdownSerializer = editor => {
2424
trimmedText = `*${trimmedText}*`;
2525
break;
2626
case 'underline':
27-
trimmedText = `<u>${trimmedText}</u>`;
27+
trimmedText = `__${trimmedText}__`;
2828
break;
2929
case 'strike':
3030
trimmedText = `~~${trimmedText}~~`;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
function stripMsoConditionalComments(html) {
2+
return html.replace(/<!--\[if.*?endif\]-->/gis, '');
3+
}
4+
5+
function stripOfficeNamespacedTags(html) {
6+
return html.replace(/<\/?(w|m|o|v):[^>]*>/gis, '');
7+
}
8+
9+
function filterMsoStyleDeclarations(doc) {
10+
doc.querySelectorAll('[style]').forEach(el => {
11+
const filtered = el
12+
.getAttribute('style')
13+
.split(';')
14+
.map(s => s.trim())
15+
.filter(s => s && !s.toLowerCase().startsWith('mso-'))
16+
.join('; ');
17+
if (filtered) {
18+
el.setAttribute('style', filtered);
19+
} else {
20+
el.removeAttribute('style');
21+
}
22+
});
23+
}
24+
25+
function filterMsoClasses(doc) {
26+
doc.querySelectorAll('[class]').forEach(el => {
27+
const cls = el
28+
.getAttribute('class')
29+
.split(/\s+/)
30+
.filter(c => c && !/^Mso/i.test(c))
31+
.join(' ');
32+
if (cls) {
33+
el.setAttribute('class', cls);
34+
} else {
35+
el.removeAttribute('class');
36+
}
37+
});
38+
}
39+
40+
function hoistListsOutOfStrike(doc) {
41+
doc.querySelectorAll('s, strike, del').forEach(el => {
42+
el.querySelectorAll('ul, ol').forEach(list => {
43+
el.parentNode.insertBefore(list, el.nextSibling);
44+
});
45+
});
46+
}
47+
48+
function reparentNestedListsInLi(doc) {
49+
doc.querySelectorAll('ul, ol').forEach(list => {
50+
list.querySelectorAll(':scope > li').forEach(item => {
51+
Array.from(item.children)
52+
.filter(child => child.tagName === 'UL' || child.tagName === 'OL')
53+
.forEach(nestedList => item.appendChild(nestedList));
54+
});
55+
});
56+
}
57+
58+
function stripImages(doc) {
59+
doc.querySelectorAll('img').forEach(el => el.remove());
60+
}
61+
62+
const STRING_TRANSFORMS = [stripMsoConditionalComments, stripOfficeNamespacedTags];
63+
64+
const DOM_TRANSFORMS = [
65+
filterMsoStyleDeclarations,
66+
filterMsoClasses,
67+
hoistListsOutOfStrike,
68+
reparentNestedListsInLi,
69+
stripImages,
70+
];
71+
72+
export function transformPastedHTML(html) {
73+
if (!html) return '';
74+
let cleaned = html;
75+
for (const transform of STRING_TRANSFORMS) {
76+
cleaned = transform(cleaned);
77+
}
78+
const doc = new DOMParser().parseFromString(cleaned, 'text/html');
79+
for (const transform of DOM_TRANSFORMS) {
80+
transform(doc);
81+
}
82+
return doc.body.innerHTML;
83+
}

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdown.spec.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,28 @@ describe('preprocessMarkdown', () => {
7070
});
7171
});
7272

73-
// 4: Standard Markdown Passthrough
73+
// 4: Perseus Underline Handling
74+
// Perseus's simple-markdown treats __text__ as <u>, but `marked` (CommonMark)
75+
// treats it as <strong>. We must rewrite __text__ -> <u>text</u> before marked
76+
// sees it, so the round-trip preserves underline instead of turning it into bold.
77+
describe('Perseus Underline Handling', () => {
78+
it('should rewrite __text__ to <u>text</u> before passing to marked', () => {
79+
preprocessMarkdown('This is __underlined__ text.');
80+
expect(marked).toHaveBeenCalledWith('This is <u>underlined</u> text.');
81+
});
82+
83+
it('should rewrite multiple __ runs on the same line independently', () => {
84+
preprocessMarkdown('__one__ and __two__');
85+
expect(marked).toHaveBeenCalledWith('<u>one</u> and <u>two</u>');
86+
});
87+
88+
it('should rewrite __ spanning multiple words', () => {
89+
preprocessMarkdown('__multi word underline__');
90+
expect(marked).toHaveBeenCalledWith('<u>multi word underline</u>');
91+
});
92+
});
93+
94+
// 5: Standard Markdown Passthrough
7495
describe('Standard Markdown Passthrough', () => {
7596
it('should pass non-custom syntax through to the marked library', () => {
7697
const standardMd = 'Here is **bold** and a [link](url).';

0 commit comments

Comments
 (0)