refactor(dev-only): import tools (#2121)

This commit is contained in:
Aman Harwara
2022-12-27 21:03:01 +05:30
committed by GitHub
parent e67240821c
commit 3fbe28e068
24 changed files with 1267 additions and 1 deletions

View File

@@ -46,6 +46,8 @@ function selectFiles(): Promise<File[]> {
files.push(file)
}
resolve(files)
// Reset input value so that onchange is triggered again if the same file is selected
input.value = ''
}
input.click()
})

View File

@@ -18,4 +18,5 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleAndroidBackButtonPressed(): void
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
setAndroidBackHandlerFallbackListener(listener: () => boolean): void
generateUUID(): string
}

View File

@@ -55,6 +55,8 @@ export interface ItemsClientInterface {
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
notesMatchingSmartView(view: SmartView): SNNote[]
addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void

View File

@@ -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()
})
})

View File

@@ -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
}
}
}

View 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)

View File

@@ -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)
})
})

View File

@@ -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 || ''
}
}

View File

@@ -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>`

View File

@@ -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.')
})
})

View File

@@ -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
}
}
}

View 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>`

View 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)
}
}
}

View File

@@ -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)
})
})

View File

@@ -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
}
}
}

View File

@@ -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)

View 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)
})
}

View File

@@ -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'

View File

@@ -4,6 +4,7 @@
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "./dist",
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],

View File

@@ -430,4 +430,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.getViewControllerManager().preferencesController.setCurrentPane(pane)
}
}
generateUUID(): string {
return this.options.crypto.generateUUID()
}
}

View File

@@ -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<Props> = ({
<Icon type="open-in" className={iconClassName} />
Open FileSend
</MenuItem>
{featureTrunkEnabled(FeatureTrunkName.ImportTools) && <ImportMenuOption />}
{user ? (
<>
<MenuItemSeparator />

View File

@@ -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

View File

@@ -315,7 +315,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
itemListController={itemListController}
/>
)}
{(!isFilesTableViewEnabled || isMobileScreen()) && (
{(!shouldShowFilesTableView || isMobileScreen()) && (
<SearchBar
itemListController={itemListController}
searchOptionsController={searchOptionsController}

View File

@@ -3,11 +3,13 @@ import { isDev } from '@/Utils'
export enum FeatureTrunkName {
Super,
FilesTableView,
ImportTools,
}
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
[FeatureTrunkName.Super]: isDev && true,
[FeatureTrunkName.FilesTableView]: isDev && true,
[FeatureTrunkName.ImportTools]: isDev && true,
}
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {