diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts index 47d39f9e801..1cecbac2b0c 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts @@ -5,7 +5,7 @@ import { ContentType } from '@standardnotes/domain-core' import { SNNote, SNTag } from '@standardnotes/models' import { EvernoteConverter, EvernoteResource } from './EvernoteConverter' -import { createTestResourceElement, enex } from './testData' +import { checkboxEnex, createTestResourceElement, emptyLineEnex, enTodoEnex, enex, highlightEnex } from './testData' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { GenerateUuid } from '@standardnotes/services' import { Converter } from '../Converter' @@ -109,7 +109,43 @@ describe('EvernoteConverter', () => { ) }) + it('should convert Evernote checkbox lists to super format', async () => { + const converter = new EvernoteConverter(generateUuid) + + const { successful } = await converter.convert(checkboxEnex as unknown as File, { + ...dependencies, + canUseSuper: true, + }) + + expect((successful?.[0] as SNNote).content.text).toContain('__lexicallisttype="check"') + expect((successful?.[0] as SNNote).content.text).toContain('aria-checked="true"') + expect((successful?.[0] as SNNote).content.text).toContain('aria-checked="false"') + }) + + it('should convert Evernote checkbox lists to plaintext checkboxes without super', async () => { + const converter = new EvernoteConverter(generateUuid) + + const { successful } = await converter.convert(checkboxEnex as unknown as File, dependencies) + + expect((successful?.[0] as SNNote).content.text).toBe('- [x] Line 1\n- [ ] Line 2\n') + }) + + it('should convert en-todo tags to super checklist format', async () => { + const converter = new EvernoteConverter(generateUuid) + + const { successful } = await converter.convert(enTodoEnex as unknown as File, { + ...dependencies, + canUseSuper: true, + }) + + expect((successful?.[0] as SNNote).content.text).toContain('__lexicallisttype="check"') + expect((successful?.[0] as SNNote).content.text).toContain('Checked item') + expect((successful?.[0] as SNNote).content.text).toContain('Unchecked item') + }) + it('should convert lists to super format if applicable', () => { + const converter = new EvernoteConverter(generateUuid) + const noteElement = document.createElement('en-note') const unorderedList1 = document.createElement('ul') unorderedList1.style.setProperty('--en-todo', 'true') const listItem1 = document.createElement('li') @@ -120,11 +156,10 @@ describe('EvernoteConverter', () => { unorderedList1.appendChild(listItem2) const unorderedList2 = document.createElement('ul') + noteElement.appendChild(unorderedList1) + noteElement.appendChild(unorderedList2) - const array = [unorderedList1, unorderedList2] - - const converter = new EvernoteConverter(generateUuid) - converter.convertListsToSuperFormatIfApplicable(array) + converter.convertEvernoteChecklists(noteElement, true) expect(unorderedList1.getAttribute('__lexicallisttype')).toBe('check') expect(listItem1.getAttribute('aria-checked')).toBe('true') @@ -132,6 +167,49 @@ describe('EvernoteConverter', () => { expect(unorderedList2.getAttribute('__lexicallisttype')).toBeFalsy() }) + it('should preserve single empty lines from Evernote br-only divs', async () => { + const converter = new EvernoteConverter(generateUuid) + + const { successful } = await converter.convert(emptyLineEnex as unknown as File, dependencies) + + expect((successful?.[0] as SNNote).content.text).toBe('line1\n\nline2') + }) + + it('should convert highlight spans to mark elements', () => { + const converter = new EvernoteConverter(generateUuid) + const root = document.createElement('div') + root.innerHTML = + 'Line 2plain' + + converter.convertHighlightSpansToMarks(root) + + expect(root.querySelector('span')?.textContent).toBe('plain') + expect(root.querySelector('mark')?.textContent).toBe('Line 2') + }) + + it('should convert highlight spans to mark elements before Super import', async () => { + const converter = new EvernoteConverter(generateUuid) + + const { successful } = await converter.convert(highlightEnex as unknown as File, { + ...dependencies, + canUseSuper: true, + }) + + expect((successful?.[0] as SNNote).content.text).toContain(']*--en-highlight/) + }) + + it('should convert Evernote br-only divs to empty paragraphs for Super', async () => { + const converter = new EvernoteConverter(generateUuid) + + const { successful } = await converter.convert(emptyLineEnex as unknown as File, { + ...dependencies, + canUseSuper: true, + }) + + expect((successful?.[0] as SNNote).content.text).toBe('

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 = ` + + + + Highlight test + 20210308T051614Z + 20210308T051855Z + + +
Line 1
Line 2
]]> +
+
+
` + +export const checkboxEnex = ` + + + + Checkbox test + 20221122T043758Z + 20221122T043813Z + + +
  • Line 1
  • Line 2
]]> +
+
+
` + +export const enTodoEnex = ` + + + + En-todo test + + +
Checked item
Unchecked item
]]>
+ 20200622T091652Z + 20200622T091707Z +
+
` + +export const emptyLineEnex = ` + + + + Empty line test + 20210308T051614Z + 20210308T051855Z + + +
line1

line2
]]> +
+
+
` + export function createTestResourceElement( shouldHaveMimeType = true, shouldHaveSourceUrl = false, diff --git a/packages/ui-services/src/Import/HighlightSpanImport.spec.ts b/packages/ui-services/src/Import/HighlightSpanImport.spec.ts new file mode 100644 index 00000000000..2b942f6b445 --- /dev/null +++ b/packages/ui-services/src/Import/HighlightSpanImport.spec.ts @@ -0,0 +1,26 @@ +/** + * @jest-environment jsdom + */ + +import { isHighlightSpanElement, isHighlightSpanStyle } from './HighlightSpanImport' + +describe('HighlightSpanImport', () => { + it('detects --en-highlight in style attribute', () => { + expect(isHighlightSpanStyle('--en-highlight:yellow;background-color: #ffef9e;')).toBe(true) + }) + + it('detects -evernote-highlight in style attribute', () => { + expect(isHighlightSpanStyle('background-color: rgb(255, 250, 165);-evernote-highlight:true;')).toBe(true) + }) + + it('does not treat highlight:false as highlighted', () => { + expect(isHighlightSpanStyle('--en-highlight:false;')).toBe(false) + }) + + it('detects highlight spans by element style', () => { + const span = document.createElement('span') + span.setAttribute('style', '--en-highlight:yellow;background-color: #ffef9e;') + + expect(isHighlightSpanElement(span)).toBe(true) + }) +}) diff --git a/packages/ui-services/src/Import/HighlightSpanImport.ts b/packages/ui-services/src/Import/HighlightSpanImport.ts new file mode 100644 index 00000000000..d7f1e86a0d6 --- /dev/null +++ b/packages/ui-services/src/Import/HighlightSpanImport.ts @@ -0,0 +1,24 @@ +const HIGHLIGHT_SPAN_PROPERTY = + /(?:--en-highlight|-en-highlight|--evernote-highlight|-evernote-highlight)\s*:\s*([^;]+)/i + +export function isHighlightSpanStyle(styleAttribute: string | null | undefined): boolean { + if (!styleAttribute) { + return false + } + + const match = styleAttribute.match(HIGHLIGHT_SPAN_PROPERTY) + if (!match) { + return false + } + + return match[1].trim().toLowerCase() !== 'false' +} + +export function isHighlightSpanElement(element: HTMLElement): boolean { + if (isHighlightSpanStyle(element.getAttribute('style'))) { + return true + } + + const enHighlight = element.style.getPropertyValue('--en-highlight') + return enHighlight !== '' && enHighlight.toLowerCase() !== 'false' +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditorComposer.tsx b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditorComposer.tsx index 71fc81a1381..507579b243d 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditorComposer.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditorComposer.tsx @@ -2,6 +2,7 @@ import { FunctionComponent } from 'react' import { LexicalComposer, InitialEditorStateType } from '@lexical/react/LexicalComposer' import BlocksEditorTheme from './Lexical/Theme/Theme' import { BlockEditorNodes } from './Lexical/Nodes/AllNodes' +import { highlightHtmlImport } from './Lexical/Utils/highlightHtmlImport' import { Klass, LexicalNode } from 'lexical' type BlocksEditorComposerProps = { @@ -26,6 +27,9 @@ export const BlocksEditorComposer: FunctionComponent onError: (error: Error) => console.error(error), editorState: typeof initialValue === 'string' && initialValue.length === 0 ? undefined : initialValue, nodes: [...nodes, ...BlockEditorNodes], + html: { + import: highlightHtmlImport, + }, }} > <>{children} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts new file mode 100644 index 00000000000..11810d40aa0 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts @@ -0,0 +1,42 @@ +import { isHighlightSpanElement } from '@standardnotes/ui-services/src/Import/HighlightSpanImport' +import { $isTextNode, DOMConversionMap, LexicalNode } from 'lexical' + +function applyHighlightToTextChild(domNode: HTMLElement) { + const backgroundColor = domNode.style.backgroundColor + + return { + forChild: (lexicalNode: LexicalNode) => { + if (!$isTextNode(lexicalNode)) { + return lexicalNode + } + + if (!lexicalNode.hasFormat('highlight')) { + lexicalNode.toggleFormat('highlight') + } + + if (backgroundColor) { + lexicalNode.setStyle(`background-color: ${backgroundColor}`) + } + + return lexicalNode + }, + node: null, + } +} + +export const highlightHtmlImport: DOMConversionMap = { + mark: () => ({ + conversion: applyHighlightToTextChild, + priority: 1, + }), + span: (domNode) => { + if (!isHighlightSpanElement(domNode as HTMLElement)) { + return null + } + + return { + conversion: applyHighlightToTextChild, + priority: 1, + } + }, +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts new file mode 100644 index 00000000000..0e75fe96fe2 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts @@ -0,0 +1,110 @@ +/** + * @jest-environment jsdom + */ + +import { HeadlessSuperConverter } from './HeadlessSuperConverter' +import { EvernoteConverter } from '@standardnotes/ui-services/src/Import/EvernoteConverter/EvernoteConverter' +import { checkboxEnex, highlightEnex } from '@standardnotes/ui-services/src/Import/EvernoteConverter/testData' +import { GenerateUuid } from '@standardnotes/services' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +describe('HeadlessSuperConverter', () => { + it('imports mark tags 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 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',