Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand All @@ -120,18 +156,60 @@ 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')
expect(listItem2.getAttribute('aria-checked')).toBe('false')
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 =
'<span style="--en-highlight:yellow;background-color: #ffef9e;">Line 2</span><span>plain</span>'

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('<mark')
expect((successful?.[0] as SNNote).content.text).not.toMatch(/<span[^>]*--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('<p>line1</p><p></p><p>line2</p>')
})

it('should replace media elements with resources', async () => {
const resources: EvernoteResource[] = [
{
Expand Down
188 changes: 170 additions & 18 deletions packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
}
})
}
Expand Down Expand Up @@ -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)
}
Loading
Loading