refactor(dev-only): import tools (#2121)
This commit is contained in:
@@ -46,6 +46,8 @@ function selectFiles(): Promise<File[]> {
|
|||||||
files.push(file)
|
files.push(file)
|
||||||
}
|
}
|
||||||
resolve(files)
|
resolve(files)
|
||||||
|
// Reset input value so that onchange is triggered again if the same file is selected
|
||||||
|
input.value = ''
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export interface WebApplicationInterface extends ApplicationInterface {
|
|||||||
handleAndroidBackButtonPressed(): void
|
handleAndroidBackButtonPressed(): void
|
||||||
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
|
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
|
||||||
setAndroidBackHandlerFallbackListener(listener: () => boolean): void
|
setAndroidBackHandlerFallbackListener(listener: () => boolean): void
|
||||||
|
generateUUID(): string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface ItemsClientInterface {
|
|||||||
|
|
||||||
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
|
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
|
||||||
|
|
||||||
|
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
|
||||||
|
|
||||||
notesMatchingSmartView(view: SmartView): SNNote[]
|
notesMatchingSmartView(view: SmartView): SNNote[]
|
||||||
|
|
||||||
addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void
|
addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<NoteContent> {
|
||||||
|
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<DecryptedTransferPayload<NoteContent>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/ui-services/src/Import/AegisConverter/testData.ts
Normal file
42
packages/ui-services/src/Import/AegisConverter/testData.ts
Normal file
@@ -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)
|
||||||
@@ -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<NoteContent>).content.text).toBe('This is a test.')
|
||||||
|
expect(result?.[1].content_type).toBe(ContentType.Note)
|
||||||
|
expect((result?.[1] as DecryptedTransferPayload<NoteContent>).content.text).toBe(
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
|
||||||
|
)
|
||||||
|
expect(result?.[2].content_type).toBe(ContentType.Tag)
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.title).toBe('evernote')
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.references.length).toBe(2)
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.references[0].uuid).toBe(result?.[0].uuid)
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).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<NoteContent>).content.text).toBe('<div>This is a test.</div>')
|
||||||
|
expect(result?.[1].content_type).toBe(ContentType.Note)
|
||||||
|
expect((result?.[1] as DecryptedTransferPayload<NoteContent>).content.text).toBe(
|
||||||
|
'<div>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
)
|
||||||
|
expect(result?.[2].content_type).toBe(ContentType.Tag)
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.title).toBe('evernote')
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.references.length).toBe(2)
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.references[0].uuid).toBe(result?.[0].uuid)
|
||||||
|
expect((result?.[2] as DecryptedTransferPayload<TagContent>).content.references[1].uuid).toBe(result?.[1].uuid)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<DecryptedTransferPayload[]> {
|
||||||
|
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<NoteContent>[] = []
|
||||||
|
const tags: DecryptedTransferPayload<TagContent>[] = []
|
||||||
|
let defaultTag: DecryptedTransferPayload<TagContent> | 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<TagContent>) {
|
||||||
|
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, '</div>\n')
|
||||||
|
contentHTML = contentHTML.replace(/<li[^>]*>/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<NoteContent> = {
|
||||||
|
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 || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export default `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export3.dtd">
|
||||||
|
<en-export export-date="20210408T052957Z" application="Evernote" version="10.8.5">
|
||||||
|
<note>
|
||||||
|
<title>Testing 1</title>
|
||||||
|
<created>20210308T051614Z</created>
|
||||||
|
<updated>20210308T051855Z</updated>
|
||||||
|
<note-attributes>
|
||||||
|
</note-attributes>
|
||||||
|
<content>
|
||||||
|
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div>This is a test.</div></en-note> ]]>
|
||||||
|
</content>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<title></title>
|
||||||
|
<created>20200508T234829Z</created>
|
||||||
|
<updated>20200508T235233Z</updated>
|
||||||
|
<note-attributes>
|
||||||
|
</note-attributes>
|
||||||
|
<content>
|
||||||
|
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div></en-note> ]]>
|
||||||
|
</content>
|
||||||
|
</note>
|
||||||
|
</en-export>`
|
||||||
@@ -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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<DecryptedTransferPayload<NoteContent>> {
|
||||||
|
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<NoteContent> {
|
||||||
|
const rootElement = document.createElement('html')
|
||||||
|
rootElement.innerHTML = data
|
||||||
|
|
||||||
|
const contentElement = rootElement.getElementsByClassName('content')[0]
|
||||||
|
let content: string | null
|
||||||
|
|
||||||
|
// Replace <br> with \n so line breaks get recognised
|
||||||
|
contentElement.innerHTML = contentElement.innerHTML.replace(/<br>/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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tryParseAsJson(data: string): DecryptedTransferPayload<NoteContent> | 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
290
packages/ui-services/src/Import/GoogleKeepConverter/testData.ts
Normal file
290
packages/ui-services/src/Import/GoogleKeepConverter/testData.ts
Normal file
@@ -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 = `<?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" />
|
||||||
|
<title>Testing 2</title>
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto Condensed','Droid Sans',arial,sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(0,0,0,0.8);
|
||||||
|
word-wrap: break-word;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-style: inset dashed;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 2px 1px rgba(0,0,0,0.08);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
max-width: 600px;
|
||||||
|
min-width: 240px;
|
||||||
|
margin: 20px;
|
||||||
|
|
||||||
|
background-color: rgb(255, 255, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .heading {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 15px 15px 0 15px;
|
||||||
|
color: rgba(100,100,100,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 15px 15px 0 15px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .content {
|
||||||
|
padding: 12px 15px 15px 15px;
|
||||||
|
font-family: 'Roboto Slab','Times New Roman',serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .attachments {
|
||||||
|
padding: 0 15px 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments ul {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments li {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments li img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments .audio {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxOC4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4KPCFET0NUWVBFIHN2ZyAgUFVCTElDICctLy9XM0MvL0RURCBTVkcgMS4xLy9FTicgICdodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQnPgo8c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCAyMCAyMCIgdmVyc2lvbj0iMS4xIiB5PSIwcHgiIHg9IjBweCIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDIwIDIwIj4KPHBhdGggZD0ibTEgN3Y2aDRsNSA1di0xNmwtNSA1aC00em0xMy41IDNjMC0xLjgtMS0zLjMtMi41LTR2OGMxLjUtMC43IDIuNS0yLjIgMi41LTR6bS0yLjUtOC44djIuMWMyLjkgMC45IDUgMy41IDUgNi43cy0yLjEgNS44LTUgNi43djIuMWM0LTAuOSA3LTQuNSA3LTguOHMtMy03LjktNy04Ljh6Ii8+Cjwvc3ZnPgo=);
|
||||||
|
background-size: 18px 18px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .listitem {
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .listitem .bullet {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .listitem .text {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .identifier {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.note .identifier:before {
|
||||||
|
content: "(";
|
||||||
|
}
|
||||||
|
.note .identifier:after {
|
||||||
|
content: ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only show identifiers when the element is hovered. */
|
||||||
|
.note .listitem .identifier,
|
||||||
|
.note .chip .identifier {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .listitem:hover .identifier,
|
||||||
|
.note .chip:hover .identifier {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .chips {
|
||||||
|
padding: 12px 15px 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .chip {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 198px;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Roboto','Droid Sans',arial,sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .chip a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background-size: 100%;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation.CALENDAR .chip-icon {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMTcgMTJoLTV2NWg1di01ek0xNiAxdjJIOFYxSDZ2Mkg1Yy0xLjExIDAtMS45OS45LTEuOTkgMkwzIDE5YzAgMS4xLjg5IDIgMiAyaDE0YzEuMSAwIDItLjkgMi0yVjVjMC0xLjEtLjktMi0yLTJoLTFWMWgtMnptMyAxOEg1VjhoMTR2MTF6Ii8+CiAgICA8cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+Cjwvc3ZnPgo=);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation.DOCS .chip-icon {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+CiAgICA8cGF0aCBkPSJNMTkgM0g1Yy0xLjEgMC0yIC45LTIgMnYxNGMwIDEuMS45IDIgMiAyaDE0YzEuMSAwIDItLjkgMi0yVjVjMC0xLjEtLjktMi0yLTJ6bS0xLjk5IDZIN1Y3aDEwLjAxdjJ6bTAgNEg3di0yaDEwLjAxdjJ6bS0zIDRIN3YtMmg3LjAxdjJ6Ii8+Cjwvc3ZnPgo=);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation.GMAIL .chip-icon {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBmaWxsPSJub25lIiBkPSJNLTYxOC0yMTA0SDc4MnYzNjAwSC02MTh6TTAgMGgyNHYyNEgweiIvPgogICAgPHBhdGggZD0iTTIwIDRINGMtMS4xIDAtMiAuOS0yIDJ2MTJjMCAxLjEuOSAyIDIgMmgxNmMxLjEgMCAyLS45IDItMlY2YzAtMS4xLS45LTItMi0yem0wIDE0aC0yVjkuMkwxMiAxMyA2IDkuMlYxOEg0VjZoMS4ybDYuOCA0LjJMMTguOCA2SDIwdjEyeiIvPgo8L3N2Zz4K);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation.SHEETS .chip-icon {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+CiAgICA8cGF0aCBkPSJNMTkgM0g1Yy0xLjEgMC0xLjk5LjktMS45OSAyTDMgOHYxMWMwIDEuMS45IDIgMiAyaDE0YzEuMSAwIDItLjkgMi0yVjVjMC0xLjEtLjktMi0yLTJ6bTAgOGgtOHY4SDl2LThINVY5aDRWNWgydjRoOHYyeiIvPgo8L3N2Zz4K);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation.SLIDES .chip-icon {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjRweCIgaGVpZ2h0PSIyNHB4IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+CiAgICA8cGF0aCBkPSJNMTkgM0g1Yy0xLjEgMC0xLjk5LjktMS45OSAydjE0YzAgMS4xLjg5IDIgMS45OSAyaDE0YzEuMSAwIDItLjkgMi0yVjVjMC0xLjEtLjktMi0yLTJ6bTAgMTNINVY4aDE0djh6Ii8+Cjwvc3ZnPgo=);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation.WEBLINK .chip-icon {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMTkgNEg1Yy0xLjExIDAtMiAuOS0yIDJ2MTJjMCAxLjEuODkgMiAyIDJoMTRjMS4xIDAgMi0uOSAyLTJWNmMwLTEuMS0uODktMi0yLTJ6bTAgMTRINVY4aDE0djEweiIvPgogICAgPHBhdGggZmlsbD0ibm9uZSIgZD0iTTAgMGgyNHYyNEgweiIvPgo8L3N2Zz4K);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharees h2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharees ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 15px 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharees li {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
text-indent: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJzNC40OCAxMCAxMCAxMCAxMC00LjQ4IDEwLTEwUzE3LjUyIDIgMTIgMnptMCAzYzEuNjYgMCAzIDEuMzQgMyAzcy0xLjM0IDMtMyAzLTMtMS4zNC0zLTMgMS4zNC0zIDMtM3ptMCAxNC4yYy0yLjUgMC00LjcxLTEuMjgtNi0zLjIyLjAzLTEuOTkgNC0zLjA4IDYtMy4wOCAxLjk5IDAgNS45NyAxLjA5IDYgMy4wOC0xLjI5IDEuOTQtMy41IDMuMjItNiAzLjIyeiIvPgogICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPgo8L3N2Zz4K);
|
||||||
|
background-size: 18px 18px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sharees li.group {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwMDAwMCI+CiAgICA8cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+CiAgICA8cGF0aCBkPSJNMTYgMTFjMS42NiAwIDIuOTktMS4zNCAyLjk5LTNTMTcuNjYgNSAxNiA1Yy0xLjY2IDAtMyAxLjM0LTMgM3MxLjM0IDMgMyAzem0tOCAwYzEuNjYgMCAyLjk5LTEuMzQgMi45OS0zUzkuNjYgNSA4IDVDNi4zNCA1IDUgNi4zNCA1IDhzMS4zNCAzIDMgM3ptMCAyYy0yLjMzIDAtNyAxLjE3LTcgMy41VjE5aDE0di0yLjVjMC0yLjMzLTQuNjctMy41LTctMy41em04IDBjLS4yOSAwLS42Mi4wMi0uOTcuMDUgMS4xNi44NCAxLjk3IDEuOTcgMS45NyAzLjQ1VjE5aDZ2LTIuNWMwLTIuMzMtNC42Ny0zLjUtNy0zLjV6Ii8+Cjwvc3ZnPgo=);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .meta-icons {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note .meta-icons span {
|
||||||
|
display: inline-block;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icons .pinned {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMHB4IiBoZWlnaHQ9IjIwcHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzQyODVmNCI+DQogICAgPHBhdGggZD0iTTE2IDVoLjk5TDE3IDNIN3YyaDF2N2wtMiAydjJoNXY2bDEgMSAxLTF2LTZoNXYtMmwtMi0yVjV6Ii8+DQogICAgPHBhdGggZmlsbD0ibm9uZSIgZD0iTTAgMGgyNHYyNEgweiIvPg0KPC9zdmc+);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icons .archived {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxOC4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4KPCFET0NUWVBFIHN2ZyAgUFVCTElDICctLy9XM0MvL0RURCBTVkcgMS4xLy9FTicgICdodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQnPgo8c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCAxOCAxOCIgdmVyc2lvbj0iMS4xIiB5PSIwcHgiIHg9IjBweCIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE4IDE4Ij4KPHBhdGggZD0ibTE2LjYgM2wtMS4yLTEuNWMtMC4yLTAuMy0wLjYtMC41LTEtMC41aC0xMC43Yy0wLjQgMC0wLjggMC4yLTEgMC41bC0xLjMgMS41Yy0wLjIgMC4zLTAuNCAwLjctMC40IDEuMXYxMS4xYzAgMSAwLjggMS44IDEuOCAxLjhoMTIuNGMxIDAgMS44LTAuOCAxLjgtMS44di0xMS4xYzAtMC40LTAuMi0wLjgtMC40LTEuMXptLTcuNiAxMC45bC00LjktNC45aDMuMXYtMS44aDMuNnYxLjhoMy4xbC00LjkgNC45em0tNi4xLTExLjFsMC43LTAuOWgxMC43bDAuOCAwLjloLTEyLjJ6Ii8+Cjwvc3ZnPgo=);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icons .trashed {
|
||||||
|
background-image: url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE4cHgiIHdpZHRoPSIxOHB4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0iIzAwMDAwMCI+DQogPHBhdGggZD0ibTEyIDM4YzAgMi4yMSAxLjc5IDQgNCA0aDE2YzIuMjEgMCA0LTEuNzkgNC00di0yNGgtMjR2MjR6bTI2LTMwaC03bC0yLTJoLTEwbC0yIDJoLTd2NGgyOHYtNHoiLz4NCiA8cGF0aCBkPSJtMCAwaDQ4djQ4aC00OHoiIGZpbGw9Im5vbmUiLz4NCjwvc3ZnPg==);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checked {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RED {
|
||||||
|
background-color: rgb(255, 109, 63) !important;
|
||||||
|
}
|
||||||
|
.ORANGE {
|
||||||
|
background-color: rgb(255, 155, 0) !important;
|
||||||
|
}
|
||||||
|
.YELLOW {
|
||||||
|
background-color: rgb(255, 218, 0) !important;
|
||||||
|
}
|
||||||
|
.GREEN {
|
||||||
|
background-color: rgb(149, 214, 65) !important;
|
||||||
|
}
|
||||||
|
.TEAL {
|
||||||
|
background-color: rgb(28, 232, 181) !important;
|
||||||
|
}
|
||||||
|
.BLUE {
|
||||||
|
background-color: rgb(63, 195, 255) !important;
|
||||||
|
}
|
||||||
|
.GRAY {
|
||||||
|
background-color: rgb(184, 196, 201) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* go/keep-more-colors-eng */
|
||||||
|
.CERULEAN {
|
||||||
|
background-color: rgb(130, 177, 255) !important;
|
||||||
|
}
|
||||||
|
.PURPLE {
|
||||||
|
background-color: rgb(179, 136, 255) !important;
|
||||||
|
}
|
||||||
|
.PINK {
|
||||||
|
background-color: rgb(248, 187, 208) !important;
|
||||||
|
}
|
||||||
|
.BROWN {
|
||||||
|
background-color: rgb(215, 204, 200) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style></head>
|
||||||
|
<body><div class="note"><div class="heading"><div class="meta-icons">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
Apr 15, 2021, 7:07:43 PM</div>
|
||||||
|
<div class="title">Testing 2</div>
|
||||||
|
<div class="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div></body></html>`
|
||||||
14
packages/ui-services/src/Import/Importer.ts
Normal file
14
packages/ui-services/src/Import/Importer.ts
Normal file
@@ -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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<NoteContent> {
|
||||||
|
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<DecryptedTransferPayload<NoteContent>[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
13
packages/ui-services/src/Import/Utils.ts
Normal file
13
packages/ui-services/src/Import/Utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const readFileAsText = (file: File): Promise<string> => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,3 +32,7 @@ export * from './Theme/ThemeManager'
|
|||||||
export * from './Toast/ToastService'
|
export * from './Toast/ToastService'
|
||||||
export * from './Toast/ToastServiceInterface'
|
export * from './Toast/ToastServiceInterface'
|
||||||
export * from './StatePersistence/StatePersistence'
|
export * from './StatePersistence/StatePersistence'
|
||||||
|
export * from './Import/AegisConverter/AegisToAuthenticatorConverter'
|
||||||
|
export * from './Import/SimplenoteConverter/SimplenoteConverter'
|
||||||
|
export * from './Import/GoogleKeepConverter/GoogleKeepConverter'
|
||||||
|
export * from './Import/EvernoteConverter/EvernoteConverter'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
|
|||||||
@@ -430,4 +430,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
|||||||
this.getViewControllerManager().preferencesController.setCurrentPane(pane)
|
this.getViewControllerManager().preferencesController.setCurrentPane(pane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateUUID(): string {
|
||||||
|
return this.options.crypto.generateUUID()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
|||||||
import { formatLastSyncDate } from '@/Utils/DateUtils'
|
import { formatLastSyncDate } from '@/Utils/DateUtils'
|
||||||
import Spinner from '@/Components/Spinner/Spinner'
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
||||||
|
import ImportMenuOption from './ImportMenuOption'
|
||||||
|
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
viewControllerManager: ViewControllerManager
|
viewControllerManager: ViewControllerManager
|
||||||
@@ -187,6 +189,7 @@ const GeneralAccountMenu: FunctionComponent<Props> = ({
|
|||||||
<Icon type="open-in" className={iconClassName} />
|
<Icon type="open-in" className={iconClassName} />
|
||||||
Open FileSend
|
Open FileSend
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{featureTrunkEnabled(FeatureTrunkName.ImportTools) && <ImportMenuOption />}
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<MenuItemSeparator />
|
<MenuItemSeparator />
|
||||||
|
|||||||
@@ -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<HTMLButtonElement>(null)
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const togglePopover = () => {
|
||||||
|
setIsMenuOpen((isOpen) => !isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem ref={anchorRef} onClick={togglePopover}>
|
||||||
|
<Icon type="upload" className={iconClassName} />
|
||||||
|
Import
|
||||||
|
<Icon type="chevron-right" className={`ml-auto text-neutral ${MenuItemIconSize}`} />
|
||||||
|
</MenuItem>
|
||||||
|
<Popover
|
||||||
|
anchorElement={anchorRef.current}
|
||||||
|
className="py-2"
|
||||||
|
open={isMenuOpen}
|
||||||
|
side="right"
|
||||||
|
align="end"
|
||||||
|
togglePopover={togglePopover}
|
||||||
|
>
|
||||||
|
<Menu a11yLabel="Import options menu" isOpen={isMenuOpen}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsMenuOpen((isOpen) => !isOpen)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="plain-text" className={iconClassName} />
|
||||||
|
Plaintext
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="plain-text" className={iconClassName} />
|
||||||
|
Google Keep
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="rich-text" className={iconClassName} />
|
||||||
|
Evernote
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
const files = await ClassicFileReader.selectFiles()
|
||||||
|
files.forEach(async (file) => {
|
||||||
|
const converter = new SimplenoteConverter(application)
|
||||||
|
const noteTransferPayloads = await converter.convertSimplenoteBackupFileToNotes(file)
|
||||||
|
void converter.importFromTransferPayloads(noteTransferPayloads)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="rich-text" className={iconClassName} />
|
||||||
|
Simplenote
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="lock-filled" className={iconClassName} />
|
||||||
|
Aegis
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportMenuOption
|
||||||
@@ -315,7 +315,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
|||||||
itemListController={itemListController}
|
itemListController={itemListController}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(!isFilesTableViewEnabled || isMobileScreen()) && (
|
{(!shouldShowFilesTableView || isMobileScreen()) && (
|
||||||
<SearchBar
|
<SearchBar
|
||||||
itemListController={itemListController}
|
itemListController={itemListController}
|
||||||
searchOptionsController={searchOptionsController}
|
searchOptionsController={searchOptionsController}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { isDev } from '@/Utils'
|
|||||||
export enum FeatureTrunkName {
|
export enum FeatureTrunkName {
|
||||||
Super,
|
Super,
|
||||||
FilesTableView,
|
FilesTableView,
|
||||||
|
ImportTools,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
|
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
|
||||||
[FeatureTrunkName.Super]: isDev && true,
|
[FeatureTrunkName.Super]: isDev && true,
|
||||||
[FeatureTrunkName.FilesTableView]: isDev && true,
|
[FeatureTrunkName.FilesTableView]: isDev && true,
|
||||||
|
[FeatureTrunkName.ImportTools]: isDev && true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {
|
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user