diff --git a/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx index 7c4b74fb8..74e63ad23 100644 --- a/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx +++ b/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx @@ -11,6 +11,7 @@ import { ChangeEditorOption } from './ChangeEditorOption'; import { BYTES_IN_ONE_MEGABYTE } from '@/constants'; import { ListedActionsOption } from './ListedActionsOption'; import { AddTagOption } from './AddTagOption'; +import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit'; export type NotesOptionsProps = { application: WebApplication; @@ -236,18 +237,39 @@ export const NotesOptions = observer( }; }, [application]); - const downloadSelectedItems = () => { - notes.forEach((note) => { - const editor = application.componentManager.editorForNote(note); - const format = editor?.package_info?.file_type || 'txt'; - const downloadAnchor = document.createElement('a'); - downloadAnchor.setAttribute( - 'href', - 'data:text/plain;charset=utf-8,' + encodeURIComponent(note.text) + const getNoteFileName = (note: SNNote): string => { + const editor = application.componentManager.editorForNote(note); + const format = editor?.package_info?.file_type || 'txt'; + return `${note.title}.${format}`; + }; + + const downloadSelectedItems = async () => { + if (notes.length === 1) { + application + .getArchiveService() + .downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0])); + return; + } + + if (notes.length > 1) { + const loadingToastId = addToast({ + type: ToastType.Loading, + message: `Exporting ${notes.length} notes...`, + }); + await application.getArchiveService().downloadDataAsZip( + notes.map((note) => { + return { + filename: getNoteFileName(note), + content: new Blob([note.text]), + }; + }) ); - downloadAnchor.setAttribute('download', `${note.title}.${format}`); - downloadAnchor.click(); - }); + dismissToast(loadingToastId); + addToast({ + type: ToastType.Success, + message: `Exported ${notes.length} notes`, + }); + } }; const duplicateSelectedItems = () => { diff --git a/app/assets/javascripts/services/archiveManager.ts b/app/assets/javascripts/services/archiveManager.ts index 90fb01ba2..ec5baeeb4 100644 --- a/app/assets/javascripts/services/archiveManager.ts +++ b/app/assets/javascripts/services/archiveManager.ts @@ -1,4 +1,5 @@ import { WebApplication } from '@/ui_models/application'; +import { parseFileName } from '@standardnotes/filepicker'; import { EncryptionIntent, ContentType, @@ -11,13 +12,18 @@ function sanitizeFileName(name: string): string { return name.trim().replace(/[.\\/:"?*|<>]/g, '_'); } -function zippableTxtName(name: string, suffix = ''): string { +function zippableFileName(name: string, suffix = '', format = 'txt'): string { const sanitizedName = sanitizeFileName(name); - const nameEnd = suffix + '.txt'; + const nameEnd = suffix + '.' + format; const maxFileNameLength = 100; return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd; } +type ZippableData = { + filename: string; + content: Blob; +}[]; + export class ArchiveManager { private readonly application: WebApplication; private textFile?: string; @@ -89,7 +95,7 @@ export class ArchiveManager { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'text/plain', }); - const fileName = zippableTxtName( + const fileName = zippableFileName( 'Standard Notes Backup and Import File' ); zipWriter.add(fileName, new this.zip.BlobReader(blob), resolve); @@ -113,7 +119,7 @@ export class ArchiveManager { const blob = new Blob([contents], { type: 'text/plain' }); const fileName = `Items/${sanitizeFileName(item.content_type)}/` + - zippableTxtName(name, `-${item.uuid.split('-')[0]}`); + zippableFileName(name, `-${item.uuid.split('-')[0]}`); zipWriter.add(fileName, new this.zip.BlobReader(blob), () => { index++; if (index < items.length) { @@ -135,6 +141,31 @@ export class ArchiveManager { ); } + async zipData(data: ZippableData): Promise { + const zip = await import('@zip.js/zip.js'); + const writer = new zip.ZipWriter(new zip.BlobWriter('application/zip')); + + for (let i = 0; i < data.length; i++) { + const { name, ext } = parseFileName(data[i].filename); + await writer.add( + zippableFileName(name, '', ext), + new zip.BlobReader(data[i].content) + ); + } + + const zipFileAsBlob = await writer.close(); + + return zipFileAsBlob; + } + + async downloadDataAsZip(data: ZippableData) { + const zipFileAsBlob = await this.zipData(data); + this.downloadData( + zipFileAsBlob, + `Standard Notes Export - ${this.formattedDate()}.zip` + ); + } + private hrefForData(data: Blob) { // If we are replacing a previously generated file we need to // manually revoke the object URL to avoid memory leaks. @@ -146,7 +177,7 @@ export class ArchiveManager { return this.textFile; } - private downloadData(data: Blob, fileName: string) { + downloadData(data: Blob, fileName: string) { const link = document.createElement('a'); link.setAttribute('download', fileName); link.href = this.hrefForData(data); diff --git a/package.json b/package.json index 78aba3447..1dbd4c539 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@standardnotes/settings": "1.12.0", "@standardnotes/sncrypto-web": "1.7.3", "@standardnotes/snjs": "2.82.0", + "@zip.js/zip.js": "^2.4.6", "mobx": "^6.4.2", "mobx-react-lite": "^3.3.0", "preact": "^10.6.6", diff --git a/yarn.lock b/yarn.lock index 68aba0102..8b8459638 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3076,6 +3076,11 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zip.js/zip.js@^2.4.6": + version "2.4.6" + resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.4.6.tgz#eb284910f4dcbccb267c5ef76950fb84ee43bb74" + integrity sha512-gP13tvMy1bhaTWw5I/Sm3mJAOU7J8S18e4sAcscGzYY8NVUF8FRirfY17eYq+rZhRBk8SNg5bFzzWgFR47qSyw== + abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"