From 4601c685c27ad56f9ff9fc67309f25ed6a7e4b9c Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Wed, 3 Jun 2026 15:14:43 -0300 Subject: [PATCH 1/3] fix: Fixes empty lines doubled when importing Evernote notes --- .../EvernoteConverter.spec.ts | 21 ++++++++++++++++++- .../EvernoteConverter/EvernoteConverter.ts | 2 +- .../src/Import/EvernoteConverter/testData.ts | 14 +++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts index 47d39f9e801..2715879b83a 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 { createTestResourceElement, emptyLineEnex, enex } from './testData' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { GenerateUuid } from '@standardnotes/services' import { Converter } from '../Converter' @@ -132,6 +132,25 @@ 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 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..be09e94f8e7 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -298,7 +298,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() } }) } diff --git a/packages/ui-services/src/Import/EvernoteConverter/testData.ts b/packages/ui-services/src/Import/EvernoteConverter/testData.ts index ffb141189ce..0151ff118a3 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/testData.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/testData.ts @@ -36,6 +36,20 @@ export const enex = ` ` +export const emptyLineEnex = ` + + + + Empty line test + 20210308T051614Z + 20210308T051855Z + + +
line1

line2
]]> +
+
+
` + export function createTestResourceElement( shouldHaveMimeType = true, shouldHaveSourceUrl = false, From b174d08f74e0035f9de1aad3625ff96e7f3955c2 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Fri, 5 Jun 2026 00:07:46 -0300 Subject: [PATCH 2/3] fix: Fixes highlight text style lost when importing Evernote notes --- .../EvernoteConverter.spec.ts | 14 +++- .../EvernoteConverter/EvernoteConverter.ts | 2 + .../src/Import/EvernoteConverter/testData.ts | 14 ++++ .../src/Import/EvernoteHighlight.spec.ts | 35 ++++++++ .../src/Import/EvernoteHighlight.ts | 46 +++++++++++ .../SuperEditor/BlocksEditorComposer.tsx | 4 + .../Lexical/Utils/highlightHtmlImport.ts | 42 ++++++++++ .../Tools/HeadlessSuperConverter.spec.ts | 82 +++++++++++++++++++ .../Tools/HeadlessSuperConverter.tsx | 4 + 9 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/ui-services/src/Import/EvernoteHighlight.spec.ts create mode 100644 packages/ui-services/src/Import/EvernoteHighlight.ts create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts create mode 100644 packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts index 2715879b83a..d6b09413919 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, emptyLineEnex, enex } from './testData' +import { createTestResourceElement, emptyLineEnex, enex, highlightEnex } from './testData' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { GenerateUuid } from '@standardnotes/services' import { Converter } from '../Converter' @@ -140,6 +140,18 @@ describe('EvernoteConverter', () => { expect((successful?.[0] as SNNote).content.text).toBe('line1\n\nline2') }) + 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) diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts index be09e94f8e7..aa8ec11e75e 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -8,6 +8,7 @@ import Base64 from 'crypto-js/enc-base64' import { Converter, UploadFileFn } from '../Converter' import { ConversionResult } from '../ConversionResult' import { getBlobFromBase64 } from '../Utils' +import { convertEvernoteHighlightSpansToMarks } from '../EvernoteHighlight' dayjs.extend(customParseFormat) dayjs.extend(utc) @@ -92,6 +93,7 @@ export class EvernoteConverter implements Converter { this.convertTopLevelDivsToParagraphs(noteElement) this.convertListsToSuperFormatIfApplicable(unorderedLists) this.convertLeftPaddingToSuperIndent(noteElement) + convertEvernoteHighlightSpansToMarks(noteElement) } this.removeEmptyAndOrphanListElements(noteElement) diff --git a/packages/ui-services/src/Import/EvernoteConverter/testData.ts b/packages/ui-services/src/Import/EvernoteConverter/testData.ts index 0151ff118a3..67d19f4747a 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/testData.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/testData.ts @@ -36,6 +36,20 @@ export const enex = ` ` +export const highlightEnex = ` + + + + Highlight test + 20210308T051614Z + 20210308T051855Z + + +
Line 1
Line 2
]]> +
+
+
` + export const emptyLineEnex = ` diff --git a/packages/ui-services/src/Import/EvernoteHighlight.spec.ts b/packages/ui-services/src/Import/EvernoteHighlight.spec.ts new file mode 100644 index 00000000000..e913b4eaf8e --- /dev/null +++ b/packages/ui-services/src/Import/EvernoteHighlight.spec.ts @@ -0,0 +1,35 @@ +/** + * @jest-environment jsdom + */ + +import { + convertEvernoteHighlightSpansToMarks, + isEvernoteHighlightElement, + isEvernoteHighlightStyle, +} from './EvernoteHighlight' + +describe('EvernoteHighlight', () => { + it('detects --en-highlight in style attribute', () => { + expect(isEvernoteHighlightStyle('--en-highlight:yellow;background-color: #ffef9e;')).toBe(true) + }) + + it('detects -evernote-highlight in style attribute', () => { + expect(isEvernoteHighlightStyle('background-color: rgb(255, 250, 165);-evernote-highlight:true;')).toBe(true) + }) + + it('does not treat highlight:false as highlighted', () => { + expect(isEvernoteHighlightStyle('--en-highlight:false;')).toBe(false) + }) + + it('converts highlight spans to mark elements', () => { + const root = document.createElement('div') + root.innerHTML = + 'Line 2plain' + + convertEvernoteHighlightSpansToMarks(root) + + expect(root.querySelector('span')).not.toBeNull() + expect(root.querySelector('mark')?.textContent).toBe('Line 2') + expect(isEvernoteHighlightElement(root.querySelector('mark') as HTMLElement)).toBe(true) + }) +}) diff --git a/packages/ui-services/src/Import/EvernoteHighlight.ts b/packages/ui-services/src/Import/EvernoteHighlight.ts new file mode 100644 index 00000000000..b08bb5cf88a --- /dev/null +++ b/packages/ui-services/src/Import/EvernoteHighlight.ts @@ -0,0 +1,46 @@ +const EVERNOTE_HIGHLIGHT_PROPERTY = + /(?:--en-highlight|-en-highlight|--evernote-highlight|-evernote-highlight)\s*:\s*([^;]+)/i + +export function isEvernoteHighlightStyle(styleAttribute: string | null | undefined): boolean { + if (!styleAttribute) { + return false + } + + const match = styleAttribute.match(EVERNOTE_HIGHLIGHT_PROPERTY) + if (!match) { + return false + } + + return match[1].trim().toLowerCase() !== 'false' +} + +export function isEvernoteHighlightElement(element: HTMLElement): boolean { + if (isEvernoteHighlightStyle(element.getAttribute('style'))) { + return true + } + + const enHighlight = element.style.getPropertyValue('--en-highlight') + return enHighlight !== '' && enHighlight.toLowerCase() !== 'false' +} + +export function convertEvernoteHighlightSpansToMarks(root: ParentNode) { + const spans = Array.from(root.querySelectorAll('span')) + + for (const span of spans) { + if (!isEvernoteHighlightElement(span)) { + continue + } + + const mark = document.createElement('mark') + const style = span.getAttribute('style') + if (style) { + mark.setAttribute('style', style) + } + + while (span.firstChild) { + mark.appendChild(span.firstChild) + } + + span.replaceWith(mark) + } +} 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..64e74a71d63 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts @@ -0,0 +1,42 @@ +import { isEvernoteHighlightElement } from '@standardnotes/ui-services/src/Import/EvernoteHighlight' +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 (!isEvernoteHighlightElement(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..fa3bee8320a --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts @@ -0,0 +1,82 @@ +/** + * @jest-environment jsdom + */ + +import { HeadlessSuperConverter } from './HeadlessSuperConverter' +import { EvernoteConverter } from '@standardnotes/ui-services/src/Import/EvernoteConverter/EvernoteConverter' +import { 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('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', From 7cfe4863c52bb846af305937cda4f6de9908c8a3 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Fri, 5 Jun 2026 00:33:09 -0300 Subject: [PATCH 3/3] fix: Fixes checkboxes parsed as bullet items when importing Evernote notes --- .../EvernoteConverter.spec.ts | 57 +++++- .../EvernoteConverter/EvernoteConverter.ts | 188 ++++++++++++++++-- .../src/Import/EvernoteConverter/testData.ts | 27 +++ .../src/Import/EvernoteHighlight.spec.ts | 35 ---- .../src/Import/EvernoteHighlight.ts | 46 ----- .../src/Import/HighlightSpanImport.spec.ts | 26 +++ .../src/Import/HighlightSpanImport.ts | 24 +++ .../Lexical/Utils/highlightHtmlImport.ts | 4 +- .../Tools/HeadlessSuperConverter.spec.ts | 30 ++- 9 files changed, 329 insertions(+), 108 deletions(-) delete mode 100644 packages/ui-services/src/Import/EvernoteHighlight.spec.ts delete mode 100644 packages/ui-services/src/Import/EvernoteHighlight.ts create mode 100644 packages/ui-services/src/Import/HighlightSpanImport.spec.ts create mode 100644 packages/ui-services/src/Import/HighlightSpanImport.ts diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts index d6b09413919..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, emptyLineEnex, enex, highlightEnex } 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') @@ -140,6 +175,18 @@ describe('EvernoteConverter', () => { 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) diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts index aa8ec11e75e..4f46633ce3b 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -8,7 +8,10 @@ import Base64 from 'crypto-js/enc-base64' import { Converter, UploadFileFn } from '../Converter' import { ConversionResult } from '../ConversionResult' import { getBlobFromBase64 } from '../Utils' -import { convertEvernoteHighlightSpansToMarks } from '../EvernoteHighlight' +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) @@ -87,15 +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) - convertEvernoteHighlightSpansToMarks(noteElement) + this.convertHighlightSpansToMarks(noteElement) } + this.convertEvernoteChecklists(noteElement, canUseSuper) this.removeEmptyAndOrphanListElements(noteElement) this.unwrapTopLevelBreaks(noteElement) @@ -244,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) { @@ -410,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 67d19f4747a..8d1f6fb309b 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/testData.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/testData.ts @@ -50,6 +50,33 @@ export const highlightEnex = `
` +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 = ` diff --git a/packages/ui-services/src/Import/EvernoteHighlight.spec.ts b/packages/ui-services/src/Import/EvernoteHighlight.spec.ts deleted file mode 100644 index e913b4eaf8e..00000000000 --- a/packages/ui-services/src/Import/EvernoteHighlight.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { - convertEvernoteHighlightSpansToMarks, - isEvernoteHighlightElement, - isEvernoteHighlightStyle, -} from './EvernoteHighlight' - -describe('EvernoteHighlight', () => { - it('detects --en-highlight in style attribute', () => { - expect(isEvernoteHighlightStyle('--en-highlight:yellow;background-color: #ffef9e;')).toBe(true) - }) - - it('detects -evernote-highlight in style attribute', () => { - expect(isEvernoteHighlightStyle('background-color: rgb(255, 250, 165);-evernote-highlight:true;')).toBe(true) - }) - - it('does not treat highlight:false as highlighted', () => { - expect(isEvernoteHighlightStyle('--en-highlight:false;')).toBe(false) - }) - - it('converts highlight spans to mark elements', () => { - const root = document.createElement('div') - root.innerHTML = - 'Line 2plain' - - convertEvernoteHighlightSpansToMarks(root) - - expect(root.querySelector('span')).not.toBeNull() - expect(root.querySelector('mark')?.textContent).toBe('Line 2') - expect(isEvernoteHighlightElement(root.querySelector('mark') as HTMLElement)).toBe(true) - }) -}) diff --git a/packages/ui-services/src/Import/EvernoteHighlight.ts b/packages/ui-services/src/Import/EvernoteHighlight.ts deleted file mode 100644 index b08bb5cf88a..00000000000 --- a/packages/ui-services/src/Import/EvernoteHighlight.ts +++ /dev/null @@ -1,46 +0,0 @@ -const EVERNOTE_HIGHLIGHT_PROPERTY = - /(?:--en-highlight|-en-highlight|--evernote-highlight|-evernote-highlight)\s*:\s*([^;]+)/i - -export function isEvernoteHighlightStyle(styleAttribute: string | null | undefined): boolean { - if (!styleAttribute) { - return false - } - - const match = styleAttribute.match(EVERNOTE_HIGHLIGHT_PROPERTY) - if (!match) { - return false - } - - return match[1].trim().toLowerCase() !== 'false' -} - -export function isEvernoteHighlightElement(element: HTMLElement): boolean { - if (isEvernoteHighlightStyle(element.getAttribute('style'))) { - return true - } - - const enHighlight = element.style.getPropertyValue('--en-highlight') - return enHighlight !== '' && enHighlight.toLowerCase() !== 'false' -} - -export function convertEvernoteHighlightSpansToMarks(root: ParentNode) { - const spans = Array.from(root.querySelectorAll('span')) - - for (const span of spans) { - if (!isEvernoteHighlightElement(span)) { - continue - } - - const mark = document.createElement('mark') - const style = span.getAttribute('style') - if (style) { - mark.setAttribute('style', style) - } - - while (span.firstChild) { - mark.appendChild(span.firstChild) - } - - span.replaceWith(mark) - } -} 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/Lexical/Utils/highlightHtmlImport.ts b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts index 64e74a71d63..11810d40aa0 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Utils/highlightHtmlImport.ts @@ -1,4 +1,4 @@ -import { isEvernoteHighlightElement } from '@standardnotes/ui-services/src/Import/EvernoteHighlight' +import { isHighlightSpanElement } from '@standardnotes/ui-services/src/Import/HighlightSpanImport' import { $isTextNode, DOMConversionMap, LexicalNode } from 'lexical' function applyHighlightToTextChild(domNode: HTMLElement) { @@ -30,7 +30,7 @@ export const highlightHtmlImport: DOMConversionMap = { priority: 1, }), span: (domNode) => { - if (!isEvernoteHighlightElement(domNode as HTMLElement)) { + if (!isHighlightSpanElement(domNode as HTMLElement)) { return null } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts index fa3bee8320a..0e75fe96fe2 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.spec.ts @@ -4,7 +4,7 @@ import { HeadlessSuperConverter } from './HeadlessSuperConverter' import { EvernoteConverter } from '@standardnotes/ui-services/src/Import/EvernoteConverter/EvernoteConverter' -import { highlightEnex } from '@standardnotes/ui-services/src/Import/EvernoteConverter/testData' +import { checkboxEnex, highlightEnex } from '@standardnotes/ui-services/src/Import/EvernoteConverter/testData' import { GenerateUuid } from '@standardnotes/services' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' @@ -51,6 +51,34 @@ describe('HeadlessSuperConverter', () => { 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()),