line1
line2
') + }) + it('should replace media elements with resources', async () => { const resources: EvernoteResource[] = [ { diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts index e377e4a1c6b..4f46633ce3b 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -8,6 +8,10 @@ import Base64 from 'crypto-js/enc-base64' import { Converter, UploadFileFn } from '../Converter' import { ConversionResult } from '../ConversionResult' import { getBlobFromBase64 } from '../Utils' +import { isHighlightSpanElement } from '../HighlightSpanImport' + +const EVERNOTE_TODO = /--en-todo\s*:\s*true/i +const EVERNOTE_CHECKED = /--en-checked\s*:\s*true/i dayjs.extend(customParseFormat) dayjs.extend(utc) @@ -86,14 +90,13 @@ export class EvernoteConverter implements Converter { const noteElement = content.getElementsByTagName('en-note')[0] as HTMLElement - const unorderedLists = Array.from(noteElement.getElementsByTagName('ul')) - if (canUseSuper) { this.convertTopLevelDivsToParagraphs(noteElement) - this.convertListsToSuperFormatIfApplicable(unorderedLists) this.convertLeftPaddingToSuperIndent(noteElement) + this.convertHighlightSpansToMarks(noteElement) } + this.convertEvernoteChecklists(noteElement, canUseSuper) this.removeEmptyAndOrphanListElements(noteElement) this.unwrapTopLevelBreaks(noteElement) @@ -242,27 +245,44 @@ export class EvernoteConverter implements Converter { } as EvernoteResource } - convertTopLevelDivsToParagraphs(noteElement: HTMLElement) { - noteElement.querySelectorAll('div').forEach((div) => { - if (div.parentElement === noteElement) { - changeElementTag(div, 'p') + convertHighlightSpansToMarks(noteElement: HTMLElement) { + for (const span of Array.from(noteElement.querySelectorAll('span'))) { + if (!isHighlightSpanElement(span)) { + continue } - }) - } - convertListsToSuperFormatIfApplicable(unorderedLists: HTMLUListElement[]) { - for (const unorderedList of unorderedLists) { - if (unorderedList.style.getPropertyValue('--en-todo') !== 'true') { - continue + const mark = document.createElement('mark') + const style = span.getAttribute('style') + if (style) { + mark.setAttribute('style', style) } - unorderedList.setAttribute('__lexicallisttype', 'check') + while (span.firstChild) { + mark.appendChild(span.firstChild) + } - const listItems = unorderedList.getElementsByTagName('li') - for (const listItem of Array.from(listItems)) { - listItem.setAttribute('aria-checked', listItem.style.getPropertyValue('--en-checked')) + span.replaceWith(mark) + } + } + + convertEvernoteChecklists(noteElement: HTMLElement, forSuper: boolean) { + for (const ul of Array.from(noteElement.getElementsByTagName('ul'))) { + if (isEvernoteTodoList(ul)) { + convertEvernoteTodoList(ul, forSuper) } } + + for (const group of getEnTodoBlockGroups(noteElement)) { + convertEvernoteEnTodoGroup(group, forSuper) + } + } + + convertTopLevelDivsToParagraphs(noteElement: HTMLElement) { + noteElement.querySelectorAll('div').forEach((div) => { + if (div.parentElement === noteElement) { + changeElementTag(div, 'p') + } + }) } convertLeftPaddingToSuperIndent(noteElement: HTMLElement) { @@ -298,7 +318,7 @@ export class EvernoteConverter implements Converter { const children = Array.from(parent.children) const isEveryChildBR = children.every((child) => child.tagName === 'BR') if (isEveryChildBR) { - parent.replaceWith(children[0]) + parent.replaceChildren() } }) } @@ -408,3 +428,135 @@ function changeElementTag(element: HTMLElement, newTag: string) { } parent.replaceChild(replacement, element) } + +function isEvernoteStyleTrue(element: HTMLElement, property: '--en-todo' | '--en-checked'): boolean { + const style = element.getAttribute('style') ?? '' + const matchesStyleAttribute = property === '--en-todo' ? EVERNOTE_TODO.test(style) : EVERNOTE_CHECKED.test(style) + + return matchesStyleAttribute || element.style.getPropertyValue(property) === 'true' +} + +function isEvernoteTodoList(element: HTMLUListElement): boolean { + return isEvernoteStyleTrue(element, '--en-todo') +} + +function isEvernoteChecked(element: HTMLElement): boolean { + return isEvernoteStyleTrue(element, '--en-checked') +} + +function formatPlaintextCheckbox(checked: boolean, text: string): string { + return `- ${checked ? '[x]' : '[ ]'} ${text}` +} + +function moveEnTodoBlockContent(block: HTMLElement, target: HTMLElement) { + const clone = block.cloneNode(true) as HTMLElement + const enTodo = clone.querySelector('en-todo') + + if (enTodo) { + while (enTodo.firstChild) { + target.appendChild(enTodo.firstChild) + } + enTodo.remove() + } + + while (clone.lastChild?.nodeName === 'BR') { + clone.removeChild(clone.lastChild) + } + + while (clone.firstChild) { + target.appendChild(clone.firstChild) + } +} + +function getEnTodoBlockGroups(noteElement: HTMLElement): HTMLElement[][] { + const groups: HTMLElement[][] = [] + let currentGroup: HTMLElement[] = [] + + for (const child of Array.from(noteElement.children)) { + if (!(child instanceof HTMLElement) || (child.tagName !== 'DIV' && child.tagName !== 'P')) { + if (currentGroup.length > 0) { + groups.push(currentGroup) + currentGroup = [] + } + continue + } + + if (child.querySelector('en-todo')) { + currentGroup.push(child) + } else if (currentGroup.length > 0) { + groups.push(currentGroup) + currentGroup = [] + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup) + } + + return groups +} + +function replaceBlockGroup(group: HTMLElement[], replacement: HTMLElement) { + group[0].replaceWith(replacement) + for (let index = 1; index < group.length; index++) { + group[index].remove() + } +} + +function convertEvernoteTodoList(ul: HTMLUListElement, forSuper: boolean) { + if (forSuper) { + ul.setAttribute('__lexicallisttype', 'check') + for (const listItem of Array.from(ul.getElementsByTagName('li'))) { + listItem.setAttribute('aria-checked', isEvernoteChecked(listItem) ? 'true' : 'false') + } + return + } + + const lines = Array.from(ul.getElementsByTagName('li')).map((listItem) => + formatPlaintextCheckbox(isEvernoteChecked(listItem), listItem.textContent?.trim() ?? ''), + ) + const replacement = document.createElement('div') + replacement.textContent = `${lines.join('\n')}\n` + ul.replaceWith(replacement) +} + +function convertEvernoteEnTodoGroup(group: HTMLElement[], forSuper: boolean) { + if (forSuper) { + const ul = document.createElement('ul') + ul.setAttribute('__lexicallisttype', 'check') + + for (const block of group) { + const enTodo = block.querySelector('en-todo') + if (!enTodo) { + continue + } + + const listItem = document.createElement('li') + const checked = enTodo.getAttribute('checked')?.toLowerCase() === 'true' + listItem.setAttribute('aria-checked', checked ? 'true' : 'false') + moveEnTodoBlockContent(block, listItem) + ul.appendChild(listItem) + } + + replaceBlockGroup(group, ul) + return + } + + const lines: string[] = [] + + for (const block of group) { + const enTodo = block.querySelector('en-todo') + if (!enTodo) { + continue + } + + const textContainer = document.createElement('div') + moveEnTodoBlockContent(block, textContainer) + const checked = enTodo.getAttribute('checked')?.toLowerCase() === 'true' + lines.push(formatPlaintextCheckbox(checked, textContainer.textContent?.trim() ?? '')) + } + + const replacement = document.createElement('div') + replacement.textContent = `${lines.join('\n')}\n` + replaceBlockGroup(group, replacement) +} diff --git a/packages/ui-services/src/Import/EvernoteConverter/testData.ts b/packages/ui-services/src/Import/EvernoteConverter/testData.ts index ffb141189ce..8d1f6fb309b 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/testData.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/testData.ts @@ -36,6 +36,61 @@ export const enex = ` ` +export const highlightEnex = ` + +Line 2
', 'html', { + html: { + addLineBreaks: false, + }, + }) + + expect(superString).toContain('"format":128') + }) + + it('imports Evernote highlight spans as highlight format with background color', () => { + const converter = new HeadlessSuperConverter() + const superString = converter.convertOtherFormatToSuperString( + 'Line 2
', + 'html', + { + html: { + addLineBreaks: false, + }, + }, + ) + + expect(superString).toContain('"format":128') + expect(superString).toContain('background-color') + }) + + it('imports legacy -evernote-highlight spans as highlight format', () => { + const converter = new HeadlessSuperConverter() + const superString = converter.convertOtherFormatToSuperString( + 'Line 2
', + 'html', + { + html: { + addLineBreaks: false, + }, + }, + ) + + expect(superString).toContain('"format":128') + }) + + it('imports Evernote checkbox lists as check list type', async () => { + const crypto = { + generateUUID: () => String(Math.random()), + } as unknown as PureCryptoInterface + const generateUuid = new GenerateUuid(crypto) + const superConverter = new HeadlessSuperConverter() + const evernoteConverter = new EvernoteConverter(generateUuid) + + const readFileAsText = async (file: File) => file as unknown as string + + const { successful } = await evernoteConverter.convert(checkboxEnex as unknown as File, { + insertNote: async ({ text }) => ({ content: { text } }) as never, + insertTag: async () => ({ content: { references: [] } }) as never, + convertHTMLToSuper: (html, options) => + superConverter.convertOtherFormatToSuperString(html, 'html', { html: options }), + convertMarkdownToSuper: jest.fn(), + readFileAsText, + canUseSuper: true, + canUploadFiles: false, + uploadFile: async () => void 0, + linkItems: async () => void 0, + cleanupItems: async () => void 0, + }) + + const superString = (successful?.[0] as unknown as { content: { text: string } }).content.text + expect(superString).toContain('"listType":"check"') + }) + + it('exports imported Evernote highlights as mark elements', async () => { + const crypto = { + generateUUID: () => String(Math.random()), + } as unknown as PureCryptoInterface + const generateUuid = new GenerateUuid(crypto) + const superConverter = new HeadlessSuperConverter() + const evernoteConverter = new EvernoteConverter(generateUuid) + + const readFileAsText = async (file: File) => file as unknown as string + + const { successful } = await evernoteConverter.convert(highlightEnex as unknown as File, { + insertNote: async ({ text }) => ({ content: { text } }) as never, + insertTag: async () => ({ content: { references: [] } }) as never, + convertHTMLToSuper: (html, options) => + superConverter.convertOtherFormatToSuperString(html, 'html', { html: options }), + convertMarkdownToSuper: jest.fn(), + readFileAsText, + canUseSuper: true, + canUploadFiles: false, + uploadFile: async () => void 0, + linkItems: async () => void 0, + cleanupItems: async () => void 0, + }) + + const superString = (successful?.[0] as unknown as { content: { text: string } }).content.text + expect(superString).toContain('"format":128') + expect(superString).toContain('background-color') + }) +}) diff --git a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx index 63651062a39..6b342adbc5b 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx @@ -20,6 +20,7 @@ import { parseFileName } from '@standardnotes/utils' import { $dfs } from '@lexical/utils' import { $isFileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileUtils' import { $generateNodesFromSerializedNodes, $insertGeneratedNodes } from '@lexical/clipboard' +import { highlightHtmlImport } from '../Lexical/Utils/highlightHtmlImport' export class HeadlessSuperConverter implements SuperConverterServiceInterface { private importEditor: LexicalEditor @@ -32,6 +33,9 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { editable: false, onError: (error: Error) => console.error(error), nodes: BlockEditorNodes, + html: { + import: highlightHtmlImport, + }, }) this.exportEditor = createHeadlessEditor({ namespace: 'BlocksEditor',