feat: export as zip if multiple notes are selected (#926)
This commit is contained in:
@@ -11,6 +11,7 @@ import { ChangeEditorOption } from './ChangeEditorOption';
|
|||||||
import { BYTES_IN_ONE_MEGABYTE } from '@/constants';
|
import { BYTES_IN_ONE_MEGABYTE } from '@/constants';
|
||||||
import { ListedActionsOption } from './ListedActionsOption';
|
import { ListedActionsOption } from './ListedActionsOption';
|
||||||
import { AddTagOption } from './AddTagOption';
|
import { AddTagOption } from './AddTagOption';
|
||||||
|
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit';
|
||||||
|
|
||||||
export type NotesOptionsProps = {
|
export type NotesOptionsProps = {
|
||||||
application: WebApplication;
|
application: WebApplication;
|
||||||
@@ -236,18 +237,39 @@ export const NotesOptions = observer(
|
|||||||
};
|
};
|
||||||
}, [application]);
|
}, [application]);
|
||||||
|
|
||||||
const downloadSelectedItems = () => {
|
const getNoteFileName = (note: SNNote): string => {
|
||||||
notes.forEach((note) => {
|
const editor = application.componentManager.editorForNote(note);
|
||||||
const editor = application.componentManager.editorForNote(note);
|
const format = editor?.package_info?.file_type || 'txt';
|
||||||
const format = editor?.package_info?.file_type || 'txt';
|
return `${note.title}.${format}`;
|
||||||
const downloadAnchor = document.createElement('a');
|
};
|
||||||
downloadAnchor.setAttribute(
|
|
||||||
'href',
|
const downloadSelectedItems = async () => {
|
||||||
'data:text/plain;charset=utf-8,' + encodeURIComponent(note.text)
|
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}`);
|
dismissToast(loadingToastId);
|
||||||
downloadAnchor.click();
|
addToast({
|
||||||
});
|
type: ToastType.Success,
|
||||||
|
message: `Exported ${notes.length} notes`,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicateSelectedItems = () => {
|
const duplicateSelectedItems = () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WebApplication } from '@/ui_models/application';
|
import { WebApplication } from '@/ui_models/application';
|
||||||
|
import { parseFileName } from '@standardnotes/filepicker';
|
||||||
import {
|
import {
|
||||||
EncryptionIntent,
|
EncryptionIntent,
|
||||||
ContentType,
|
ContentType,
|
||||||
@@ -11,13 +12,18 @@ function sanitizeFileName(name: string): string {
|
|||||||
return name.trim().replace(/[.\\/:"?*|<>]/g, '_');
|
return name.trim().replace(/[.\\/:"?*|<>]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
function zippableTxtName(name: string, suffix = ''): string {
|
function zippableFileName(name: string, suffix = '', format = 'txt'): string {
|
||||||
const sanitizedName = sanitizeFileName(name);
|
const sanitizedName = sanitizeFileName(name);
|
||||||
const nameEnd = suffix + '.txt';
|
const nameEnd = suffix + '.' + format;
|
||||||
const maxFileNameLength = 100;
|
const maxFileNameLength = 100;
|
||||||
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd;
|
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ZippableData = {
|
||||||
|
filename: string;
|
||||||
|
content: Blob;
|
||||||
|
}[];
|
||||||
|
|
||||||
export class ArchiveManager {
|
export class ArchiveManager {
|
||||||
private readonly application: WebApplication;
|
private readonly application: WebApplication;
|
||||||
private textFile?: string;
|
private textFile?: string;
|
||||||
@@ -89,7 +95,7 @@ export class ArchiveManager {
|
|||||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
const fileName = zippableTxtName(
|
const fileName = zippableFileName(
|
||||||
'Standard Notes Backup and Import File'
|
'Standard Notes Backup and Import File'
|
||||||
);
|
);
|
||||||
zipWriter.add(fileName, new this.zip.BlobReader(blob), resolve);
|
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 blob = new Blob([contents], { type: 'text/plain' });
|
||||||
const fileName =
|
const fileName =
|
||||||
`Items/${sanitizeFileName(item.content_type)}/` +
|
`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), () => {
|
zipWriter.add(fileName, new this.zip.BlobReader(blob), () => {
|
||||||
index++;
|
index++;
|
||||||
if (index < items.length) {
|
if (index < items.length) {
|
||||||
@@ -135,6 +141,31 @@ export class ArchiveManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async zipData(data: ZippableData): Promise<Blob> {
|
||||||
|
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) {
|
private hrefForData(data: Blob) {
|
||||||
// If we are replacing a previously generated file we need to
|
// If we are replacing a previously generated file we need to
|
||||||
// manually revoke the object URL to avoid memory leaks.
|
// manually revoke the object URL to avoid memory leaks.
|
||||||
@@ -146,7 +177,7 @@ export class ArchiveManager {
|
|||||||
return this.textFile;
|
return this.textFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private downloadData(data: Blob, fileName: string) {
|
downloadData(data: Blob, fileName: string) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.setAttribute('download', fileName);
|
link.setAttribute('download', fileName);
|
||||||
link.href = this.hrefForData(data);
|
link.href = this.hrefForData(data);
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
"@standardnotes/settings": "1.12.0",
|
"@standardnotes/settings": "1.12.0",
|
||||||
"@standardnotes/sncrypto-web": "1.7.3",
|
"@standardnotes/sncrypto-web": "1.7.3",
|
||||||
"@standardnotes/snjs": "2.82.0",
|
"@standardnotes/snjs": "2.82.0",
|
||||||
|
"@zip.js/zip.js": "^2.4.6",
|
||||||
"mobx": "^6.4.2",
|
"mobx": "^6.4.2",
|
||||||
"mobx-react-lite": "^3.3.0",
|
"mobx-react-lite": "^3.3.0",
|
||||||
"preact": "^10.6.6",
|
"preact": "^10.6.6",
|
||||||
|
|||||||
@@ -3076,6 +3076,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
|
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
|
||||||
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
|
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:
|
abab@^2.0.3, abab@^2.0.5:
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"
|
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"
|
||||||
|
|||||||
Reference in New Issue
Block a user