diff --git a/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts b/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts index 2f1fa91f5..9e826f67b 100644 --- a/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts +++ b/packages/files/src/Domain/Service/SuperConverterServiceInterface.ts @@ -1,3 +1,5 @@ export interface SuperConverterServiceInterface { - convertString: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string + isValidSuperString(superString: string): boolean + convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string + convertOtherFormatToSuperString: (otherFormatString: string, fromFormat: 'txt' | 'md' | 'html' | 'json') => string } diff --git a/packages/services/src/Domain/Backups/FilesBackupService.ts b/packages/services/src/Domain/Backups/FilesBackupService.ts index 3d994222b..428ea7e58 100644 --- a/packages/services/src/Domain/Backups/FilesBackupService.ts +++ b/packages/services/src/Domain/Backups/FilesBackupService.ts @@ -460,7 +460,10 @@ export class FilesBackupService for (const note of notes) { const tags = this.items.getSortedTagsForItem(note) const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag)) - const text = note.noteType === NoteType.Super ? this.markdownConverter.convertString(note.text, 'md') : note.text + const text = + note.noteType === NoteType.Super + ? this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md') + : note.text await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, text) } diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts index 7b71c2436..4cc57aa1e 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.spec.ts @@ -2,37 +2,56 @@ * @jest-environment jsdom */ -import { jsonTestData, htmlTestData } from './testData' +import { jsonTextContentData, htmlTestData, jsonListContentData } from './testData' import { GoogleKeepConverter } from './GoogleKeepConverter' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { GenerateUuid } from '@standardnotes/services' +import { SuperConverterServiceInterface } from '@standardnotes/snjs' describe('GoogleKeepConverter', () => { const crypto = { generateUUID: () => String(Math.random()), } as unknown as PureCryptoInterface + const superConverterService: SuperConverterServiceInterface = { + isValidSuperString: () => true, + convertOtherFormatToSuperString: (data: string) => data, + convertSuperStringToOtherFormat: (data: string) => data, + } const generateUuid = new GenerateUuid(crypto) it('should parse json data', () => { - const converter = new GoogleKeepConverter(generateUuid) + const converter = new GoogleKeepConverter(superConverterService, generateUuid) - const result = converter.tryParseAsJson(jsonTestData) + const textContent = converter.tryParseAsJson(jsonTextContentData, false) - expect(result).not.toBeNull() - expect(result?.created_at).toBeInstanceOf(Date) - expect(result?.updated_at).toBeInstanceOf(Date) - expect(result?.uuid).not.toBeNull() - expect(result?.content_type).toBe('Note') - expect(result?.content.title).toBe('Testing 1') - expect(result?.content.text).toBe('This is a test.') - expect(result?.content.trashed).toBe(false) - expect(result?.content.archived).toBe(false) - expect(result?.content.pinned).toBe(false) + expect(textContent).not.toBeNull() + expect(textContent?.created_at).toBeInstanceOf(Date) + expect(textContent?.updated_at).toBeInstanceOf(Date) + expect(textContent?.uuid).not.toBeNull() + expect(textContent?.content_type).toBe('Note') + expect(textContent?.content.title).toBe('Testing 1') + expect(textContent?.content.text).toBe('This is a test.') + expect(textContent?.content.trashed).toBe(false) + expect(textContent?.content.archived).toBe(false) + expect(textContent?.content.pinned).toBe(false) + + const listContent = converter.tryParseAsJson(jsonListContentData, false) + + expect(listContent).not.toBeNull() + expect(listContent?.created_at).toBeInstanceOf(Date) + expect(listContent?.updated_at).toBeInstanceOf(Date) + expect(listContent?.uuid).not.toBeNull() + expect(listContent?.content_type).toBe('Note') + expect(listContent?.content.title).toBe('Testing 1') + expect(listContent?.content.text).toBe('- [ ] Test 1\n- [x] Test 2') + expect(textContent?.content.trashed).toBe(false) + expect(textContent?.content.archived).toBe(false) + expect(textContent?.content.pinned).toBe(false) }) it('should parse html data', () => { - const converter = new GoogleKeepConverter(generateUuid) + const converter = new GoogleKeepConverter(superConverterService, generateUuid) const result = converter.tryParseAsHtml( htmlTestData, diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts index 3e0867ec4..3947de55a 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts @@ -2,33 +2,48 @@ import { ContentType } from '@standardnotes/domain-core' import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' import { readFileAsText } from '../Utils' import { GenerateUuid } from '@standardnotes/services' +import { SuperConverterServiceInterface } from '@standardnotes/files' +import { NativeFeatureIdentifier, NoteType } from '@standardnotes/features' + +type Content = + | { + textContent: string + } + | { + listContent: { + text: string + isChecked: boolean + }[] + } type GoogleKeepJsonNote = { color: string isTrashed: boolean isPinned: boolean isArchived: boolean - textContent: string title: string userEditedTimestampUsec: number -} +} & Content export class GoogleKeepConverter { - constructor(private _generateUuid: GenerateUuid) {} + constructor( + private superConverterService: SuperConverterServiceInterface, + private _generateUuid: GenerateUuid, + ) {} async convertGoogleKeepBackupFileToNote( file: File, - stripHtml: boolean, + isEntitledToSuper: boolean, ): Promise> { const content = await readFileAsText(file) - const possiblePayloadFromJson = this.tryParseAsJson(content) + const possiblePayloadFromJson = this.tryParseAsJson(content, isEntitledToSuper) if (possiblePayloadFromJson) { return possiblePayloadFromJson } - const possiblePayloadFromHtml = this.tryParseAsHtml(content, file, stripHtml) + const possiblePayloadFromHtml = this.tryParseAsHtml(content, file, isEntitledToSuper) if (possiblePayloadFromHtml) { return possiblePayloadFromHtml @@ -37,20 +52,51 @@ export class GoogleKeepConverter { throw new Error('Could not parse Google Keep backup file') } - tryParseAsHtml(data: string, file: { name: string }, stripHtml: boolean): DecryptedTransferPayload { + tryParseAsHtml( + data: string, + file: { name: string }, + isEntitledToSuper: boolean, + ): DecryptedTransferPayload { const rootElement = document.createElement('html') rootElement.innerHTML = data + const headingElement = rootElement.getElementsByClassName('heading')[0] + const date = new Date(headingElement?.textContent || '') + headingElement?.remove() + const contentElement = rootElement.getElementsByClassName('content')[0] + if (!contentElement) { + throw new Error('Could not parse content. Content element not found.') + } + let content: string | null - // Replace
with \n so line breaks get recognised - contentElement.innerHTML = contentElement.innerHTML.replace(/
/g, '\n') + // Convert lists to readable plaintext format + // or Super-convertable format + const lists = contentElement.getElementsByTagName('ul') + Array.from(lists).forEach((list) => { + list.setAttribute('__lexicallisttype', 'check') - if (stripHtml) { + const items = list.getElementsByTagName('li') + Array.from(items).forEach((item) => { + const bulletSpan = item.getElementsByClassName('bullet')[0] + bulletSpan?.remove() + + const checked = item.classList.contains('checked') + item.setAttribute('aria-checked', checked ? 'true' : 'false') + + if (!isEntitledToSuper) { + item.textContent = `- ${checked ? '[x]' : '[ ]'} ${item.textContent?.trim()}\n` + } + }) + }) + + if (!isEntitledToSuper) { + // Replace
with \n so line breaks get recognised + contentElement.innerHTML = contentElement.innerHTML.replace(/
/g, '\n') content = contentElement.textContent } else { - content = contentElement.innerHTML + content = this.superConverterService.convertOtherFormatToSuperString(rootElement.innerHTML, 'html') } if (!content) { @@ -59,8 +105,6 @@ export class GoogleKeepConverter { const title = rootElement.getElementsByClassName('title')[0]?.textContent || file.name - const date = this.getDateFromGKeepNote(data) || new Date() - return { created_at: date, created_at_timestamp: date.getTime(), @@ -72,35 +116,30 @@ export class GoogleKeepConverter { title: title, text: content, references: [], + ...(isEntitledToSuper + ? { + noteType: NoteType.Super, + editorIdentifier: NativeFeatureIdentifier.TYPES.SuperEditor, + } + : {}), }, } } - getDateFromGKeepNote(note: string) { - const regexWithTitle = /.*(?=<\/div>\n
)/ - const regexWithoutTitle = /.*(?=<\/div>\n\n
)/ - const possibleDateStringWithTitle = regexWithTitle.exec(note)?.[0] - const possibleDateStringWithoutTitle = regexWithoutTitle.exec(note)?.[0] - if (possibleDateStringWithTitle) { - const date = new Date(possibleDateStringWithTitle) - if (date.toString() !== 'Invalid Date' && date.toString() !== 'NaN') { - return date - } - } - if (possibleDateStringWithoutTitle) { - const date = new Date(possibleDateStringWithoutTitle) - if (date.toString() !== 'Invalid Date' && date.toString() !== 'NaN') { - return date - } - } - return - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any static isValidGoogleKeepJson(json: any): boolean { + if (typeof json.textContent !== 'string') { + if (typeof json.listContent === 'object' && Array.isArray(json.listContent)) { + return json.listContent.every( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => typeof item.text === 'string' && typeof item.isChecked === 'boolean', + ) + } + return false + } + return ( typeof json.title === 'string' && - typeof json.textContent === 'string' && typeof json.userEditedTimestampUsec === 'number' && typeof json.isArchived === 'boolean' && typeof json.isTrashed === 'boolean' && @@ -109,13 +148,26 @@ export class GoogleKeepConverter { ) } - tryParseAsJson(data: string): DecryptedTransferPayload | null { + tryParseAsJson(data: string, isEntitledToSuper: boolean): DecryptedTransferPayload | null { try { const parsed = JSON.parse(data) as GoogleKeepJsonNote if (!GoogleKeepConverter.isValidGoogleKeepJson(parsed)) { return null } const date = new Date(parsed.userEditedTimestampUsec / 1000) + let text: string + if ('textContent' in parsed) { + text = parsed.textContent + } else { + text = parsed.listContent + .map((item) => { + return item.isChecked ? `- [x] ${item.text}` : `- [ ] ${item.text}` + }) + .join('\n') + } + if (isEntitledToSuper) { + text = this.superConverterService.convertOtherFormatToSuperString(text, 'md') + } return { created_at: date, created_at_timestamp: date.getTime(), @@ -125,14 +177,21 @@ export class GoogleKeepConverter { content_type: ContentType.TYPES.Note, content: { title: parsed.title, - text: parsed.textContent, + text, references: [], archived: Boolean(parsed.isArchived), trashed: Boolean(parsed.isTrashed), pinned: Boolean(parsed.isPinned), + ...(isEntitledToSuper + ? { + noteType: NoteType.Super, + editorIdentifier: NativeFeatureIdentifier.TYPES.SuperEditor, + } + : {}), }, } } catch (e) { + console.error(e) return null } } diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts b/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts index 37cb2006c..4ed4fb461 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/testData.ts @@ -1,4 +1,4 @@ -const json = { +const jsonWithTextContent = { color: 'DEFAULT', isTrashed: false, isPinned: false, @@ -8,7 +8,28 @@ const json = { userEditedTimestampUsec: 1618528050144000, } -export const jsonTestData = JSON.stringify(json) +export const jsonTextContentData = JSON.stringify(jsonWithTextContent) + +const jsonWithListContent = { + color: 'DEFAULT', + isTrashed: false, + isPinned: false, + isArchived: false, + listContent: [ + { + text: 'Test 1', + isChecked: false, + }, + { + text: 'Test 2', + isChecked: true, + }, + ], + title: 'Testing 1', + userEditedTimestampUsec: 1618528050144000, +} + +export const jsonListContentData = JSON.stringify(jsonWithListContent) export const htmlTestData = ` diff --git a/packages/ui-services/src/Import/HTMLConverter/HTMLConverter.ts b/packages/ui-services/src/Import/HTMLConverter/HTMLConverter.ts new file mode 100644 index 000000000..044810926 --- /dev/null +++ b/packages/ui-services/src/Import/HTMLConverter/HTMLConverter.ts @@ -0,0 +1,51 @@ +import { ContentType } from '@standardnotes/domain-core' +import { NativeFeatureIdentifier, NoteType } from '@standardnotes/features' +import { parseFileName } from '@standardnotes/filepicker' +import { SuperConverterServiceInterface } from '@standardnotes/files' +import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { GenerateUuid } from '@standardnotes/services' +import { readFileAsText } from '../Utils' + +export class HTMLConverter { + constructor( + private superConverterService: SuperConverterServiceInterface, + private _generateUuid: GenerateUuid, + ) {} + + static isHTMLFile(file: File): boolean { + return file.type === 'text/html' + } + + async convertHTMLFileToNote(file: File, isEntitledToSuper: boolean): Promise> { + 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() + + const text = isEntitledToSuper + ? this.superConverterService.convertOtherFormatToSuperString(content, 'html') + : content + + return { + created_at: createdAtDate, + created_at_timestamp: createdAtDate.getTime(), + updated_at: updatedAtDate, + updated_at_timestamp: updatedAtDate.getTime(), + uuid: this._generateUuid.execute().getValue(), + content_type: ContentType.TYPES.Note, + content: { + title: name, + text, + references: [], + ...(isEntitledToSuper + ? { + noteType: NoteType.Super, + editorIdentifier: NativeFeatureIdentifier.TYPES.SuperEditor, + } + : {}), + }, + } + } +} diff --git a/packages/ui-services/src/Import/Importer.ts b/packages/ui-services/src/Import/Importer.ts index 0f825eb97..7abc9defd 100644 --- a/packages/ui-services/src/Import/Importer.ts +++ b/packages/ui-services/src/Import/Importer.ts @@ -14,8 +14,11 @@ import { PlaintextConverter } from './PlaintextConverter/PlaintextConverter' import { SimplenoteConverter } from './SimplenoteConverter/SimplenoteConverter' import { readFileAsText } from './Utils' import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { HTMLConverter } from './HTMLConverter/HTMLConverter' +import { SuperConverterServiceInterface } from '@standardnotes/snjs/dist/@types' +import { SuperConverter } from './SuperConverter/SuperConverter' -export type NoteImportType = 'plaintext' | 'evernote' | 'google-keep' | 'simplenote' | 'aegis' +export type NoteImportType = 'plaintext' | 'evernote' | 'google-keep' | 'simplenote' | 'aegis' | 'html' | 'super' export class Importer { aegisConverter: AegisToAuthenticatorConverter @@ -23,21 +26,26 @@ export class Importer { simplenoteConverter: SimplenoteConverter plaintextConverter: PlaintextConverter evernoteConverter: EvernoteConverter + htmlConverter: HTMLConverter + superConverter: SuperConverter constructor( private features: FeaturesClientInterface, private mutator: MutatorClientInterface, private items: ItemManagerInterface, + private superConverterService: SuperConverterServiceInterface, _generateUuid: GenerateUuid, ) { this.aegisConverter = new AegisToAuthenticatorConverter(_generateUuid) - this.googleKeepConverter = new GoogleKeepConverter(_generateUuid) + this.googleKeepConverter = new GoogleKeepConverter(this.superConverterService, _generateUuid) this.simplenoteConverter = new SimplenoteConverter(_generateUuid) this.plaintextConverter = new PlaintextConverter(_generateUuid) this.evernoteConverter = new EvernoteConverter(_generateUuid) + this.htmlConverter = new HTMLConverter(this.superConverterService, _generateUuid) + this.superConverter = new SuperConverter(this.superConverterService, _generateUuid) } - static detectService = async (file: File): Promise => { + detectService = async (file: File): Promise => { const content = await readFileAsText(file) const { ext } = parseFileName(file.name) @@ -46,6 +54,10 @@ export class Importer { return 'evernote' } + if (file.type === 'application/json' && this.superConverterService.isValidSuperString(content)) { + return 'super' + } + try { const json = JSON.parse(content) @@ -68,24 +80,39 @@ export class Importer { return 'plaintext' } + if (HTMLConverter.isHTMLFile(file)) { + return 'html' + } + return null } async getPayloadsFromFile(file: File, type: NoteImportType): Promise { - if (type === 'aegis') { + const isEntitledToSuper = + this.features.getFeatureStatus( + NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.SuperEditor).getValue(), + ) === FeatureStatus.Entitled + if (type === 'super') { + if (!isEntitledToSuper) { + throw new Error('Importing Super notes requires a subscription.') + } + return [await this.superConverter.convertSuperFileToNote(file)] + } else if (type === 'aegis') { const isEntitledToAuthenticator = this.features.getFeatureStatus( NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.TokenVaultEditor).getValue(), ) === FeatureStatus.Entitled return [await this.aegisConverter.convertAegisBackupFileToNote(file, isEntitledToAuthenticator)] } else if (type === 'google-keep') { - return [await this.googleKeepConverter.convertGoogleKeepBackupFileToNote(file, true)] + return [await this.googleKeepConverter.convertGoogleKeepBackupFileToNote(file, isEntitledToSuper)] } 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)] + } else if (type === 'html') { + return [await this.htmlConverter.convertHTMLFileToNote(file, isEntitledToSuper)] } return [] diff --git a/packages/ui-services/src/Import/SuperConverter/SuperConverter.ts b/packages/ui-services/src/Import/SuperConverter/SuperConverter.ts new file mode 100644 index 000000000..b505dc6d1 --- /dev/null +++ b/packages/ui-services/src/Import/SuperConverter/SuperConverter.ts @@ -0,0 +1,43 @@ +import { SuperConverterServiceInterface } from '@standardnotes/files' +import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { GenerateUuid } from '@standardnotes/services' +import { readFileAsText } from '../Utils' +import { parseFileName } from '@standardnotes/filepicker' +import { ContentType } from '@standardnotes/domain-core' +import { NativeFeatureIdentifier, NoteType } from '@standardnotes/features' + +export class SuperConverter { + constructor( + private converterService: SuperConverterServiceInterface, + private _generateUuid: GenerateUuid, + ) {} + + async convertSuperFileToNote(file: File): Promise> { + const content = await readFileAsText(file) + + if (!this.converterService.isValidSuperString(content)) { + throw new Error('Content is not valid Super JSON') + } + + 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._generateUuid.execute().getValue(), + content_type: ContentType.TYPES.Note, + content: { + title: name, + text: content, + references: [], + noteType: NoteType.Super, + editorIdentifier: NativeFeatureIdentifier.TYPES.SuperEditor, + }, + } + } +} diff --git a/packages/web/src/javascripts/Application/Dependencies/Types.ts b/packages/web/src/javascripts/Application/Dependencies/Types.ts index 915398a43..746359561 100644 --- a/packages/web/src/javascripts/Application/Dependencies/Types.ts +++ b/packages/web/src/javascripts/Application/Dependencies/Types.ts @@ -7,6 +7,7 @@ export const Web_TYPES = { AutolockService: Symbol.for('AutolockService'), ChangelogService: Symbol.for('ChangelogService'), DesktopManager: Symbol.for('DesktopManager'), + SuperConverter: Symbol.for('SuperConverter'), Importer: Symbol.for('Importer'), ItemGroupController: Symbol.for('ItemGroupController'), KeyboardService: Symbol.for('KeyboardService'), diff --git a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts index 28774417b..da93e90f7 100644 --- a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts +++ b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts @@ -48,13 +48,24 @@ import { PanesForLayout } from '../UseCase/PanesForLayout' import { LoadPurchaseFlowUrl } from '../UseCase/LoadPurchaseFlowUrl' import { GetPurchaseFlowUrl } from '../UseCase/GetPurchaseFlowUrl' import { OpenSubscriptionDashboard } from '../UseCase/OpenSubscriptionDashboard' +import { HeadlessSuperConverter } from '@/Components/SuperEditor/Tools/HeadlessSuperConverter' export class WebDependencies extends DependencyContainer { constructor(private application: WebApplicationInterface) { super() + this.bind(Web_TYPES.SuperConverter, () => { + return new HeadlessSuperConverter() + }) + this.bind(Web_TYPES.Importer, () => { - return new Importer(application.features, application.mutator, application.items, application.generateUuid) + return new Importer( + application.features, + application.mutator, + application.items, + this.get(Web_TYPES.SuperConverter), + application.generateUuid, + ) }) this.bind(Web_TYPES.IsNativeIOS, () => { diff --git a/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx b/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx index ba0878a20..96b690a79 100644 --- a/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx +++ b/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx @@ -11,6 +11,8 @@ const NoteImportTypeColors: Record = { 'google-keep': 'bg-[#fbbd00] text-[#000]', aegis: 'bg-[#0d47a1] text-default', plaintext: 'bg-default border border-border', + html: 'bg-accessory-tint-2', + super: 'bg-accessory-tint-1 text-accessory-tint-1', } const NoteImportTypeIcons: Record = { @@ -19,6 +21,8 @@ const NoteImportTypeIcons: Record = { 'google-keep': 'gkeep', aegis: 'aegis', plaintext: 'plain-text', + html: 'rich-text', + super: 'file-doc', } const ImportModalFileItem = ({ @@ -53,13 +57,13 @@ const ImportModalFileItem = ({ useEffect(() => { const detect = async () => { - const detectedService = await Importer.detectService(file.file) + const detectedService = await importer.detectService(file.file) void setFileService(detectedService) } if (file.service === undefined) { void detect() } - }, [file, setFileService]) + }, [file, importer, setFileService]) const notePayloads = file.status === 'ready' && file.payloads diff --git a/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx b/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx index cb822bf69..1cfa3e26c 100644 --- a/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx +++ b/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx @@ -5,12 +5,17 @@ import { observer } from 'mobx-react-lite' import { useCallback } from 'react' import Button from '../Button/Button' import Icon from '../Icon/Icon' +import { useApplication } from '../ApplicationProvider' +import { FeatureStatus, NativeFeatureIdentifier } from '@standardnotes/snjs' +import { FeatureName } from '@/Controllers/FeatureName' type Props = { setFiles: ImportModalController['setFiles'] } const ImportModalInitialPage = ({ setFiles }: Props) => { + const application = useApplication() + const selectFiles = useCallback( async (service?: NoteImportType) => { const files = await ClassicFileReader.selectFiles() @@ -38,41 +43,46 @@ const ImportModalInitialPage = ({ setFiles }: Props) => {
or import from:
- - - - - +
diff --git a/packages/web/src/javascripts/Components/Modal/ModalOverlay.tsx b/packages/web/src/javascripts/Components/Modal/ModalOverlay.tsx index 681b85594..4ec9cf563 100644 --- a/packages/web/src/javascripts/Components/Modal/ModalOverlay.tsx +++ b/packages/web/src/javascripts/Components/Modal/ModalOverlay.tsx @@ -79,6 +79,7 @@ const ModalOverlay = forwardRef( tabIndex={0} className={classNames( 'z-[1] pointer-events-auto m-0 flex h-full w-full flex-col border-[--popover-border-color] bg-default md:bg-[--popover-background-color] md:[backdrop-filter:var(--popover-backdrop-filter)] p-0 md:h-auto md:max-h-[85vh] md:w-160 md:rounded md:border md:shadow-main', + 'focus-visible:shadow-none focus-visible:outline-none', className, )} backdrop={ diff --git a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx index ec3b76576..6580eed5f 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx @@ -31,14 +31,14 @@ export const DiffView = ({ const firstTitle = firstNote.title const firstText = firstNote.noteType === NoteType.Super && convertSuperToMarkdown - ? new HeadlessSuperConverter().convertString(firstNote.text, 'md') + ? new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md') : firstNote.text const secondNote = selectedNotes[1] const secondTitle = secondNote.title const secondText = secondNote.noteType === NoteType.Super && convertSuperToMarkdown - ? new HeadlessSuperConverter().convertString(secondNote.text, 'md') + ? new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md') : secondNote.text const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true) diff --git a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx index a3b642494..08c054f1e 100644 --- a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx +++ b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx @@ -86,6 +86,7 @@ const StyledTooltip = ({ className={classNames( 'z-tooltip max-w-max rounded border border-border translucent-ui:border-[--popover-border-color] bg-contrast translucent-ui:bg-[--popover-background-color] [backdrop-filter:var(--popover-backdrop-filter)] px-3 py-1.5 text-sm text-foreground shadow', 'opacity-60 [&[data-enter]]:opacity-100 [&[data-leave]]:opacity-60 transition-opacity duration-75', + 'focus-visible:shadow-none focus-visible:outline-none', className, )} updatePosition={() => { diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts index 5003b4ba6..0722f5351 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ExportPlugin/ExportPlugin.ts @@ -40,7 +40,7 @@ export const ExportPlugin = () => { const exportJson = useCallback( (title: string) => { - const content = converter.current.convertString(JSON.stringify(editor.getEditorState()), 'json') + const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'json') const blob = new Blob([content], { type: 'application/json' }) downloadData(blob, `${sanitizeFileName(title)}.json`) }, @@ -49,7 +49,7 @@ export const ExportPlugin = () => { const exportMarkdown = useCallback( (title: string) => { - const content = converter.current.convertString(JSON.stringify(editor.getEditorState()), 'md') + const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'md') const blob = new Blob([content], { type: 'text/markdown' }) downloadData(blob, `${sanitizeFileName(title)}.md`) }, @@ -58,7 +58,7 @@ export const ExportPlugin = () => { const exportHtml = useCallback( (title: string) => { - const content = converter.current.convertString(JSON.stringify(editor.getEditorState()), 'html') + const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'html') const blob = new Blob([content], { type: 'text/html' }) downloadData(blob, `${sanitizeFileName(title)}.html`) }, diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ImportPlugin/ImportPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ImportPlugin/ImportPlugin.tsx index 5c090893f..07272a699 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/ImportPlugin/ImportPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/ImportPlugin/ImportPlugin.tsx @@ -1,10 +1,11 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useEffect } from 'react' -import { $convertFromMarkdownString, TRANSFORMERS } from '@lexical/markdown' +import { $convertFromMarkdownString } from '@lexical/markdown' import { $createParagraphNode, $createRangeSelection, LexicalEditor } from 'lexical' import { handleEditorChange } from '../../Utils' import { SuperNotePreviewCharLimit } from '../../SuperEditor' import { $generateNodesFromDOM } from '@lexical/html' +import { MarkdownTransformers } from '../../MarkdownTransformers' /** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */ export default function ImportPlugin({ @@ -33,7 +34,7 @@ export default function ImportPlugin({ editor.update(() => { if (format === 'md') { - $convertFromMarkdownString(text, [...TRANSFORMERS]) + $convertFromMarkdownString(text, MarkdownTransformers) } else { const parser = new DOMParser() const dom = parser.parseFromString(text, 'text/html') diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx index 0db2f7ddb..305d4d951 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/SuperNoteConverter.tsx @@ -58,7 +58,7 @@ const SuperNoteConverter = ({ } try { - return new HeadlessSuperConverter().convertString(note.text, format) + return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format) } catch (error) { console.error(error) } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx index b3cd5befc..91b416dfb 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Tools/HeadlessSuperConverter.tsx @@ -1,11 +1,19 @@ import { createHeadlessEditor } from '@lexical/headless' -import { $convertToMarkdownString } from '@lexical/markdown' +import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown' import { SuperConverterServiceInterface } from '@standardnotes/snjs' -import { $nodesOfType, LexicalEditor, ParagraphNode } from 'lexical' +import { + $createParagraphNode, + $getRoot, + $insertNodes, + $nodesOfType, + LexicalEditor, + LexicalNode, + ParagraphNode, +} from 'lexical' import BlocksEditorTheme from '../Lexical/Theme/Theme' import { BlockEditorNodes } from '../Lexical/Nodes/AllNodes' import { MarkdownTransformers } from '../MarkdownTransformers' -import { $generateHtmlFromNodes } from '@lexical/html' +import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html' export class HeadlessSuperConverter implements SuperConverterServiceInterface { private editor: LexicalEditor @@ -20,7 +28,16 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { }) } - convertString(superString: string, format: 'txt' | 'md' | 'html' | 'json'): string { + isValidSuperString(superString: string): boolean { + try { + this.editor.parseEditorState(superString) + return true + } catch (error) { + return false + } + } + + convertSuperStringToOtherFormat(superString: string, toFormat: 'txt' | 'md' | 'html' | 'json'): string { if (superString.length === 0) { return superString } @@ -31,7 +48,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { this.editor.update( () => { - switch (format) { + switch (toFormat) { case 'txt': case 'md': { const paragraphs = $nodesOfType(ParagraphNode) @@ -61,4 +78,58 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface { return content } + + convertOtherFormatToSuperString(otherFormatString: string, fromFormat: 'txt' | 'md' | 'html' | 'json'): string { + if (otherFormatString.length === 0) { + return otherFormatString + } + + if (fromFormat === 'json' && this.isValidSuperString(otherFormatString)) { + return otherFormatString + } + + if (fromFormat === 'html') { + this.editor.update( + () => { + const root = $getRoot() + root.clear() + + const parser = new DOMParser() + const dom = parser.parseFromString(otherFormatString, 'text/html') + const generatedNodes = $generateNodesFromDOM(this.editor, dom) + const nodesToInsert: LexicalNode[] = [] + generatedNodes.forEach((node) => { + const type = node.getType() + + // Wrap text & link nodes with paragraph since they can't + // be top-level nodes in Super + if (type === 'text' || type === 'link') { + const paragraphNode = $createParagraphNode() + paragraphNode.append(node) + nodesToInsert.push(paragraphNode) + return + } else { + nodesToInsert.push(node) + } + + nodesToInsert.push($createParagraphNode()) + }) + $getRoot().selectEnd() + $insertNodes(nodesToInsert.concat($createParagraphNode())) + }, + { discrete: true }, + ) + } else { + this.editor.update( + () => { + $convertFromMarkdownString(otherFormatString, MarkdownTransformers) + }, + { + discrete: true, + }, + ) + } + + return JSON.stringify(this.editor.getEditorState()) + } } diff --git a/packages/web/src/javascripts/Controllers/FeatureName.ts b/packages/web/src/javascripts/Controllers/FeatureName.ts index fef41c61c..26c257285 100644 --- a/packages/web/src/javascripts/Controllers/FeatureName.ts +++ b/packages/web/src/javascripts/Controllers/FeatureName.ts @@ -1,3 +1,4 @@ export enum FeatureName { Files = 'Encrypted File Storage', + Super = 'Super notes', } diff --git a/packages/web/src/javascripts/Controllers/ImportModalController.ts b/packages/web/src/javascripts/Controllers/ImportModalController.ts index 8c92acd64..71646df59 100644 --- a/packages/web/src/javascripts/Controllers/ImportModalController.ts +++ b/packages/web/src/javascripts/Controllers/ImportModalController.ts @@ -155,6 +155,9 @@ export class ImportModalController { console.error(error) } } + if (!importedPayloads.length) { + return + } const currentDate = new Date() const importTagItem = this.items.createTemplateItem(ContentType.TYPES.Tag, { title: `Imported on ${currentDate.toLocaleString()}`, diff --git a/packages/web/src/javascripts/Utils/NoteExportUtils.ts b/packages/web/src/javascripts/Utils/NoteExportUtils.ts index 611eb1ee4..eba93a836 100644 --- a/packages/web/src/javascripts/Utils/NoteExportUtils.ts +++ b/packages/web/src/javascripts/Utils/NoteExportUtils.ts @@ -39,7 +39,9 @@ export const getNoteBlob = (application: WebApplicationInterface, note: SNNote) break } const content = - note.noteType === NoteType.Super ? new HeadlessSuperConverter().convertString(note.text, format) : note.text + note.noteType === NoteType.Super + ? new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format) + : note.text const blob = new Blob([content], { type, })