From 3fbe28e068992e56c0e45d4390fd93d10b785d07 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Tue, 27 Dec 2022 21:03:01 +0530 Subject: [PATCH] refactor(dev-only): import tools (#2121) --- .../filepicker/src/Classic/ClassicReader.ts | 2 + .../Application/WebApplicationInterface.ts | 1 + .../src/Domain/Item/ItemsClientInterface.ts | 2 + .../AegisToAuthenticatorConverter.spec.ts | 89 ++++++ .../AegisToAuthenticatorConverter.ts | 92 ++++++ .../src/Import/AegisConverter/testData.ts | 42 +++ .../EvernoteConverter.spec.ts | 72 +++++ .../EvernoteConverter/EvernoteConverter.ts | 164 ++++++++++ .../src/Import/EvernoteConverter/testData.ts | 26 ++ .../GoogleKeepConverter.spec.ts | 54 ++++ .../GoogleKeepConverter.ts | 126 ++++++++ .../Import/GoogleKeepConverter/testData.ts | 290 ++++++++++++++++++ packages/ui-services/src/Import/Importer.ts | 14 + .../SimplenoteConverter.spec.ts | 46 +++ .../SimplenoteConverter.ts | 78 +++++ .../Import/SimplenoteConverter/testData.ts | 26 ++ packages/ui-services/src/Import/Utils.ts | 13 + packages/ui-services/src/index.ts | 4 + packages/ui-services/tsconfig.json | 1 + .../javascripts/Application/Application.ts | 4 + .../AccountMenu/GeneralAccountMenu.tsx | 3 + .../AccountMenu/ImportMenuOption.tsx | 115 +++++++ .../ContentListView/ContentListView.tsx | 2 +- packages/web/src/javascripts/FeatureTrunk.ts | 2 + 24 files changed, 1267 insertions(+), 1 deletion(-) create mode 100644 packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts create mode 100644 packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts create mode 100644 packages/ui-services/src/Import/AegisConverter/testData.ts create mode 100644 packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts create mode 100644 packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts create mode 100644 packages/ui-services/src/Import/EvernoteConverter/testData.ts create mode 100644 packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts create mode 100644 packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts create mode 100644 packages/ui-services/src/Import/GoogleKeepConverter/testData.ts create mode 100644 packages/ui-services/src/Import/Importer.ts create mode 100644 packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts create mode 100644 packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts create mode 100644 packages/ui-services/src/Import/SimplenoteConverter/testData.ts create mode 100644 packages/ui-services/src/Import/Utils.ts create mode 100644 packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx diff --git a/packages/filepicker/src/Classic/ClassicReader.ts b/packages/filepicker/src/Classic/ClassicReader.ts index 65f7b0edd..eaf253e36 100644 --- a/packages/filepicker/src/Classic/ClassicReader.ts +++ b/packages/filepicker/src/Classic/ClassicReader.ts @@ -46,6 +46,8 @@ function selectFiles(): Promise { files.push(file) } resolve(files) + // Reset input value so that onchange is triggered again if the same file is selected + input.value = '' } input.click() }) diff --git a/packages/services/src/Domain/Application/WebApplicationInterface.ts b/packages/services/src/Domain/Application/WebApplicationInterface.ts index bfd842dac..9395f59d4 100644 --- a/packages/services/src/Domain/Application/WebApplicationInterface.ts +++ b/packages/services/src/Domain/Application/WebApplicationInterface.ts @@ -18,4 +18,5 @@ export interface WebApplicationInterface extends ApplicationInterface { handleAndroidBackButtonPressed(): void addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined setAndroidBackHandlerFallbackListener(listener: () => boolean): void + generateUUID(): string } diff --git a/packages/services/src/Domain/Item/ItemsClientInterface.ts b/packages/services/src/Domain/Item/ItemsClientInterface.ts index 304761edc..39957b281 100644 --- a/packages/services/src/Domain/Item/ItemsClientInterface.ts +++ b/packages/services/src/Domain/Item/ItemsClientInterface.ts @@ -55,6 +55,8 @@ export interface ItemsClientInterface { getItems(contentType: ContentType | ContentType[]): T[] + insertItem(item: DecryptedItemInterface): Promise + notesMatchingSmartView(view: SmartView): SNNote[] addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void diff --git a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts new file mode 100644 index 000000000..29b197819 --- /dev/null +++ b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.spec.ts @@ -0,0 +1,89 @@ +import { FeatureIdentifier, NoteType } from '@standardnotes/features' +import { WebApplicationInterface } from '@standardnotes/services' +import { AegisToAuthenticatorConverter } from './AegisToAuthenticatorConverter' +import data from './testData' + +describe('AegisConverter', () => { + let application: WebApplicationInterface + + beforeEach(() => { + application = { + generateUUID: jest.fn().mockReturnValue('test'), + } as unknown as WebApplicationInterface + }) + + it('should parse entries', () => { + const converter = new AegisToAuthenticatorConverter(application) + + const result = converter.parseEntries(data) + + expect(result).not.toBeNull() + expect(result?.length).toBe(2) + expect(result?.[0]).toStrictEqual({ + service: 'TestMail', + account: 'test@test.com', + secret: 'TESTMAILTESTMAILTESTMAILTESTMAIL', + notes: 'Some note', + }) + expect(result?.[1]).toStrictEqual({ + service: 'Some Service', + account: 'test@test.com', + secret: 'SOMESERVICESOMESERVICESOMESERVIC', + notes: 'Some other service', + }) + }) + + it('should create note from entries with editor info', () => { + const converter = new AegisToAuthenticatorConverter(application) + + const parsedEntries = converter.parseEntries(data) + + const result = converter.createNoteFromEntries( + parsedEntries!, + { + lastModified: 123456789, + name: 'test.json', + }, + true, + ) + + expect(result).not.toBeNull() + expect(result.content_type).toBe('Note') + expect(result.created_at).toBeInstanceOf(Date) + expect(result.updated_at).toBeInstanceOf(Date) + expect(result.uuid).not.toBeNull() + expect(result.content.title).toBe('test') + expect(result.content.text).toBe( + '[{"service":"TestMail","account":"test@test.com","secret":"TESTMAILTESTMAILTESTMAILTESTMAIL","notes":"Some note"},{"service":"Some Service","account":"test@test.com","secret":"SOMESERVICESOMESERVICESOMESERVIC","notes":"Some other service"}]', + ) + expect(result.content.noteType).toBe(NoteType.Authentication) + expect(result.content.editorIdentifier).toBe(FeatureIdentifier.TokenVaultEditor) + }) + + it('should create note from entries without editor info', () => { + const converter = new AegisToAuthenticatorConverter(application) + + const parsedEntries = converter.parseEntries(data) + + const result = converter.createNoteFromEntries( + parsedEntries!, + { + lastModified: 123456789, + name: 'test.json', + }, + false, + ) + + expect(result).not.toBeNull() + expect(result.content_type).toBe('Note') + expect(result.created_at).toBeInstanceOf(Date) + expect(result.updated_at).toBeInstanceOf(Date) + expect(result.uuid).not.toBeNull() + expect(result.content.title).toBe('test') + expect(result.content.text).toBe( + '[{"service":"TestMail","account":"test@test.com","secret":"TESTMAILTESTMAILTESTMAILTESTMAIL","notes":"Some note"},{"service":"Some Service","account":"test@test.com","secret":"SOMESERVICESOMESERVICESOMESERVIC","notes":"Some other service"}]', + ) + expect(result.content.noteType).toBeFalsy() + expect(result.content.editorIdentifier).toBeFalsy() + }) +}) diff --git a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts new file mode 100644 index 000000000..41ec6017f --- /dev/null +++ b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts @@ -0,0 +1,92 @@ +import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' +import { readFileAsText } from '../Utils' +import { FeatureIdentifier, NoteType } from '@standardnotes/features' +import { WebApplicationInterface } from '@standardnotes/services' +import { Importer } from '../Importer' + +type AegisData = { + db: { + entries: { + issuer: string + name: string + info: { + secret: string + } + note: string + }[] + } +} + +type AuthenticatorEntry = { + service: string + account: string + secret: string + notes: string +} + +export class AegisToAuthenticatorConverter extends Importer { + constructor(protected override application: WebApplicationInterface) { + super(application) + } + + createNoteFromEntries( + entries: AuthenticatorEntry[], + file: { + lastModified: number + name: string + }, + addEditorInfo: boolean, + ): DecryptedTransferPayload { + return { + created_at: new Date(file.lastModified), + created_at_timestamp: file.lastModified, + updated_at: new Date(file.lastModified), + updated_at_timestamp: file.lastModified, + uuid: this.application.generateUUID(), + content_type: ContentType.Note, + content: { + title: file.name.split('.')[0], + text: JSON.stringify(entries), + references: [], + ...(addEditorInfo && { + noteType: NoteType.Authentication, + editorIdentifier: FeatureIdentifier.TokenVaultEditor, + }), + }, + } + } + + async convertAegisBackupFileToNote( + file: File, + addEditorInfo: boolean, + ): Promise> { + const content = await readFileAsText(file) + + const entries = this.parseEntries(content) + + if (!entries) { + throw new Error('Could not parse entries') + } + + return this.createNoteFromEntries(entries, file, addEditorInfo) + } + + parseEntries(data: string): AuthenticatorEntry[] | null { + try { + const json = JSON.parse(data) as AegisData + const entries = json.db.entries.map((entry) => { + return { + service: entry.issuer, + account: entry.name, + secret: entry.info.secret, + notes: entry.note, + } as AuthenticatorEntry + }) + return entries + } catch (error) { + console.error(error) + return null + } + } +} diff --git a/packages/ui-services/src/Import/AegisConverter/testData.ts b/packages/ui-services/src/Import/AegisConverter/testData.ts new file mode 100644 index 000000000..5a799fc3e --- /dev/null +++ b/packages/ui-services/src/Import/AegisConverter/testData.ts @@ -0,0 +1,42 @@ +const data = { + version: 1, + header: { + slots: null, + params: null, + }, + db: { + version: 2, + entries: [ + { + type: 'totp', + uuid: 'c74a11c4-4f23-417b-818a-e11f6a4d51d7', + name: 'test@test.com', + issuer: 'TestMail', + note: 'Some note', + icon: null, + info: { + secret: 'TESTMAILTESTMAILTESTMAILTESTMAIL', + algo: 'SHA1', + digits: 6, + period: 30, + }, + }, + { + type: 'totp', + uuid: '803ed58f-b2c4-386c-9aad-645a47309124', + name: 'test@test.com', + issuer: 'Some Service', + note: 'Some other service', + icon: null, + info: { + secret: 'SOMESERVICESOMESERVICESOMESERVIC', + algo: 'SHA1', + digits: 6, + period: 30, + }, + }, + ], + }, +} + +export default JSON.stringify(data, null, 2) diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts new file mode 100644 index 000000000..6a3d7830d --- /dev/null +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.spec.ts @@ -0,0 +1,72 @@ +/** + * @jest-environment jsdom + */ + +import { ContentType } from '@standardnotes/common' +import { WebApplicationInterface } from '@standardnotes/services' +import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models' +import { EvernoteConverter } from './EvernoteConverter' +import data from './testData' + +// Mock dayjs so dayjs.extend() doesn't throw an error in EvernoteConverter.ts +jest.mock('dayjs', () => { + return { + __esModule: true, + default: { + extend: jest.fn(), + utc: jest.fn().mockReturnValue({ + toDate: jest.fn().mockReturnValue(new Date()), + }), + }, + } +}) + +describe('EvernoteConverter', () => { + let application: WebApplicationInterface + + beforeEach(() => { + application = { + generateUUID: jest.fn().mockReturnValue(Math.random()), + } as any as WebApplicationInterface + }) + + it('should parse and strip html', () => { + const converter = new EvernoteConverter(application) + + const result = converter.parseENEXData(data, true) + + expect(result).not.toBeNull() + expect(result?.length).toBe(3) + expect(result?.[0].content_type).toBe(ContentType.Note) + expect((result?.[0] as DecryptedTransferPayload).content.text).toBe('This is a test.') + expect(result?.[1].content_type).toBe(ContentType.Note) + expect((result?.[1] as DecryptedTransferPayload).content.text).toBe( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ) + expect(result?.[2].content_type).toBe(ContentType.Tag) + expect((result?.[2] as DecryptedTransferPayload).content.title).toBe('evernote') + expect((result?.[2] as DecryptedTransferPayload).content.references.length).toBe(2) + expect((result?.[2] as DecryptedTransferPayload).content.references[0].uuid).toBe(result?.[0].uuid) + expect((result?.[2] as DecryptedTransferPayload).content.references[1].uuid).toBe(result?.[1].uuid) + }) + + it('should parse and not strip html', () => { + const converter = new EvernoteConverter(application) + + const result = converter.parseENEXData(data, false) + + expect(result).not.toBeNull() + expect(result?.length).toBe(3) + expect(result?.[0].content_type).toBe(ContentType.Note) + expect((result?.[0] as DecryptedTransferPayload).content.text).toBe('
This is a test.
') + expect(result?.[1].content_type).toBe(ContentType.Note) + expect((result?.[1] as DecryptedTransferPayload).content.text).toBe( + '
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
', + ) + expect(result?.[2].content_type).toBe(ContentType.Tag) + expect((result?.[2] as DecryptedTransferPayload).content.title).toBe('evernote') + expect((result?.[2] as DecryptedTransferPayload).content.references.length).toBe(2) + expect((result?.[2] as DecryptedTransferPayload).content.references[0].uuid).toBe(result?.[0].uuid) + expect((result?.[2] as DecryptedTransferPayload).content.references[1].uuid).toBe(result?.[1].uuid) + }) +}) diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts new file mode 100644 index 000000000..d6e0cc68f --- /dev/null +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -0,0 +1,164 @@ +import { ContentType } from '@standardnotes/common' +import { WebApplicationInterface } from '@standardnotes/services' +import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models' +import { readFileAsText } from '../Utils' +import { Importer } from '../Importer' +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import utc from 'dayjs/plugin/utc' +dayjs.extend(customParseFormat) +dayjs.extend(utc) + +const dateFormat = 'YYYYMMDDTHHmmss' + +export class EvernoteConverter extends Importer { + constructor(protected override application: WebApplicationInterface) { + super(application) + } + + async convertENEXFileToNotesAndTags(file: File, stripHTML: boolean): Promise { + const content = await readFileAsText(file) + + const notesAndTags = this.parseENEXData(content, stripHTML) + + return notesAndTags + } + + parseENEXData(data: string, stripHTML = false, defaultTagName = 'evernote') { + const xmlDoc = this.loadXMLString(data, 'xml') + const xmlNotes = xmlDoc.getElementsByTagName('note') + const notes: DecryptedTransferPayload[] = [] + const tags: DecryptedTransferPayload[] = [] + let defaultTag: DecryptedTransferPayload | undefined + + if (defaultTagName) { + const now = new Date() + defaultTag = { + created_at: now, + created_at_timestamp: now.getTime(), + updated_at: now, + updated_at_timestamp: now.getTime(), + uuid: this.application.generateUUID(), + content_type: ContentType.Tag, + content: { + title: defaultTagName, + expanded: false, + iconString: '', + references: [], + }, + } + } + + function findTag(title: string | null) { + return tags.filter(function (tag) { + return tag.content.title == title + })[0] + } + + function addTag(tag: DecryptedTransferPayload) { + tags.push(tag) + } + + for (const [index, xmlNote] of Array.from(xmlNotes).entries()) { + const title = xmlNote.getElementsByTagName('title')[0].textContent + const created = xmlNote.getElementsByTagName('created')[0].textContent + const updatedNodes = xmlNote.getElementsByTagName('updated') + const updated = updatedNodes.length ? updatedNodes[0].textContent : null + const contentNode = xmlNote.getElementsByTagName('content')[0] + let contentXmlString + /** Find the node with the content */ + for (const node of Array.from(contentNode.childNodes)) { + if (node instanceof CDATASection) { + contentXmlString = node.nodeValue + break + } + } + if (!contentXmlString) { + continue + } + const contentXml = this.loadXMLString(contentXmlString, 'html') + let contentHTML = contentXml.getElementsByTagName('en-note')[0].innerHTML + if (stripHTML) { + contentHTML = contentHTML.replace(/<\/div>/g, '\n') + contentHTML = contentHTML.replace(/]*>/g, '\n') + contentHTML = contentHTML.trim() + } + const text = stripHTML ? this.stripHTML(contentHTML) : contentHTML + const createdAtDate = created ? dayjs.utc(created, dateFormat).toDate() : new Date() + const updatedAtDate = updated ? dayjs.utc(updated, dateFormat).toDate() : createdAtDate + const note: DecryptedTransferPayload = { + created_at: createdAtDate, + created_at_timestamp: createdAtDate.getTime(), + updated_at: updatedAtDate, + updated_at_timestamp: updatedAtDate.getTime(), + uuid: this.application.generateUUID(), + content_type: ContentType.Note, + content: { + title: !title ? `Imported note ${index + 1} from Evernote` : title, + text, + references: [], + }, + } + + if (defaultTag) { + defaultTag.content.references.push({ + content_type: ContentType.Note, + uuid: note.uuid, + }) + } + + const xmlTags = xmlNote.getElementsByTagName('tag') + for (const tagXml of Array.from(xmlTags)) { + const tagName = tagXml.childNodes[0].nodeValue + let tag = findTag(tagName) + if (!tag) { + const now = new Date() + tag = { + uuid: this.application.generateUUID(), + content_type: ContentType.Tag, + created_at: now, + created_at_timestamp: now.getTime(), + updated_at: now, + updated_at_timestamp: now.getTime(), + content: { + title: tagName || `Imported tag ${index + 1} from Evernote`, + expanded: false, + iconString: '', + references: [], + }, + } + addTag(tag) + } + + note.content.references.push({ content_type: tag.content_type, uuid: tag.uuid }) + tag.content.references.push({ content_type: note.content_type, uuid: note.uuid }) + } + + notes.push(note) + } + + const allItems: DecryptedTransferPayload[] = [...notes, ...tags] + if (defaultTag) { + allItems.push(defaultTag) + } + + return allItems + } + + loadXMLString(string: string, type: 'html' | 'xml') { + let xmlDoc + if (window.DOMParser) { + const parser = new DOMParser() + xmlDoc = parser.parseFromString(string, `text/${type}`) + } else { + throw new Error('Could not parse XML string') + } + return xmlDoc + } + + stripHTML(html: string) { + const tmp = document.createElement('html') + tmp.innerHTML = html + return tmp.textContent || tmp.innerText || '' + } +} diff --git a/packages/ui-services/src/Import/EvernoteConverter/testData.ts b/packages/ui-services/src/Import/EvernoteConverter/testData.ts new file mode 100644 index 000000000..de60009b9 --- /dev/null +++ b/packages/ui-services/src/Import/EvernoteConverter/testData.ts @@ -0,0 +1,26 @@ +export default ` + + + + Testing 1 + 20210308T051614Z + 20210308T051855Z + + + + +
This is a test.
]]> +
+
+ + + 20200508T234829Z + 20200508T235233Z + + + + +
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
]]> +
+
+
` diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts new file mode 100644 index 000000000..7923e6d84 --- /dev/null +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts @@ -0,0 +1,54 @@ +/** + * @jest-environment jsdom + */ + +import { WebApplicationInterface } from '@standardnotes/snjs/dist/@types' +import { jsonTestData, htmlTestData } from './testData' +import { GoogleKeepConverter } from './GoogleKeepConverter' + +describe('GoogleKeepConverter', () => { + let application: WebApplicationInterface + + beforeEach(() => { + application = { + generateUUID: jest.fn().mockReturnValue('uuid'), + } as unknown as WebApplicationInterface + }) + + it('should parse json data', () => { + const converter = new GoogleKeepConverter(application) + + const result = converter.tryParseAsJson(jsonTestData) + + 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) + }) + + it('should parse html data', () => { + const converter = new GoogleKeepConverter(application) + + const result = converter.tryParseAsHtml( + htmlTestData, + { + name: 'note-2.html', + }, + 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 2') + expect(result?.content.text).toBe('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + }) +}) diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts new file mode 100644 index 000000000..d3ce3f4c9 --- /dev/null +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts @@ -0,0 +1,126 @@ +import { WebApplicationInterface } from '@standardnotes/services' +import { ContentType } from '@standardnotes/common' +import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { readFileAsText } from '../Utils' +import { Importer } from '../Importer' + +type GoogleKeepJsonNote = { + color: string + isTrashed: boolean + isPinned: boolean + isArchived: boolean + textContent: string + title: string + userEditedTimestampUsec: number +} + +export class GoogleKeepConverter extends Importer { + constructor(protected override application: WebApplicationInterface) { + super(application) + } + + async convertGoogleKeepBackupFileToNote( + file: File, + stripHtml: boolean, + ): Promise> { + const content = await readFileAsText(file) + + const possiblePayloadFromJson = this.tryParseAsJson(content) + + if (possiblePayloadFromJson) { + return possiblePayloadFromJson + } + + const possiblePayloadFromHtml = this.tryParseAsHtml(content, file, stripHtml) + + if (possiblePayloadFromHtml) { + return possiblePayloadFromHtml + } + + throw new Error('Could not parse Google Keep backup file') + } + + tryParseAsHtml(data: string, file: { name: string }, stripHtml: boolean): DecryptedTransferPayload { + const rootElement = document.createElement('html') + rootElement.innerHTML = data + + const contentElement = rootElement.getElementsByClassName('content')[0] + let content: string | null + + // Replace
with \n so line breaks get recognised + contentElement.innerHTML = contentElement.innerHTML.replace(/
/g, '\n') + + if (stripHtml) { + content = contentElement.textContent + } else { + content = contentElement.innerHTML + } + + if (!content) { + throw new Error('Could not parse content') + } + + 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(), + updated_at: date, + updated_at_timestamp: date.getTime(), + uuid: this.application.generateUUID(), + content_type: ContentType.Note, + content: { + title: title, + text: content, + references: [], + }, + } + } + + getDateFromGKeepNote(note: string) { + const regexWithTitle = /.*(?=<\/div>\n
)/ + const regexWithoutTitle = /.*(?=<\/div>\n\n
)/ + 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 + } + } + return + } + + tryParseAsJson(data: string): DecryptedTransferPayload | null { + try { + const parsed = JSON.parse(data) as GoogleKeepJsonNote + const date = new Date(parsed.userEditedTimestampUsec / 1000) + return { + created_at: date, + created_at_timestamp: date.getTime(), + updated_at: date, + updated_at_timestamp: date.getTime(), + uuid: this.application.generateUUID(), + content_type: ContentType.Note, + content: { + title: parsed.title, + text: parsed.textContent, + references: [], + archived: Boolean(parsed.isArchived), + trashed: Boolean(parsed.isTrashed), + pinned: Boolean(parsed.isPinned), + }, + } + } catch (e) { + return null + } + } +} diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts b/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts new file mode 100644 index 000000000..37cb2006c --- /dev/null +++ b/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts @@ -0,0 +1,290 @@ +const json = { + color: 'DEFAULT', + isTrashed: false, + isPinned: false, + isArchived: false, + textContent: 'This is a test.', + title: 'Testing 1', + userEditedTimestampUsec: 1618528050144000, +} + +export const jsonTestData = JSON.stringify(json) + +export const htmlTestData = ` + +Testing 2 + +
+ +
+Apr 15, 2021, 7:07:43 PM
+
Testing 2
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ + +
` diff --git a/packages/ui-services/src/Import/Importer.ts b/packages/ui-services/src/Import/Importer.ts new file mode 100644 index 000000000..a7999175c --- /dev/null +++ b/packages/ui-services/src/Import/Importer.ts @@ -0,0 +1,14 @@ +import { WebApplicationInterface } from '@standardnotes/services' +import { DecryptedTransferPayload } from '@standardnotes/snjs' + +export class Importer { + constructor(protected application: WebApplicationInterface) {} + + async importFromTransferPayloads(payloads: DecryptedTransferPayload[]): Promise { + for (const payload of payloads) { + const itemPayload = this.application.items.createPayloadFromObject(payload) + const item = this.application.items.createItemFromPayload(itemPayload) + await this.application.mutator.insertItem(item) + } + } +} diff --git a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts new file mode 100644 index 000000000..e540ae701 --- /dev/null +++ b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.spec.ts @@ -0,0 +1,46 @@ +import { WebApplicationInterface } from '@standardnotes/services' +import { SimplenoteConverter } from './SimplenoteConverter' +import data from './testData' + +describe('SimplenoteConverter', () => { + let application: WebApplicationInterface + + beforeEach(() => { + application = { + generateUUID: jest.fn().mockReturnValue('uuid'), + } as any + }) + + it('should parse', () => { + const converter = new SimplenoteConverter(application) + + const result = converter.parse(data) + + expect(result).not.toBeNull() + expect(result?.length).toBe(3) + + expect(result?.[0].created_at).toBeInstanceOf(Date) + expect(result?.[0].updated_at).toBeInstanceOf(Date) + expect(result?.[0].uuid).not.toBeNull() + expect(result?.[0].content_type).toBe('Note') + expect(result?.[0].content.title).toBe('Testing 1') + expect(result?.[0].content.text).toBe("This is the 1st note's content.") + expect(result?.[0].content.trashed).toBe(false) + + expect(result?.[1].created_at).toBeInstanceOf(Date) + expect(result?.[1].updated_at).toBeInstanceOf(Date) + expect(result?.[1].uuid).not.toBeNull() + expect(result?.[1].content_type).toBe('Note') + expect(result?.[1].content.title).toBe('Testing 2') + expect(result?.[1].content.text).toBe("This is the 2nd note's content.") + expect(result?.[1].content.trashed).toBe(false) + + expect(result?.[2].created_at).toBeInstanceOf(Date) + expect(result?.[2].updated_at).toBeInstanceOf(Date) + expect(result?.[2].uuid).not.toBeNull() + expect(result?.[2].content_type).toBe('Note') + expect(result?.[2].content.title).not.toBeFalsy() + expect(result?.[2].content.text).toBe('Welcome to Simplenote!') + expect(result?.[2].content.trashed).toBe(true) + }) +}) diff --git a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts new file mode 100644 index 000000000..4eef5b3e7 --- /dev/null +++ b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts @@ -0,0 +1,78 @@ +import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' +import { readFileAsText } from '../Utils' +import { WebApplicationInterface } from '@standardnotes/services' +import { Importer } from '../Importer' + +type SimplenoteItem = { + creationDate: string + lastModified: string + content: string +} + +type SimplenoteData = { + activeNotes: SimplenoteItem[] + trashedNotes: SimplenoteItem[] +} + +export class SimplenoteConverter extends Importer { + constructor(protected override application: WebApplicationInterface) { + super(application) + } + + createNoteFromItem(item: SimplenoteItem, trashed: boolean): DecryptedTransferPayload { + const createdAtDate = new Date(item.creationDate) + const updatedAtDate = new Date(item.lastModified) + + const splitItemContent = item.content.split('\r\n') + const hasTitleAndContent = splitItemContent.length === 2 + const title = + hasTitleAndContent && splitItemContent[0].length ? splitItemContent[0] : createdAtDate.toLocaleString() + const content = hasTitleAndContent && splitItemContent[1].length ? splitItemContent[1] : item.content + + return { + created_at: createdAtDate, + created_at_timestamp: createdAtDate.getTime(), + updated_at: updatedAtDate, + updated_at_timestamp: updatedAtDate.getTime(), + uuid: this.application.generateUUID(), + content_type: ContentType.Note, + content: { + title, + text: content, + references: [], + trashed, + appData: { + 'org.standardnotes.sn': { + client_updated_at: updatedAtDate, + }, + }, + }, + } + } + + async convertSimplenoteBackupFileToNotes(file: File): Promise[]> { + const content = await readFileAsText(file) + + const notes = this.parse(content) + + if (!notes) { + throw new Error('Could not parse notes') + } + + return notes + } + + parse(data: string) { + try { + const parsed = JSON.parse(data) as SimplenoteData + const activeNotes = parsed.activeNotes.reverse().map((item) => this.createNoteFromItem(item, false)) + const trashedNotes = parsed.trashedNotes.reverse().map((item) => this.createNoteFromItem(item, true)) + + return [...activeNotes, ...trashedNotes] + } catch (error) { + console.error(error) + return null + } + } +} diff --git a/packages/ui-services/src/Import/SimplenoteConverter/testData.ts b/packages/ui-services/src/Import/SimplenoteConverter/testData.ts new file mode 100644 index 000000000..cfb0b5120 --- /dev/null +++ b/packages/ui-services/src/Import/SimplenoteConverter/testData.ts @@ -0,0 +1,26 @@ +const data = { + activeNotes: [ + { + id: '43349052-4efa-48c2-bdd6-8323124451b1', + content: "Testing 2\r\nThis is the 2nd note's content.", + creationDate: '2020-06-08T21:28:43.856Z', + lastModified: '2021-04-16T06:21:53.124Z', + }, + { + id: '2a338440-4a24-4180-9805-1110d325642c', + content: "Testing 1\r\nThis is the 1st note's content.", + creationDate: '2020-06-08T21:28:38.241Z', + lastModified: '2021-04-16T06:21:58.294Z', + }, + ], + trashedNotes: [ + { + id: 'agtzaW1wbGUtbm90ZXIRCxIETm90ZRiAgLCvy-3gCAw', + content: 'Welcome to Simplenote!', + creationDate: '2020-06-08T21:28:28.434Z', + lastModified: '2021-04-16T06:20:14.143Z', + }, + ], +} + +export default JSON.stringify(data, null, 2) diff --git a/packages/ui-services/src/Import/Utils.ts b/packages/ui-services/src/Import/Utils.ts new file mode 100644 index 000000000..a9b79dcc0 --- /dev/null +++ b/packages/ui-services/src/Import/Utils.ts @@ -0,0 +1,13 @@ +export const readFileAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => { + if (e.target?.result) { + resolve(e.target.result as string) + } else { + reject() + } + } + reader.readAsText(file) + }) +} diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index 8fa1ab040..9cb7c7bda 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -32,3 +32,7 @@ export * from './Theme/ThemeManager' export * from './Toast/ToastService' export * from './Toast/ToastServiceInterface' export * from './StatePersistence/StatePersistence' +export * from './Import/AegisConverter/AegisToAuthenticatorConverter' +export * from './Import/SimplenoteConverter/SimplenoteConverter' +export * from './Import/GoogleKeepConverter/GoogleKeepConverter' +export * from './Import/EvernoteConverter/EvernoteConverter' diff --git a/packages/ui-services/tsconfig.json b/packages/ui-services/tsconfig.json index 6e7b1ea93..fa983cae8 100644 --- a/packages/ui-services/tsconfig.json +++ b/packages/ui-services/tsconfig.json @@ -4,6 +4,7 @@ "skipLibCheck": true, "rootDir": "./src", "outDir": "./dist", + "allowSyntheticDefaultImports": true, "jsx": "react-jsx" }, "include": ["src/**/*"], diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index bf2e33d1e..e93b9ab54 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -430,4 +430,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter this.getViewControllerManager().preferencesController.setCurrentPane(pane) } } + + generateUUID(): string { + return this.options.crypto.generateUUID() + } } diff --git a/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx b/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx index c9348be00..ac6a10fdf 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx @@ -14,6 +14,8 @@ import { ApplicationGroup } from '@/Application/ApplicationGroup' import { formatLastSyncDate } from '@/Utils/DateUtils' import Spinner from '@/Components/Spinner/Spinner' import { MenuItemIconSize } from '@/Constants/TailwindClassNames' +import ImportMenuOption from './ImportMenuOption' +import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk' type Props = { viewControllerManager: ViewControllerManager @@ -187,6 +189,7 @@ const GeneralAccountMenu: FunctionComponent = ({ Open FileSend + {featureTrunkEnabled(FeatureTrunkName.ImportTools) && } {user ? ( <> diff --git a/packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx b/packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx new file mode 100644 index 000000000..e11bada17 --- /dev/null +++ b/packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx @@ -0,0 +1,115 @@ +import { classNames, FeatureIdentifier, FeatureStatus } from '@standardnotes/snjs' +import Icon from '../Icon/Icon' +import MenuItem from '../Menu/MenuItem' +import { MenuItemIconSize } from '@/Constants/TailwindClassNames' +import { useRef, useState } from 'react' +import Popover from '../Popover/Popover' +import Menu from '../Menu/Menu' +import { ClassicFileReader } from '@standardnotes/filepicker' +import { + AegisToAuthenticatorConverter, + EvernoteConverter, + GoogleKeepConverter, + SimplenoteConverter, +} from '@standardnotes/ui-services' +import { useApplication } from '../ApplicationProvider' + +const iconClassName = classNames('mr-2 text-neutral', MenuItemIconSize) + +const ImportMenuOption = () => { + const application = useApplication() + const anchorRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const togglePopover = () => { + setIsMenuOpen((isOpen) => !isOpen) + } + + return ( + <> + + + Import + + + + + { + setIsMenuOpen((isOpen) => !isOpen) + }} + > + + Plaintext + + { + const files = await ClassicFileReader.selectFiles() + files.forEach(async (file) => { + const converter = new GoogleKeepConverter(application) + const noteTransferPayload = await converter.convertGoogleKeepBackupFileToNote(file, false) + void converter.importFromTransferPayloads([noteTransferPayload]) + }) + }} + > + + Google Keep + + { + const files = await ClassicFileReader.selectFiles() + files.forEach(async (file) => { + const converter = new EvernoteConverter(application) + const noteAndTagPayloads = await converter.convertENEXFileToNotesAndTags(file, true) + void converter.importFromTransferPayloads(noteAndTagPayloads) + }) + }} + > + + Evernote + + { + const files = await ClassicFileReader.selectFiles() + files.forEach(async (file) => { + const converter = new SimplenoteConverter(application) + const noteTransferPayloads = await converter.convertSimplenoteBackupFileToNotes(file) + void converter.importFromTransferPayloads(noteTransferPayloads) + }) + }} + > + + Simplenote + + { + const files = await ClassicFileReader.selectFiles() + const isEntitledToAuthenticator = + application.features.getFeatureStatus(FeatureIdentifier.TokenVaultEditor) === FeatureStatus.Entitled + files.forEach(async (file) => { + const converter = new AegisToAuthenticatorConverter(application) + const noteTransferPayload = await converter.convertAegisBackupFileToNote( + file, + isEntitledToAuthenticator, + ) + void converter.importFromTransferPayloads([noteTransferPayload]) + }) + }} + > + + Aegis + + + + + ) +} + +export default ImportMenuOption diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index a60ec2a13..1b337114e 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -315,7 +315,7 @@ const ContentListView = forwardRef( itemListController={itemListController} /> )} - {(!isFilesTableViewEnabled || isMobileScreen()) && ( + {(!shouldShowFilesTableView || isMobileScreen()) && ( = { [FeatureTrunkName.Super]: isDev && true, [FeatureTrunkName.FilesTableView]: isDev && true, + [FeatureTrunkName.ImportTools]: isDev && true, } export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {