Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added Super & HTML import options in Import modal. Google Keep notes will now also be imported as Super notes (with attachments when importing from HTML) #2433

Merged
merged 17 commits into from
Aug 18, 2023
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
@@ -1,3 +1,5 @@
export interface SuperConverterServiceInterface {
convertString: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string
isValidSuperString(superString: string): boolean
convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string
convertOtherFormatToSuperString: (otherFormatString: string, fromFormat: 'txt' | 'md' | 'html' | 'json') => string
}
5 changes: 4 additions & 1 deletion packages/services/src/Domain/Backups/FilesBackupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,10 @@ export class FilesBackupService
for (const note of notes) {
const tags = this.items.getSortedTagsForItem(note)
const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag))
const text = note.noteType === NoteType.Super ? this.markdownConverter.convertString(note.text, 'md') : note.text
const text =
note.noteType === NoteType.Super
? this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md')
: note.text
await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, text)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,56 @@
* @jest-environment jsdom
*/

import { jsonTestData, htmlTestData } from './testData'
import { jsonTextContentData, htmlTestData, jsonListContentData } from './testData'
import { GoogleKeepConverter } from './GoogleKeepConverter'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { GenerateUuid } from '@standardnotes/services'
import { SuperConverterServiceInterface } from '@standardnotes/snjs'

describe('GoogleKeepConverter', () => {
const crypto = {
generateUUID: () => String(Math.random()),
} as unknown as PureCryptoInterface

const superConverterService: SuperConverterServiceInterface = {
isValidSuperString: () => true,
convertOtherFormatToSuperString: (data: string) => data,
convertSuperStringToOtherFormat: (data: string) => data,
}
const generateUuid = new GenerateUuid(crypto)

it('should parse json data', () => {
const converter = new GoogleKeepConverter(generateUuid)
const converter = new GoogleKeepConverter(superConverterService, generateUuid)

const result = converter.tryParseAsJson(jsonTestData)
const textContent = converter.tryParseAsJson(jsonTextContentData, false)

expect(result).not.toBeNull()
expect(result?.created_at).toBeInstanceOf(Date)
expect(result?.updated_at).toBeInstanceOf(Date)
expect(result?.uuid).not.toBeNull()
expect(result?.content_type).toBe('Note')
expect(result?.content.title).toBe('Testing 1')
expect(result?.content.text).toBe('This is a test.')
expect(result?.content.trashed).toBe(false)
expect(result?.content.archived).toBe(false)
expect(result?.content.pinned).toBe(false)
expect(textContent).not.toBeNull()
expect(textContent?.created_at).toBeInstanceOf(Date)
expect(textContent?.updated_at).toBeInstanceOf(Date)
expect(textContent?.uuid).not.toBeNull()
expect(textContent?.content_type).toBe('Note')
expect(textContent?.content.title).toBe('Testing 1')
expect(textContent?.content.text).toBe('This is a test.')
expect(textContent?.content.trashed).toBe(false)
expect(textContent?.content.archived).toBe(false)
expect(textContent?.content.pinned).toBe(false)

const listContent = converter.tryParseAsJson(jsonListContentData, false)

expect(listContent).not.toBeNull()
expect(listContent?.created_at).toBeInstanceOf(Date)
expect(listContent?.updated_at).toBeInstanceOf(Date)
expect(listContent?.uuid).not.toBeNull()
expect(listContent?.content_type).toBe('Note')
expect(listContent?.content.title).toBe('Testing 1')
expect(listContent?.content.text).toBe('- [ ] Test 1\n- [x] Test 2')
expect(textContent?.content.trashed).toBe(false)
expect(textContent?.content.archived).toBe(false)
expect(textContent?.content.pinned).toBe(false)
})

it('should parse html data', () => {
const converter = new GoogleKeepConverter(generateUuid)
const converter = new GoogleKeepConverter(superConverterService, generateUuid)

const result = converter.tryParseAsHtml(
htmlTestData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,48 @@ import { ContentType } from '@standardnotes/domain-core'
import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models'
import { readFileAsText } from '../Utils'
import { GenerateUuid } from '@standardnotes/services'
import { SuperConverterServiceInterface } from '@standardnotes/files'
import { NativeFeatureIdentifier, NoteType } from '@standardnotes/features'

type Content =
| {
textContent: string
}
| {
listContent: {
text: string
isChecked: boolean
}[]
}

type GoogleKeepJsonNote = {
color: string
isTrashed: boolean
isPinned: boolean
isArchived: boolean
textContent: string
title: string
userEditedTimestampUsec: number
}
} & Content

export class GoogleKeepConverter {
constructor(private _generateUuid: GenerateUuid) {}
constructor(
private superConverterService: SuperConverterServiceInterface,
private _generateUuid: GenerateUuid,
) {}

async convertGoogleKeepBackupFileToNote(
file: File,
stripHtml: boolean,
isEntitledToSuper: boolean,
): Promise<DecryptedTransferPayload<NoteContent>> {
const content = await readFileAsText(file)

const possiblePayloadFromJson = this.tryParseAsJson(content)
const possiblePayloadFromJson = this.tryParseAsJson(content, isEntitledToSuper)

if (possiblePayloadFromJson) {
return possiblePayloadFromJson
}

const possiblePayloadFromHtml = this.tryParseAsHtml(content, file, stripHtml)
const possiblePayloadFromHtml = this.tryParseAsHtml(content, file, isEntitledToSuper)

if (possiblePayloadFromHtml) {
return possiblePayloadFromHtml
Expand All @@ -37,20 +52,51 @@ export class GoogleKeepConverter {
throw new Error('Could not parse Google Keep backup file')
}

tryParseAsHtml(data: string, file: { name: string }, stripHtml: boolean): DecryptedTransferPayload<NoteContent> {
tryParseAsHtml(
data: string,
file: { name: string },
isEntitledToSuper: boolean,
): DecryptedTransferPayload<NoteContent> {
const rootElement = document.createElement('html')
rootElement.innerHTML = data

const headingElement = rootElement.getElementsByClassName('heading')[0]
const date = new Date(headingElement?.textContent || '')
headingElement?.remove()

const contentElement = rootElement.getElementsByClassName('content')[0]
let content: string | null
if (!contentElement) {
throw new Error('Could not parse content. Content element not found.')
}

// Replace <br> with \n so line breaks get recognised
contentElement.innerHTML = contentElement.innerHTML.replace(/<br>/g, '\n')
let content: string | null

if (stripHtml) {
// Convert lists to readable plaintext format
// or Super-convertable format
const lists = contentElement.getElementsByTagName('ul')
Array.from(lists).forEach((list) => {
list.setAttribute('__lexicallisttype', 'check')

const items = list.getElementsByTagName('li')
Array.from(items).forEach((item) => {
const bulletSpan = item.getElementsByClassName('bullet')[0]
bulletSpan?.remove()

const checked = item.classList.contains('checked')
item.setAttribute('aria-checked', checked ? 'true' : 'false')

if (!isEntitledToSuper) {
item.textContent = `- ${checked ? '[x]' : '[ ]'} ${item.textContent?.trim()}\n`
}
})
})

if (!isEntitledToSuper) {
// Replace <br> with \n so line breaks get recognised
contentElement.innerHTML = contentElement.innerHTML.replace(/<br>/g, '\n')
content = contentElement.textContent
} else {
content = contentElement.innerHTML
content = this.superConverterService.convertOtherFormatToSuperString(rootElement.innerHTML, 'html')
}

if (!content) {
Expand All @@ -59,8 +105,6 @@ export class GoogleKeepConverter {

const title = rootElement.getElementsByClassName('title')[0]?.textContent || file.name

const date = this.getDateFromGKeepNote(data) || new Date()

return {
created_at: date,
created_at_timestamp: date.getTime(),
Expand All @@ -72,35 +116,30 @@ export class GoogleKeepConverter {
title: title,
text: content,
references: [],
...(isEntitledToSuper
? {
noteType: NoteType.Super,
editorIdentifier: NativeFeatureIdentifier.TYPES.SuperEditor,
}
: {}),
},
}
}

getDateFromGKeepNote(note: string) {
const regexWithTitle = /.*(?=<\/div>\n<div class="title">)/
const regexWithoutTitle = /.*(?=<\/div>\n\n<div class="content">)/
const possibleDateStringWithTitle = regexWithTitle.exec(note)?.[0]
const possibleDateStringWithoutTitle = regexWithoutTitle.exec(note)?.[0]
if (possibleDateStringWithTitle) {
const date = new Date(possibleDateStringWithTitle)
if (date.toString() !== 'Invalid Date' && date.toString() !== 'NaN') {
return date
}
}
if (possibleDateStringWithoutTitle) {
const date = new Date(possibleDateStringWithoutTitle)
if (date.toString() !== 'Invalid Date' && date.toString() !== 'NaN') {
return date
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static isValidGoogleKeepJson(json: any): boolean {
if (typeof json.textContent !== 'string') {
if (typeof json.listContent === 'object' && Array.isArray(json.listContent)) {
return json.listContent.every(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item: any) => typeof item.text === 'string' && typeof item.isChecked === 'boolean',
)
}
return false
}
return
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
static isValidGoogleKeepJson(json: any): boolean {
return (
typeof json.title === 'string' &&
typeof json.textContent === 'string' &&
typeof json.userEditedTimestampUsec === 'number' &&
typeof json.isArchived === 'boolean' &&
typeof json.isTrashed === 'boolean' &&
Expand All @@ -109,13 +148,26 @@ export class GoogleKeepConverter {
)
}

tryParseAsJson(data: string): DecryptedTransferPayload<NoteContent> | null {
tryParseAsJson(data: string, isEntitledToSuper: boolean): DecryptedTransferPayload<NoteContent> | null {
try {
const parsed = JSON.parse(data) as GoogleKeepJsonNote
if (!GoogleKeepConverter.isValidGoogleKeepJson(parsed)) {
return null
}
const date = new Date(parsed.userEditedTimestampUsec / 1000)
let text: string
if ('textContent' in parsed) {
text = parsed.textContent
} else {
text = parsed.listContent
.map((item) => {
return item.isChecked ? `- [x] ${item.text}` : `- [ ] ${item.text}`
})
.join('\n')
}
if (isEntitledToSuper) {
text = this.superConverterService.convertOtherFormatToSuperString(text, 'md')
}
return {
created_at: date,
created_at_timestamp: date.getTime(),
Expand All @@ -125,14 +177,21 @@ export class GoogleKeepConverter {
content_type: ContentType.TYPES.Note,
content: {
title: parsed.title,
text: parsed.textContent,
text,
references: [],
archived: Boolean(parsed.isArchived),
trashed: Boolean(parsed.isTrashed),
pinned: Boolean(parsed.isPinned),
...(isEntitledToSuper
? {
noteType: NoteType.Super,
editorIdentifier: NativeFeatureIdentifier.TYPES.SuperEditor,
}
: {}),
},
}
} catch (e) {
console.error(e)
return null
}
}
Expand Down
25 changes: 23 additions & 2 deletions packages/ui-services/src/Import/GoogleKeepConverter/testData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const json = {
const jsonWithTextContent = {
color: 'DEFAULT',
isTrashed: false,
isPinned: false,
Expand All @@ -8,7 +8,28 @@ const json = {
userEditedTimestampUsec: 1618528050144000,
}

export const jsonTestData = JSON.stringify(json)
export const jsonTextContentData = JSON.stringify(jsonWithTextContent)

const jsonWithListContent = {
color: 'DEFAULT',
isTrashed: false,
isPinned: false,
isArchived: false,
listContent: [
{
text: 'Test 1',
isChecked: false,
},
{
text: 'Test 2',
isChecked: true,
},
],
title: 'Testing 1',
userEditedTimestampUsec: 1618528050144000,
}

export const jsonListContentData = JSON.stringify(jsonWithListContent)

export const htmlTestData = `<?xml version="1.0" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
Expand Down
Loading