feat: Added "Import" option in the account menu that allows you to import from plaintext/markdown files, Evernote exports, Simplenote exports, Google Keep exports and also convert Aegis exports to TokenVault notes
This commit is contained in:
4
packages/icons/src/Icons/ic-aegis.svg
Normal file
4
packages/icons/src/Icons/ic-aegis.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 48 48">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M19.59 40a2.4 2.4 0 0 1-3.28-.88l-.48-.82A2.4 2.4 0 0 1 20 35.91l.48.82a2.42 2.42 0 0 1-.89 3.27Zm10.85 0a2.4 2.4 0 0 1-3.28-.88l-5.64-9.76A2.4 2.4 0 1 1 25.68 27l5.64 9.77a2.39 2.39 0 0 1-.88 3.23Zm10.86 0a2.42 2.42 0 0 1-3.3-.86L25.67 17.75a1.92 1.92 0 0 0-3.33 0L10 39.14a2.42 2.42 0 0 1-2.09 1.2A2.37 2.37 0 0 1 6.7 40a2.4 2.4 0 0 1-.88-3.28l16.1-27.86a2.4 2.4 0 0 1 4.16 0l16.1 27.88A2.4 2.4 0 0 1 41.3 40Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 612 B |
4
packages/icons/src/Icons/ic-evernote.svg
Normal file
4
packages/icons/src/Icons/ic-evernote.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M15.09 11.63s.19-1.28.91-1.28c.76 0 1.78 1.71 1.78 1.71s-2.32-.43-2.69-.43M19 4.69c-.36-.6-2.17-1.28-3.11-1.28H13.5S12.7 2 10.88 2c-1.83 0-1.71.81-1.71 1.5v2.82l-.83.87H4.5s-1.06.72-1.06 2.25c0 1.56.48 6.91 3.69 7.41c3.8.58 4.45-1.18 4.45-1.39c0-.9.02-2.25.02-2.25s1.11 2.12 2.79 2.12s2.65.97 2.65 1.96v1.84S17 20.28 16 20.28h-2.11s-.69-.54-.69-1.28c0-.75.33-.95.73-.95c.39 0 .72.04.72.04v-1.56s-3.18-.03-3.18 2.41c0 2.43 1.66 3.06 2.99 3.06h2.17s3.93-.5 3.93-8.25S19.33 5.28 19 4.69M7.5 6.31H4.26l4.06-4.09V5.5l-.82.81Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 660 B |
4
packages/icons/src/Icons/ic-gkeep.svg
Normal file
4
packages/icons/src/Icons/ic-gkeep.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M4 2h16a2 2 0 0 1 2 2v13.33L17.33 22H4a2 2 0 0 1-2-2V4c0-1.1.9-2 2-2m13 15v3.25L20.25 17H17m-7 2h4v-1h1v-5a5.002 5.002 0 0 0-3-9a5.002 5.002 0 0 0-3 9v5h1v1m4-2h-4v-2h4v2M12 5c2.21 0 4 1.79 4 4c0 1.5-.8 2.77-2 3.46V14h-4v-1.54C8.8 11.77 8 10.5 8 9c0-2.21 1.79-4 4-4Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 406 B |
4
packages/icons/src/Icons/ic-simplenote.svg
Normal file
4
packages/icons/src/Icons/ic-simplenote.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M3.466 3.62c-.004.052-.014.104-.018.158c-.406 4.626 2.747 8.548 8.03 9.994c2.024.553 5.374 2.018 5.06 5.599a5.063 5.063 0 0 1-1.803 3.46c-1.022.857-2.308 1.21-3.64 1.166C5.147 23.794 0 18.367 0 12.05a11.95 11.95 0 0 1 3.467-8.428zM9.82 1.032C10.727.27 11.876-.046 13.055.005C18.996.27 24 5.67 24 11.936a11.94 11.94 0 0 1-2.667 7.536c.332-4.908-2.94-8.897-8.59-10.441c-2.337-.64-4.749-2.274-4.514-4.948A4.467 4.467 0 0 1 9.82 1.03z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 570 B |
@@ -201,6 +201,10 @@ import UserSwitch from './ic-user-switch.svg'
|
|||||||
import ViewIcon from './ic-view.svg'
|
import ViewIcon from './ic-view.svg'
|
||||||
import WarningIcon from './ic-warning.svg'
|
import WarningIcon from './ic-warning.svg'
|
||||||
import WindowIcon from './ic-window.svg'
|
import WindowIcon from './ic-window.svg'
|
||||||
|
import EvernoteIcon from './ic-evernote.svg'
|
||||||
|
import GoogleKeepIcon from './ic-gkeep.svg'
|
||||||
|
import SimplenoteIcon from './ic-simplenote.svg'
|
||||||
|
import AegisIcon from './ic-aegis.svg'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AccessibilityIcon,
|
AccessibilityIcon,
|
||||||
@@ -406,4 +410,8 @@ export {
|
|||||||
ViewIcon,
|
ViewIcon,
|
||||||
WarningIcon,
|
WarningIcon,
|
||||||
WindowIcon,
|
WindowIcon,
|
||||||
|
EvernoteIcon,
|
||||||
|
GoogleKeepIcon,
|
||||||
|
SimplenoteIcon,
|
||||||
|
AegisIcon,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ContentType } from '@standardnotes/common'
|
|||||||
import { readFileAsText } from '../Utils'
|
import { readFileAsText } from '../Utils'
|
||||||
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
|
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
|
||||||
import { WebApplicationInterface } from '@standardnotes/services'
|
import { WebApplicationInterface } from '@standardnotes/services'
|
||||||
import { Importer } from '../Importer'
|
|
||||||
|
|
||||||
type AegisData = {
|
type AegisData = {
|
||||||
db: {
|
db: {
|
||||||
@@ -18,6 +17,8 @@ type AegisData = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AegisEntryTypes = ['hotp', 'totp', 'steam', 'yandex'] as const
|
||||||
|
|
||||||
type AuthenticatorEntry = {
|
type AuthenticatorEntry = {
|
||||||
service: string
|
service: string
|
||||||
account: string
|
account: string
|
||||||
@@ -25,9 +26,26 @@ type AuthenticatorEntry = {
|
|||||||
notes: string
|
notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AegisToAuthenticatorConverter extends Importer {
|
export class AegisToAuthenticatorConverter {
|
||||||
constructor(protected override application: WebApplicationInterface) {
|
constructor(protected application: WebApplicationInterface) {}
|
||||||
super(application)
|
|
||||||
|
static isValidAegisJson(json: any): boolean {
|
||||||
|
return json.db && json.db.entries && json.db.entries.every((entry: any) => AegisEntryTypes.includes(entry.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
createNoteFromEntries(
|
createNoteFromEntries(
|
||||||
@@ -57,21 +75,6 @@ export class AegisToAuthenticatorConverter extends Importer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
parseEntries(data: string): AuthenticatorEntry[] | null {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(data) as AegisData
|
const json = JSON.parse(data) as AegisData
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ContentType } from '@standardnotes/common'
|
|||||||
import { WebApplicationInterface } from '@standardnotes/services'
|
import { WebApplicationInterface } from '@standardnotes/services'
|
||||||
import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models'
|
import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models'
|
||||||
import { readFileAsText } from '../Utils'
|
import { readFileAsText } from '../Utils'
|
||||||
import { Importer } from '../Importer'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||||
import utc from 'dayjs/plugin/utc'
|
import utc from 'dayjs/plugin/utc'
|
||||||
@@ -11,10 +10,8 @@ dayjs.extend(utc)
|
|||||||
|
|
||||||
const dateFormat = 'YYYYMMDDTHHmmss'
|
const dateFormat = 'YYYYMMDDTHHmmss'
|
||||||
|
|
||||||
export class EvernoteConverter extends Importer {
|
export class EvernoteConverter {
|
||||||
constructor(protected override application: WebApplicationInterface) {
|
constructor(protected application: WebApplicationInterface) {}
|
||||||
super(application)
|
|
||||||
}
|
|
||||||
|
|
||||||
async convertENEXFileToNotesAndTags(file: File, stripHTML: boolean): Promise<DecryptedTransferPayload[]> {
|
async convertENEXFileToNotesAndTags(file: File, stripHTML: boolean): Promise<DecryptedTransferPayload[]> {
|
||||||
const content = await readFileAsText(file)
|
const content = await readFileAsText(file)
|
||||||
@@ -138,6 +135,9 @@ export class EvernoteConverter extends Importer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allItems: DecryptedTransferPayload[] = [...notes, ...tags]
|
const allItems: DecryptedTransferPayload[] = [...notes, ...tags]
|
||||||
|
if (allItems.length === 0) {
|
||||||
|
throw new Error('Could not parse any notes or tags from Evernote file.')
|
||||||
|
}
|
||||||
if (defaultTag) {
|
if (defaultTag) {
|
||||||
allItems.push(defaultTag)
|
allItems.push(defaultTag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { WebApplicationInterface } from '@standardnotes/services'
|
|||||||
import { ContentType } from '@standardnotes/common'
|
import { ContentType } from '@standardnotes/common'
|
||||||
import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models'
|
import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models'
|
||||||
import { readFileAsText } from '../Utils'
|
import { readFileAsText } from '../Utils'
|
||||||
import { Importer } from '../Importer'
|
|
||||||
|
|
||||||
type GoogleKeepJsonNote = {
|
type GoogleKeepJsonNote = {
|
||||||
color: string
|
color: string
|
||||||
@@ -14,10 +13,8 @@ type GoogleKeepJsonNote = {
|
|||||||
userEditedTimestampUsec: number
|
userEditedTimestampUsec: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoogleKeepConverter extends Importer {
|
export class GoogleKeepConverter {
|
||||||
constructor(protected override application: WebApplicationInterface) {
|
constructor(protected application: WebApplicationInterface) {}
|
||||||
super(application)
|
|
||||||
}
|
|
||||||
|
|
||||||
async convertGoogleKeepBackupFileToNote(
|
async convertGoogleKeepBackupFileToNote(
|
||||||
file: File,
|
file: File,
|
||||||
@@ -99,9 +96,24 @@ export class GoogleKeepConverter extends Importer {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isValidGoogleKeepJson(json: any): boolean {
|
||||||
|
return (
|
||||||
|
json.title &&
|
||||||
|
json.textContent &&
|
||||||
|
json.userEditedTimestampUsec &&
|
||||||
|
typeof json.isArchived === 'boolean' &&
|
||||||
|
typeof json.isTrashed === 'boolean' &&
|
||||||
|
typeof json.isPinned === 'boolean' &&
|
||||||
|
json.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
tryParseAsJson(data: string): DecryptedTransferPayload<NoteContent> | null {
|
tryParseAsJson(data: string): DecryptedTransferPayload<NoteContent> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as GoogleKeepJsonNote
|
const parsed = JSON.parse(data) as GoogleKeepJsonNote
|
||||||
|
if (!GoogleKeepConverter.isValidGoogleKeepJson(parsed)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const date = new Date(parsed.userEditedTimestampUsec / 1000)
|
const date = new Date(parsed.userEditedTimestampUsec / 1000)
|
||||||
return {
|
return {
|
||||||
created_at: date,
|
created_at: date,
|
||||||
|
|||||||
@@ -1,14 +1,91 @@
|
|||||||
import { WebApplicationInterface } from '@standardnotes/services'
|
import { parseFileName } from '@standardnotes/filepicker'
|
||||||
import { DecryptedTransferPayload } from '@standardnotes/snjs'
|
import { FeatureStatus, WebApplicationInterface } from '@standardnotes/services'
|
||||||
|
import { FeatureIdentifier } from '@standardnotes/features'
|
||||||
|
import { AegisToAuthenticatorConverter } from './AegisConverter/AegisToAuthenticatorConverter'
|
||||||
|
import { EvernoteConverter } from './EvernoteConverter/EvernoteConverter'
|
||||||
|
import { GoogleKeepConverter } from './GoogleKeepConverter/GoogleKeepConverter'
|
||||||
|
import { PlaintextConverter } from './PlaintextConverter/PlaintextConverter'
|
||||||
|
import { SimplenoteConverter } from './SimplenoteConverter/SimplenoteConverter'
|
||||||
|
import { readFileAsText } from './Utils'
|
||||||
|
import { DecryptedTransferPayload } from '@standardnotes/models'
|
||||||
|
|
||||||
|
export type NoteImportType = 'plaintext' | 'evernote' | 'google-keep' | 'simplenote' | 'aegis'
|
||||||
|
|
||||||
export class Importer {
|
export class Importer {
|
||||||
constructor(protected application: WebApplicationInterface) {}
|
aegisConverter: AegisToAuthenticatorConverter
|
||||||
|
googleKeepConverter: GoogleKeepConverter
|
||||||
|
simplenoteConverter: SimplenoteConverter
|
||||||
|
plaintextConverter: PlaintextConverter
|
||||||
|
evernoteConverter: EvernoteConverter
|
||||||
|
|
||||||
async importFromTransferPayloads(payloads: DecryptedTransferPayload[]): Promise<void> {
|
constructor(protected application: WebApplicationInterface) {
|
||||||
for (const payload of payloads) {
|
this.aegisConverter = new AegisToAuthenticatorConverter(application)
|
||||||
const itemPayload = this.application.items.createPayloadFromObject(payload)
|
this.googleKeepConverter = new GoogleKeepConverter(application)
|
||||||
const item = this.application.items.createItemFromPayload(itemPayload)
|
this.simplenoteConverter = new SimplenoteConverter(application)
|
||||||
await this.application.mutator.insertItem(item)
|
this.plaintextConverter = new PlaintextConverter(application)
|
||||||
|
this.evernoteConverter = new EvernoteConverter(application)
|
||||||
|
}
|
||||||
|
|
||||||
|
static detectService = async (file: File): Promise<NoteImportType | null> => {
|
||||||
|
const content = await readFileAsText(file)
|
||||||
|
|
||||||
|
const { ext } = parseFileName(file.name)
|
||||||
|
|
||||||
|
if (ext === 'enex') {
|
||||||
|
return 'evernote'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(content)
|
||||||
|
|
||||||
|
if (AegisToAuthenticatorConverter.isValidAegisJson(json)) {
|
||||||
|
return 'aegis'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GoogleKeepConverter.isValidGoogleKeepJson(json)) {
|
||||||
|
return 'google-keep'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SimplenoteConverter.isValidSimplenoteJson(json)) {
|
||||||
|
return 'simplenote'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PlaintextConverter.isValidPlaintextFile(file)) {
|
||||||
|
return 'plaintext'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPayloadsFromFile(file: File, type: NoteImportType): Promise<DecryptedTransferPayload[]> {
|
||||||
|
if (type === 'aegis') {
|
||||||
|
const isEntitledToAuthenticator =
|
||||||
|
this.application.features.getFeatureStatus(FeatureIdentifier.TokenVaultEditor) === FeatureStatus.Entitled
|
||||||
|
return [await this.aegisConverter.convertAegisBackupFileToNote(file, isEntitledToAuthenticator)]
|
||||||
|
} else if (type === 'google-keep') {
|
||||||
|
return [await this.googleKeepConverter.convertGoogleKeepBackupFileToNote(file, true)]
|
||||||
|
} else if (type === 'simplenote') {
|
||||||
|
return await this.simplenoteConverter.convertSimplenoteBackupFileToNotes(file)
|
||||||
|
} else if (type === 'evernote') {
|
||||||
|
return await this.evernoteConverter.convertENEXFileToNotesAndTags(file, false)
|
||||||
|
} else if (type === 'plaintext') {
|
||||||
|
return [await this.plaintextConverter.convertPlaintextFileToNote(file)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFromTransferPayloads(payloads: DecryptedTransferPayload[]) {
|
||||||
|
const insertedItems = await Promise.all(
|
||||||
|
payloads.map(async (payload) => {
|
||||||
|
const itemPayload = this.application.items.createPayloadFromObject(payload)
|
||||||
|
const item = this.application.items.createItemFromPayload(itemPayload)
|
||||||
|
return this.application.mutator.insertItem(item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return insertedItems
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ContentType } from '@standardnotes/common'
|
||||||
|
import { parseFileName } from '@standardnotes/filepicker'
|
||||||
|
import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models'
|
||||||
|
import { WebApplicationInterface } from '@standardnotes/services'
|
||||||
|
import { readFileAsText } from '../Utils'
|
||||||
|
|
||||||
|
export class PlaintextConverter {
|
||||||
|
constructor(protected application: WebApplicationInterface) {}
|
||||||
|
|
||||||
|
static isValidPlaintextFile(file: File): boolean {
|
||||||
|
return file.type === 'text/plain' || file.type === 'text/markdown'
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertPlaintextFileToNote(file: File): Promise<DecryptedTransferPayload<NoteContent>> {
|
||||||
|
const content = await readFileAsText(file)
|
||||||
|
|
||||||
|
const { name } = parseFileName(file.name)
|
||||||
|
|
||||||
|
const createdAtDate = file.lastModified ? new Date(file.lastModified) : new Date()
|
||||||
|
const updatedAtDate = file.lastModified ? new Date(file.lastModified) : new Date()
|
||||||
|
|
||||||
|
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: name,
|
||||||
|
text: content,
|
||||||
|
references: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models'
|
|||||||
import { ContentType } from '@standardnotes/common'
|
import { ContentType } from '@standardnotes/common'
|
||||||
import { readFileAsText } from '../Utils'
|
import { readFileAsText } from '../Utils'
|
||||||
import { WebApplicationInterface } from '@standardnotes/services'
|
import { WebApplicationInterface } from '@standardnotes/services'
|
||||||
import { Importer } from '../Importer'
|
|
||||||
|
|
||||||
type SimplenoteItem = {
|
type SimplenoteItem = {
|
||||||
creationDate: string
|
creationDate: string
|
||||||
@@ -15,9 +14,28 @@ type SimplenoteData = {
|
|||||||
trashedNotes: SimplenoteItem[]
|
trashedNotes: SimplenoteItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SimplenoteConverter extends Importer {
|
const isSimplenoteEntry = (entry: any): boolean => entry.id && entry.content && entry.creationDate && entry.lastModified
|
||||||
constructor(protected override application: WebApplicationInterface) {
|
|
||||||
super(application)
|
export class SimplenoteConverter {
|
||||||
|
constructor(protected application: WebApplicationInterface) {}
|
||||||
|
|
||||||
|
static isValidSimplenoteJson(json: any): boolean {
|
||||||
|
return (
|
||||||
|
(json.activeNotes && json.activeNotes.every(isSimplenoteEntry)) ||
|
||||||
|
(json.trashedNotes && json.trashedNotes.every(isSimplenoteEntry))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
createNoteFromItem(item: SimplenoteItem, trashed: boolean): DecryptedTransferPayload<NoteContent> {
|
createNoteFromItem(item: SimplenoteItem, trashed: boolean): DecryptedTransferPayload<NoteContent> {
|
||||||
@@ -51,18 +69,6 @@ export class SimplenoteConverter extends Importer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
parse(data: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as SimplenoteData
|
const parsed = JSON.parse(data) as SimplenoteData
|
||||||
|
|||||||
7
packages/ui-services/src/Import/index.ts
Normal file
7
packages/ui-services/src/Import/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './AegisConverter/AegisToAuthenticatorConverter'
|
||||||
|
export * from './SimplenoteConverter/SimplenoteConverter'
|
||||||
|
export * from './GoogleKeepConverter/GoogleKeepConverter'
|
||||||
|
export * from './EvernoteConverter/EvernoteConverter'
|
||||||
|
export * from './PlaintextConverter/PlaintextConverter'
|
||||||
|
export * from './Utils'
|
||||||
|
export * from './Importer'
|
||||||
@@ -32,7 +32,4 @@ 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'
|
||||||
export * from './Import/SimplenoteConverter/SimplenoteConverter'
|
|
||||||
export * from './Import/GoogleKeepConverter/GoogleKeepConverter'
|
|
||||||
export * from './Import/EvernoteConverter/EvernoteConverter'
|
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ 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
|
||||||
@@ -189,7 +187,10 @@ 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 />}
|
<MenuItem onClick={() => viewControllerManager.isImportModalVisible.set(true)}>
|
||||||
|
<Icon type="archive" className={iconClassName} />
|
||||||
|
Import
|
||||||
|
</MenuItem>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<MenuItemSeparator />
|
<MenuItemSeparator />
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -28,6 +28,7 @@ import CommandProvider from '../CommandProvider'
|
|||||||
import PanesSystemComponent from '../Panes/PanesSystemComponent'
|
import PanesSystemComponent from '../Panes/PanesSystemComponent'
|
||||||
import DotOrgNotice from './DotOrgNotice'
|
import DotOrgNotice from './DotOrgNotice'
|
||||||
import LinkingControllerProvider from '@/Controllers/LinkingControllerProvider'
|
import LinkingControllerProvider from '@/Controllers/LinkingControllerProvider'
|
||||||
|
import ImportModal from '../ImportModal/ImportModal'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -229,6 +230,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
|||||||
application={application}
|
application={application}
|
||||||
viewControllerManager={viewControllerManager}
|
viewControllerManager={viewControllerManager}
|
||||||
/>
|
/>
|
||||||
|
<ImportModal viewControllerManager={viewControllerManager} />
|
||||||
</>
|
</>
|
||||||
{application.routeService.isDotOrg && <DotOrgNotice />}
|
{application.routeService.isDotOrg && <DotOrgNotice />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { StreamingFileReader } from '@standardnotes/filepicker'
|
|||||||
import { FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext, memo } from 'react'
|
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext, memo } from 'react'
|
||||||
import Portal from './Portal/Portal'
|
import Portal from './Portal/Portal'
|
||||||
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
|
|
||||||
type FileDragTargetData = {
|
type FileDragTargetData = {
|
||||||
tooltipText: string
|
tooltipText: string
|
||||||
@@ -221,18 +222,24 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('dragstart', handleDragStart)
|
const appGroupRoot = document.getElementById(ElementIds.RootId)
|
||||||
window.addEventListener('dragenter', handleDragIn)
|
|
||||||
window.addEventListener('dragleave', handleDragOut)
|
if (!appGroupRoot) {
|
||||||
window.addEventListener('dragover', handleDrag)
|
return
|
||||||
window.addEventListener('drop', handleDrop)
|
}
|
||||||
|
|
||||||
|
appGroupRoot.addEventListener('dragstart', handleDragStart)
|
||||||
|
appGroupRoot.addEventListener('dragenter', handleDragIn)
|
||||||
|
appGroupRoot.addEventListener('dragleave', handleDragOut)
|
||||||
|
appGroupRoot.addEventListener('dragover', handleDrag)
|
||||||
|
appGroupRoot.addEventListener('drop', handleDrop)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('dragstart', handleDragStart)
|
appGroupRoot.removeEventListener('dragstart', handleDragStart)
|
||||||
window.removeEventListener('dragenter', handleDragIn)
|
appGroupRoot.removeEventListener('dragenter', handleDragIn)
|
||||||
window.removeEventListener('dragleave', handleDragOut)
|
appGroupRoot.removeEventListener('dragleave', handleDragOut)
|
||||||
window.removeEventListener('dragover', handleDrag)
|
appGroupRoot.removeEventListener('dragover', handleDrag)
|
||||||
window.removeEventListener('drop', handleDrop)
|
appGroupRoot.removeEventListener('drop', handleDrop)
|
||||||
}
|
}
|
||||||
}, [handleDragIn, handleDrop, handleDrag, handleDragOut, handleDragStart])
|
}, [handleDragIn, handleDrop, handleDrag, handleDragOut, handleDragStart])
|
||||||
|
|
||||||
|
|||||||
@@ -116,4 +116,8 @@ export const IconNameToSvgMapping = {
|
|||||||
view: icons.ViewIcon,
|
view: icons.ViewIcon,
|
||||||
warning: icons.WarningIcon,
|
warning: icons.WarningIcon,
|
||||||
window: icons.WindowIcon,
|
window: icons.WindowIcon,
|
||||||
|
evernote: icons.EvernoteIcon,
|
||||||
|
gkeep: icons.GoogleKeepIcon,
|
||||||
|
simplenote: icons.SimplenoteIcon,
|
||||||
|
aegis: icons.AegisIcon,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||||
|
import { ContentType, DecryptedTransferPayload, pluralize, SNTag, TagContent, UuidGenerator } from '@standardnotes/snjs'
|
||||||
|
import { Importer } from '@standardnotes/ui-services'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useCallback, useReducer, useState } from 'react'
|
||||||
|
import { useApplication } from '../ApplicationProvider'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import { useStateRef } from '../Panes/useStateRef'
|
||||||
|
import ModalDialog from '../Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '../Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '../Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '../Shared/ModalDialogLabel'
|
||||||
|
import { ImportModalFileItem } from './ImportModalFileItem'
|
||||||
|
import ImportModalInitialPage from './InitialPage'
|
||||||
|
import { ImportModalAction, ImportModalFile, ImportModalState } from './Types'
|
||||||
|
|
||||||
|
const reducer = (state: ImportModalState, action: ImportModalAction): ImportModalState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'setFiles':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: action.files.map((file) => ({
|
||||||
|
id: UuidGenerator.GenerateUuid(),
|
||||||
|
file,
|
||||||
|
status: action.service ? 'ready' : 'pending',
|
||||||
|
service: action.service,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
case 'updateFile':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: state.files.map((file) => {
|
||||||
|
if (file.file.name === action.file.file.name) {
|
||||||
|
return action.file
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case 'removeFile':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: state.files.filter((file) => file.id !== action.id),
|
||||||
|
}
|
||||||
|
case 'setImportTag':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
importTag: action.tag,
|
||||||
|
}
|
||||||
|
case 'clearState':
|
||||||
|
return {
|
||||||
|
files: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ImportModalState = {
|
||||||
|
files: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportModal = ({ viewControllerManager }: { viewControllerManager: ViewControllerManager }) => {
|
||||||
|
const application = useApplication()
|
||||||
|
const [importer] = useState(() => new Importer(application))
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState)
|
||||||
|
const { files } = state
|
||||||
|
const filesRef = useStateRef(files)
|
||||||
|
|
||||||
|
const importFromPayloads = useCallback(
|
||||||
|
async (file: ImportModalFile, payloads: DecryptedTransferPayload[]) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'updateFile',
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
status: 'importing',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await importer.importFromTransferPayloads(payloads)
|
||||||
|
|
||||||
|
const notesImported = payloads.filter((payload) => payload.content_type === ContentType.Note)
|
||||||
|
const tagsImported = payloads.filter((payload) => payload.content_type === ContentType.Tag)
|
||||||
|
|
||||||
|
const successMessage =
|
||||||
|
`Successfully imported ${notesImported.length} ` +
|
||||||
|
pluralize(notesImported.length, 'note', 'notes') +
|
||||||
|
(tagsImported.length > 0
|
||||||
|
? ` and ${tagsImported.length} ${pluralize(tagsImported.length, 'tag', 'tags')}`
|
||||||
|
: '')
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'updateFile',
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
status: 'success',
|
||||||
|
successMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: 'updateFile',
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
status: 'error',
|
||||||
|
error: error instanceof Error ? error : new Error('Could not import file'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[importer],
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseAndImport = useCallback(async () => {
|
||||||
|
const files = filesRef.current
|
||||||
|
if (files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const importedPayloads: DecryptedTransferPayload[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.service) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.status === 'ready' && file.payloads) {
|
||||||
|
await importFromPayloads(file, file.payloads)
|
||||||
|
importedPayloads.push(...file.payloads)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'updateFile',
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
status: 'parsing',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payloads = await importer.getPayloadsFromFile(file.file, file.service)
|
||||||
|
await importFromPayloads(file, payloads)
|
||||||
|
importedPayloads.push(...payloads)
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: 'updateFile',
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
status: 'error',
|
||||||
|
error: error instanceof Error ? error : new Error('Could not import file'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentDate = new Date()
|
||||||
|
const importTagPayload: DecryptedTransferPayload<TagContent> = {
|
||||||
|
uuid: UuidGenerator.GenerateUuid(),
|
||||||
|
created_at: currentDate,
|
||||||
|
created_at_timestamp: currentDate.getTime(),
|
||||||
|
updated_at: currentDate,
|
||||||
|
updated_at_timestamp: currentDate.getTime(),
|
||||||
|
content_type: ContentType.Tag,
|
||||||
|
content: {
|
||||||
|
title: `Imported on ${currentDate.toLocaleString()}`,
|
||||||
|
expanded: false,
|
||||||
|
iconString: '',
|
||||||
|
references: importedPayloads
|
||||||
|
.filter((payload) => payload.content_type === ContentType.Note)
|
||||||
|
.map((payload) => ({
|
||||||
|
content_type: ContentType.Note,
|
||||||
|
uuid: payload.uuid,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const [importTag] = await importer.importFromTransferPayloads([importTagPayload])
|
||||||
|
if (importTag) {
|
||||||
|
dispatch({
|
||||||
|
type: 'setImportTag',
|
||||||
|
tag: importTag as SNTag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [filesRef, importFromPayloads, importer])
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
viewControllerManager.isImportModalVisible.set(false)
|
||||||
|
if (state.importTag) {
|
||||||
|
void viewControllerManager.navigationController.setSelectedTag(state.importTag, 'all', {
|
||||||
|
userTriggered: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: 'clearState',
|
||||||
|
})
|
||||||
|
}, [state.importTag, viewControllerManager.isImportModalVisible, viewControllerManager.navigationController])
|
||||||
|
|
||||||
|
if (!viewControllerManager.isImportModalVisible.get()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={closeDialog}>Import</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
{!files.length && <ImportModalInitialPage dispatch={dispatch} />}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{files.map((file) => (
|
||||||
|
<ImportModalFileItem file={file} key={file.id} dispatch={dispatch} importer={importer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
{files.length > 0 && files.every((file) => file.status === 'ready') && (
|
||||||
|
<Button primary onClick={parseAndImport}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={closeDialog}>
|
||||||
|
{files.length > 0 && files.every((file) => file.status === 'success' || file.status === 'error')
|
||||||
|
? 'Close'
|
||||||
|
: 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ImportModal)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { classNames, ContentType, DecryptedTransferPayload, pluralize } from '@standardnotes/snjs'
|
||||||
|
import { Importer, NoteImportType } from '@standardnotes/ui-services'
|
||||||
|
import { Dispatch, useCallback, useEffect } from 'react'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import { ImportModalAction, ImportModalFile } from './Types'
|
||||||
|
|
||||||
|
const NoteImportTypeColors: Record<NoteImportType, string> = {
|
||||||
|
evernote: 'bg-[#14cc45] text-[#000]',
|
||||||
|
simplenote: 'bg-[#3360cc] text-default',
|
||||||
|
'google-keep': 'bg-[#fbbd00] text-[#000]',
|
||||||
|
aegis: 'bg-[#0d47a1] text-default',
|
||||||
|
plaintext: 'bg-default border border-border',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoteImportTypeIcons: Record<NoteImportType, string> = {
|
||||||
|
evernote: 'evernote',
|
||||||
|
simplenote: 'simplenote',
|
||||||
|
'google-keep': 'gkeep',
|
||||||
|
aegis: 'aegis',
|
||||||
|
plaintext: 'plain-text',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportModalFileItem = ({
|
||||||
|
file,
|
||||||
|
dispatch,
|
||||||
|
importer,
|
||||||
|
}: {
|
||||||
|
file: ImportModalFile
|
||||||
|
dispatch: Dispatch<ImportModalAction>
|
||||||
|
importer: Importer
|
||||||
|
}) => {
|
||||||
|
const setFileService = useCallback(
|
||||||
|
async (service: NoteImportType | null) => {
|
||||||
|
let payloads: DecryptedTransferPayload[] | undefined
|
||||||
|
try {
|
||||||
|
payloads = service ? await importer.getPayloadsFromFile(file.file, service) : undefined
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'updateFile',
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
service,
|
||||||
|
status: service ? 'ready' : 'pending',
|
||||||
|
payloads,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[dispatch, file, importer],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const detect = async () => {
|
||||||
|
const detectedService = await Importer.detectService(file.file)
|
||||||
|
void setFileService(detectedService)
|
||||||
|
}
|
||||||
|
if (file.service === undefined) {
|
||||||
|
void detect()
|
||||||
|
}
|
||||||
|
}, [dispatch, file, setFileService])
|
||||||
|
|
||||||
|
const notePayloads =
|
||||||
|
file.status === 'ready' && file.payloads
|
||||||
|
? file.payloads.filter((payload) => payload.content_type === ContentType.Note)
|
||||||
|
: []
|
||||||
|
const tagPayloads =
|
||||||
|
file.status === 'ready' && file.payloads
|
||||||
|
? file.payloads.filter((payload) => payload.content_type === ContentType.Tag)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const payloadsImportMessage =
|
||||||
|
`Ready to import ${notePayloads.length} ` +
|
||||||
|
pluralize(notePayloads.length, 'note', 'notes') +
|
||||||
|
(tagPayloads.length > 0 ? ` and ${tagPayloads.length} ${pluralize(tagPayloads.length, 'tag', 'tags')}` : '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'flex gap-2 py-2 px-2',
|
||||||
|
file.service == null ? 'flex-col items-start md:flex-row md:items-center' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mr-auto flex items-center">
|
||||||
|
{file.service && (
|
||||||
|
<div className={classNames('mr-4 rounded p-2', NoteImportTypeColors[file.service])}>
|
||||||
|
<Icon type={NoteImportTypeIcons[file.service]} size="medium" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div>{file.file.name}</div>
|
||||||
|
<div className="text-xs opacity-75">
|
||||||
|
{file.status === 'ready'
|
||||||
|
? notePayloads.length > 1 || tagPayloads.length
|
||||||
|
? payloadsImportMessage
|
||||||
|
: 'Ready to import'
|
||||||
|
: null}
|
||||||
|
{file.status === 'pending' && 'Could not auto-detect service. Please select manually.'}
|
||||||
|
{file.status === 'parsing' && 'Parsing...'}
|
||||||
|
{file.status === 'importing' && 'Importing...'}
|
||||||
|
{file.status === 'error' && `${file.error}`}
|
||||||
|
{file.status === 'success' && file.successMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{file.service == null && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<form
|
||||||
|
className="flex items-center"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target as HTMLFormElement
|
||||||
|
const service = form.elements[0] as HTMLSelectElement
|
||||||
|
void setFileService(service.value as NoteImportType)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select className="mr-2 rounded border border-border bg-default px-2 py-1 text-sm">
|
||||||
|
<option value="evernote">Evernote</option>
|
||||||
|
<option value="simplenote">Simplenote</option>
|
||||||
|
<option value="google-keep">Google Keep</option>
|
||||||
|
<option value="aegis">Aegis</option>
|
||||||
|
<option value="plaintext">Plaintext</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="rounded border border-border bg-default p-1.5 hover:bg-contrast">
|
||||||
|
<Icon type="check" size="medium" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
className="ml-2 rounded border border-border bg-default p-1.5 hover:bg-contrast"
|
||||||
|
onClick={() => {
|
||||||
|
dispatch({
|
||||||
|
type: 'removeFile',
|
||||||
|
id: file.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="close" size="medium" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{file.status === 'success' && <Icon type="check-circle-filled" className="text-success" />}
|
||||||
|
{file.status === 'error' && <Icon type="warning" className="text-danger" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ClassicFileReader } from '@standardnotes/filepicker'
|
||||||
|
import { NoteImportType } from '@standardnotes/ui-services'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import { ImportModalAction } from './Types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dispatch: React.Dispatch<ImportModalAction>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportModalInitialPage = ({ dispatch }: Props) => {
|
||||||
|
const selectFiles = useCallback(
|
||||||
|
async (service?: NoteImportType) => {
|
||||||
|
const files = await ClassicFileReader.selectFiles()
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'setFiles',
|
||||||
|
files,
|
||||||
|
service,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => selectFiles()}
|
||||||
|
className="flex min-h-[30vh] w-full flex-col items-center justify-center gap-2 rounded border-2 border-dashed border-info p-2 hover:border-4"
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const files = Array.from(e.dataTransfer.files)
|
||||||
|
dispatch({
|
||||||
|
type: 'setFiles',
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-semibold">Drag and drop files to auto-detect and import</div>
|
||||||
|
<div className="text-sm">Or click to open file picker</div>
|
||||||
|
</button>
|
||||||
|
<div className="relative mt-6 mb-6 w-full">
|
||||||
|
<hr className="w-full border-border" />
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2 bg-default p-1">
|
||||||
|
or import from:
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<Button
|
||||||
|
className="flex items-center bg-[#14cc45] !py-2 text-[#000]"
|
||||||
|
primary
|
||||||
|
onClick={() => selectFiles('evernote')}
|
||||||
|
>
|
||||||
|
<Icon type="evernote" className="mr-2" />
|
||||||
|
Evernote
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center bg-[#fbbd00] !py-2 text-[#000]"
|
||||||
|
primary
|
||||||
|
onClick={() => selectFiles('google-keep')}
|
||||||
|
>
|
||||||
|
<Icon type="gkeep" className="mr-2" />
|
||||||
|
Google Keep
|
||||||
|
</Button>
|
||||||
|
<Button className="flex items-center bg-[#3360cc] !py-2" primary onClick={() => selectFiles('simplenote')}>
|
||||||
|
<Icon type="simplenote" className="mr-2" />
|
||||||
|
Simplenote
|
||||||
|
</Button>
|
||||||
|
<Button className="flex items-center bg-[#0d47a1] !py-2" primary onClick={() => selectFiles('aegis')}>
|
||||||
|
<Icon type="aegis" className="mr-2" />
|
||||||
|
Aegis Authenticator
|
||||||
|
</Button>
|
||||||
|
<Button className="flex items-center bg-info !py-2" onClick={() => selectFiles('plaintext')} primary>
|
||||||
|
<Icon type="plain-text" className="mr-2" />
|
||||||
|
Plaintext
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center bg-accessory-tint-4 !py-2"
|
||||||
|
primary
|
||||||
|
onClick={() => selectFiles('plaintext')}
|
||||||
|
>
|
||||||
|
<Icon type="markdown" className="mr-2" />
|
||||||
|
Markdown
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportModalInitialPage
|
||||||
30
packages/web/src/javascripts/Components/ImportModal/Types.ts
Normal file
30
packages/web/src/javascripts/Components/ImportModal/Types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { DecryptedTransferPayload, SNTag } from '@standardnotes/models'
|
||||||
|
import { NoteImportType } from '@standardnotes/ui-services'
|
||||||
|
|
||||||
|
type ImportModalFileCommon = {
|
||||||
|
id: string
|
||||||
|
file: File
|
||||||
|
service: NoteImportType | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportModalFile = (
|
||||||
|
| { status: 'pending' }
|
||||||
|
| { status: 'ready'; payloads?: DecryptedTransferPayload[] }
|
||||||
|
| { status: 'parsing' }
|
||||||
|
| { status: 'importing' }
|
||||||
|
| { status: 'success'; successMessage: string }
|
||||||
|
| { status: 'error'; error: Error }
|
||||||
|
) &
|
||||||
|
ImportModalFileCommon
|
||||||
|
|
||||||
|
export type ImportModalState = {
|
||||||
|
files: ImportModalFile[]
|
||||||
|
importTag?: SNTag
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportModalAction =
|
||||||
|
| { type: 'setFiles'; files: File[]; service?: NoteImportType }
|
||||||
|
| { type: 'updateFile'; file: ImportModalFile }
|
||||||
|
| { type: 'removeFile'; id: ImportModalFile['id'] }
|
||||||
|
| { type: 'setImportTag'; tag: SNTag }
|
||||||
|
| { type: 'clearState' }
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
|
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
|
||||||
import {
|
import { useEffect, ReactNode, useMemo, createContext, useCallback, useContext, memo } from 'react'
|
||||||
useEffect,
|
|
||||||
ReactNode,
|
|
||||||
useMemo,
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
memo,
|
|
||||||
useRef,
|
|
||||||
useLayoutEffect,
|
|
||||||
MutableRefObject,
|
|
||||||
} from 'react'
|
|
||||||
import { AppPaneId } from './AppPaneMetadata'
|
import { AppPaneId } from './AppPaneMetadata'
|
||||||
import { PaneController } from '../../Controllers/PaneController/PaneController'
|
import { PaneController } from '../../Controllers/PaneController/PaneController'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
|
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
|
||||||
|
import { useStateRef } from './useStateRef'
|
||||||
|
|
||||||
type ResponsivePaneData = {
|
type ResponsivePaneData = {
|
||||||
selectedPane: AppPaneId
|
selectedPane: AppPaneId
|
||||||
@@ -54,16 +44,6 @@ type ProviderProps = {
|
|||||||
paneController: PaneController
|
paneController: PaneController
|
||||||
} & ChildrenProps
|
} & ChildrenProps
|
||||||
|
|
||||||
function useStateRef<State>(state: State): MutableRefObject<State> {
|
|
||||||
const ref = useRef<State>(state)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
ref.current = state
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
|
|
||||||
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
|
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
|
||||||
|
|
||||||
const ResponsivePaneProvider = ({ paneController, children }: ProviderProps) => {
|
const ResponsivePaneProvider = ({ paneController, children }: ProviderProps) => {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useRef, useLayoutEffect, MutableRefObject } from 'react'
|
||||||
|
|
||||||
|
export function useStateRef<State>(state: State): MutableRefObject<State> {
|
||||||
|
const ref = useRef<State>(state)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
ref.current = state
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return ref
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
|||||||
readonly paneController: PaneController
|
readonly paneController: PaneController
|
||||||
|
|
||||||
public isSessionsModalVisible = false
|
public isSessionsModalVisible = false
|
||||||
|
public isImportModalVisible = observable.box(false)
|
||||||
|
|
||||||
private appEventObserverRemovers: (() => void)[] = []
|
private appEventObserverRemovers: (() => void)[] = []
|
||||||
private eventBus: InternalEventBus
|
private eventBus: InternalEventBus
|
||||||
|
|||||||
Reference in New Issue
Block a user