From 8a475f654f8ab14282115afa57f4e3597ab04edb Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Tue, 23 May 2023 06:18:36 +0500 Subject: [PATCH] web: improve attachments manager ui/ux --- apps/web/__tests__/base64-stream.test.ts | 16 +- apps/web/__tests__/chunked-stream.test.ts | 38 +- apps/web/src/components/attachment/index.js | 250 --------- apps/web/src/components/attachment/index.tsx | 294 ++++++++++ .../components/dialogs/attachments-dialog.js | 81 --- .../components/dialogs/attachments-dialog.tsx | 514 ++++++++++++++++++ apps/web/src/components/dialogs/dialog.tsx | 72 ++- apps/web/src/components/icons/index.tsx | 35 +- .../src/components/list-container/index.tsx | 27 +- .../list-container/list-profiles.tsx | 10 +- .../navigation-menu/navigation-item.tsx | 5 +- apps/web/src/components/properties/index.js | 21 +- apps/web/src/interfaces/chunk-distributor.js | 100 ++++ apps/web/src/interfaces/fs.ts | 10 +- apps/web/src/stores/attachment-store.js | 52 +- .../index.ts} | 0 apps/web/src/utils/{ => stream-saver}/mitm.ts | 0 .../stream-utils.ts => utils/stream.ts} | 0 .../streams}/attachment-stream.ts | 27 +- .../streams}/base64-decoder-stream.ts | 7 +- .../streams}/chunked-stream.ts | 12 +- .../streams}/progress-stream.ts | 0 .../web/src/utils/{ => streams}/zip-stream.ts | 0 packages/core/collections/attachments.js | 22 +- packages/core/database/fs.js | 4 +- packages/core/utils/filename.js | 33 ++ 26 files changed, 1158 insertions(+), 472 deletions(-) delete mode 100644 apps/web/src/components/attachment/index.js create mode 100644 apps/web/src/components/attachment/index.tsx delete mode 100644 apps/web/src/components/dialogs/attachments-dialog.js create mode 100644 apps/web/src/components/dialogs/attachments-dialog.tsx create mode 100644 apps/web/src/interfaces/chunk-distributor.js rename apps/web/src/utils/{stream-saver.ts => stream-saver/index.ts} (100%) rename apps/web/src/utils/{ => stream-saver}/mitm.ts (100%) rename apps/web/src/{interfaces/stream-utils.ts => utils/stream.ts} (100%) rename apps/web/src/{common => utils/streams}/attachment-stream.ts (77%) rename apps/web/src/{interfaces => utils/streams}/base64-decoder-stream.ts (88%) rename apps/web/src/{interfaces => utils/streams}/chunked-stream.ts (86%) rename apps/web/src/{interfaces => utils/streams}/progress-stream.ts (100%) rename apps/web/src/utils/{ => streams}/zip-stream.ts (100%) diff --git a/apps/web/__tests__/base64-stream.test.ts b/apps/web/__tests__/base64-stream.test.ts index d523cbfac..4799aeeda 100644 --- a/apps/web/__tests__/base64-stream.test.ts +++ b/apps/web/__tests__/base64-stream.test.ts @@ -19,15 +19,21 @@ along with this program. If not, see . import "./bootstrap"; import { test } from "vitest"; -import { Base64DecoderStream } from "../src/interfaces/base64-decoder-stream"; -import { consumeReadableStream } from "../src/interfaces/stream-utils"; +import { Base64DecoderStream } from "../src/utils/streams/base64-decoder-stream"; +import { consumeReadableStream } from "../src/utils/stream"; import { createReadStream, readFileSync } from "fs"; import { Readable } from "stream"; +import path from "path"; test("streamed base64 decoder should output same as non-streamed", async (t) => { - const expected = readFileSync(__filename, "base64"); + const expected = readFileSync( + path.join(__dirname, "..", "__e2e__", "data", "importer-data.zip"), + "base64" + ); const fileStream = Readable.toWeb( - createReadStream(__filename) + createReadStream( + path.join(__dirname, "..", "__e2e__", "data", "importer-data.zip") + ) ) as ReadableStream; t.expect( @@ -35,6 +41,6 @@ test("streamed base64 decoder should output same as non-streamed", async (t) => await consumeReadableStream( fileStream.pipeThrough(new Base64DecoderStream("base64")) ) - ).join() + ).join("") ).toBe(expected); }); diff --git a/apps/web/__tests__/chunked-stream.test.ts b/apps/web/__tests__/chunked-stream.test.ts index 83792d959..c2dcf10bf 100644 --- a/apps/web/__tests__/chunked-stream.test.ts +++ b/apps/web/__tests__/chunked-stream.test.ts @@ -19,25 +19,27 @@ along with this program. If not, see . import "./bootstrap"; import { test } from "vitest"; -import { ChunkedStream } from "../src/interfaces/chunked-stream"; -import { toAsyncIterator } from "@notesnook-importer/core/dist/src/utils/stream"; +import { ChunkedStream } from "../src/utils/streams/chunked-stream"; +import { Readable } from "stream"; +import { createReadStream } from "fs"; +import { consumeReadableStream } from "../src/utils/stream"; +import { xxhash64 } from "hash-wasm"; +import path from "path"; +const CHUNK_SIZE = 512 * 1024; test("chunked stream should create equal sized chunks", async (t) => { - const { readable, writable } = new ChunkedStream(512); - const lengths: number[] = []; + const chunks = await consumeReadableStream( + ( + Readable.toWeb( + createReadStream( + path.join(__dirname, "..", "__e2e__", "data", "importer-data.zip") + ) + ) as ReadableStream + ).pipeThrough(new ChunkedStream(CHUNK_SIZE)) + ); - setTimeout(async () => { - for await (const chunk of toAsyncIterator(readable)) { - lengths.push(chunk.length); - } - }); - const writer = writable.getWriter(); - await writer.write(Buffer.alloc(411)); - await writer.write(Buffer.alloc(411)); - await writer.write(Buffer.alloc(411)); - await writer.write(Buffer.alloc(815)); - await writer.write(Buffer.alloc(12)); - await writer.close(); - - t.expect(lengths).toMatchObject([512, 512, 512, 512, 12]); + t.expect(await Promise.all(chunks.map((a) => xxhash64(a)))).toMatchObject([ + "6234b76401d9eb97", + "338834da3f6500b2" + ]); }); diff --git a/apps/web/src/components/attachment/index.js b/apps/web/src/components/attachment/index.js deleted file mode 100644 index a27c08953..000000000 --- a/apps/web/src/components/attachment/index.js +++ /dev/null @@ -1,250 +0,0 @@ -/* -This file is part of the Notesnook project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -import { Box, Flex, Text } from "@theme-ui/components"; -import { formatBytes } from "../../utils/filename"; -import ListItem from "../list-item"; -import * as Icon from "../icons"; -import { downloadAttachment } from "../../common/attachments"; -import { reuploadAttachment } from "../editor/picker"; -import { store } from "../../stores/attachment-store"; -import { db } from "../../common/db"; -import { Multiselect } from "../../common/multi-select"; -import { - closeOpenedDialog, - showAttachmentsDialog, - showPromptDialog -} from "../../common/dialog-controller"; -import { hashNavigate } from "../../navigation"; -import { showToast } from "../../utils/toast"; - -const workStatusMap = { - recheck: "Rechecking...", - delete: "Deleting..." -}; -function Attachment({ item, isCompact, index }) { - const attachment = item; - const status = attachment.status; - - return ( - {attachment.failed} - } - menu={{ - items: menuItems, - extraData: { attachment } - }} - onClick={() => { - if (isCompact) showAttachmentsDialog(); - }} - footer={ - isCompact ? ( - - {status ? ( - - {formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)} - - ) : ( - {formatBytes(attachment.length)} - )} - {attachment.failed && ( - - )} - - {attachment.isDeleting ? ( - - ) : attachment.dateUploaded ? ( - - ) : ( - - )} - - ) : attachment.working ? ( - - - - {workStatusMap[attachment.working]} - - - ) : ( - - {status && ( - - - {formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}{" "} - ({status.type}ing) - - - - )} - - {formatBytes(attachment.length)} - {attachment.metadata.type} - {attachment.noteIds && ( - {attachment.noteIds.length} notes - )} - {attachment.metadata.hash && ( - - {attachment.metadata.hash} - - )} - - - ) - } - index={index} - /> - ); -} -export default Attachment; - -const menuItems = [ - { - key: "notes", - title: "Notes", - icon: Icon.References, - items: ({ attachment }) => - attachment.noteIds.reduce((prev, curr) => { - const note = db.notes.note(curr); - if (!note) - prev.push({ - key: curr, - title: `Note with id ${curr}`, - onClick: () => showToast("error", "This note does not exist.") - }); - else - prev.push({ - key: note.id, - title: note.title, - onClick: () => { - hashNavigate(`/notes/${curr}/edit`); - closeOpenedDialog(); - } - }); - return prev; - }, []) - }, - { - key: "recheck", - title: () => "Recheck", - icon: Icon.DoubleCheckmark, - disabled: ({ attachment }) => - !attachment.dateUploaded ? "This attachment is not uploaded yet." : false, - onClick: async ({ items }) => { - await store.recheck(items.map((i) => i.metadata.hash)); - }, - multiSelect: true - }, - { - key: "rename", - title: () => "Rename", - icon: Icon.Rename, - onClick: async ({ attachment }) => { - const newName = await showPromptDialog({ - title: "Rename attachment", - description: attachment.metadata.filename, - defaultValue: attachment.metadata.filename - }); - if (!newName) return; - await store.rename(attachment.metadata.hash, newName); - } - }, - { - key: "download", - title: ({ attachment }) => - attachment.status?.type === "download" ? "Cancel download" : "Download", - icon: Icon.Download, - disabled: ({ attachment }) => - !attachment.dateUploaded ? "This attachment is not uploaded yet." : false, - onClick: async ({ attachment }) => { - const isDownloading = attachment.status?.type === "download"; - if (isDownloading) { - await db.fs.cancel(attachment.metadata.hash, "download"); - } else await downloadAttachment(attachment.metadata.hash); - } - }, - { - key: "reupload", - title: ({ attachment }) => - attachment.status?.type === "upload" ? "Cancel upload" : "Reupload", - icon: Icon.Reupload, - onClick: async ({ attachment }) => { - const isDownloading = attachment.status?.type === "upload"; - if (isDownloading) { - await db.fs.cancel(attachment.metadata.hash, "upload"); - } else - await reuploadAttachment( - attachment.metadata.type, - attachment.metadata.hash - ); - } - }, - { - key: "permanent-delete", - color: "error", - iconColor: "error", - title: () => "Delete permanently", - icon: Icon.DeleteForver, - onClick: ({ items }) => Multiselect.deleteAttachments(items), - multiSelect: true - } -]; diff --git a/apps/web/src/components/attachment/index.tsx b/apps/web/src/components/attachment/index.tsx new file mode 100644 index 000000000..cae23dba7 --- /dev/null +++ b/apps/web/src/components/attachment/index.tsx @@ -0,0 +1,294 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { Box, Checkbox, Flex, Label, Text } from "@theme-ui/components"; +import { formatBytes } from "../../utils/filename"; +import { + AttachmentError, + Checkmark, + DeleteForver, + DoubleCheckmark, + Download, + FileDocument, + FileGeneral, + FileImage, + FilePDF, + FileVideo, + FileWebClip, + Icon, + Loading, + References, + Rename, + Reupload, + Uploading +} from "../icons"; +import { formatDate } from "@notesnook/core/utils/date"; +import { showToast } from "../../utils/toast"; +import { hashNavigate } from "../../navigation"; +import { + closeOpenedDialog, + showPromptDialog +} from "../../common/dialog-controller"; +import { store } from "../../stores/attachment-store"; +import { db } from "../../common/db"; +import { downloadAttachment } from "../../common/attachments"; +import { reuploadAttachment } from "../editor/picker"; +import { Multiselect } from "../../common/multi-select"; +import { Menu } from "../../hooks/use-menu"; +import { + DocumentMimeTypes, + WebClipMimeType, + PDFMimeType +} from "@notesnook/core/utils/filename"; + +const FILE_ICONS: Record = { + "image/": FileImage, + "video/": FileVideo, + [WebClipMimeType]: FileWebClip +}; + +for (const mimeType of DocumentMimeTypes) { + FILE_ICONS[mimeType] = mimeType === PDFMimeType ? FilePDF : FileDocument; +} + +function getFileIcon(type: string) { + for (const mime in FILE_ICONS) { + if (type.startsWith(mime)) + return FILE_ICONS[mime as keyof typeof FILE_ICONS]; + } + return FileGeneral; +} + +type AttachmentProps = { + attachment: any; + isSelected?: boolean; + onSelected?: () => void; + compact?: boolean; +}; +export function Attachment({ + attachment, + isSelected, + onSelected, + compact +}: AttachmentProps) { + const Icon = getFileIcon(attachment.metadata.type); + return ( + { + e.preventDefault(); + Menu.openMenu(AttachmentMenuItems, { + attachment + }); + }} + > + {!compact && ( + + + + )} + + + {attachment.status ? ( + attachment.status.type === "download" ? ( + + ) : ( + + ) + ) : attachment.failed ? ( + + ) : attachment.working ? ( + + ) : ( + + )} + + {attachment.metadata.filename} + + + + + {attachment.isDeleting ? ( + + ) : attachment.dateUploaded ? ( + + ) : ( + + )} + + + {attachment.status ? ( + <> + {formatBytes(attachment.status.loaded, 1)}/ + {formatBytes(attachment.status.total, 1)} + + ) : ( + formatBytes(attachment.length, compact ? 1 : 2) + )} + + {!compact && ( + + {attachment.dateUploaded + ? formatDate(attachment.dateUploaded, { + dateStyle: "short", + timeStyle: "short" + }) + : "-"} + + )} + + ); +} + +type MenuActionParams = { + attachment: any; +}; + +type MenuItemValue = T | ((options: MenuActionParams) => T); +type MenuItem = { + type?: "separator"; + key: string; + title?: MenuItemValue; + icon?: MenuItemValue; + onClick?: (options: MenuActionParams) => void; + disabled?: MenuItemValue; + color?: MenuItemValue; + iconColor?: MenuItemValue; + multiSelect?: boolean; + items?: MenuItemValue; +}; +const AttachmentMenuItems: MenuItem[] = [ + { + key: "notes", + title: "Notes", + icon: References, + items: ({ attachment }) => + (attachment.noteIds as string[]).reduce((prev, curr) => { + const note = db.notes?.note(curr); + if (!note) + prev.push({ + key: curr, + title: `Note with id ${curr}`, + onClick: () => showToast("error", "This note does not exist.") + }); + else + prev.push({ + key: note.id, + title: note.title, + onClick: () => { + hashNavigate(`/notes/${curr}/edit`); + closeOpenedDialog(); + } + }); + return prev; + }, [] as MenuItem[]) + }, + { + key: "recheck", + title: () => "Recheck", + icon: DoubleCheckmark, + disabled: ({ attachment }) => + !attachment.dateUploaded ? "This attachment is not uploaded yet." : false, + onClick: async ({ attachment }) => { + await store.recheck([attachment.metadata.hash]); + } + }, + { + key: "rename", + title: () => "Rename", + icon: Rename, + onClick: async ({ attachment }) => { + const newName = await showPromptDialog({ + title: "Rename attachment", + description: attachment.metadata.filename, + defaultValue: attachment.metadata.filename + }); + if (!newName) return; + await store.rename(attachment.metadata.hash, newName); + } + }, + { + key: "download", + title: ({ attachment }) => + attachment.status?.type === "download" ? "Cancel download" : "Download", + icon: Download, + disabled: ({ attachment }) => + !attachment.dateUploaded ? "This attachment is not uploaded yet." : false, + onClick: async ({ attachment }) => { + const isDownloading = attachment.status?.type === "download"; + if (isDownloading) { + await db.fs.cancel(attachment.metadata.hash, "download"); + } else await downloadAttachment(attachment.metadata.hash); + } + }, + { + key: "reupload", + title: ({ attachment }) => + attachment.status?.type === "upload" ? "Cancel upload" : "Reupload", + icon: Reupload, + onClick: async ({ attachment }) => { + const isDownloading = attachment.status?.type === "upload"; + if (isDownloading) { + await db.fs.cancel(attachment.metadata.hash, "upload"); + } else + await reuploadAttachment( + attachment.metadata.type, + attachment.metadata.hash + ); + } + }, + { + key: "permanent-delete", + color: "error", + iconColor: "error", + title: () => "Delete permanently", + icon: DeleteForver, + onClick: ({ attachment }) => Multiselect.deleteAttachments([attachment]) + } +]; diff --git a/apps/web/src/components/dialogs/attachments-dialog.js b/apps/web/src/components/dialogs/attachments-dialog.js deleted file mode 100644 index 154345076..000000000 --- a/apps/web/src/components/dialogs/attachments-dialog.js +++ /dev/null @@ -1,81 +0,0 @@ -/* -This file is part of the Notesnook project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -import { useEffect } from "react"; -import { Flex } from "@theme-ui/components"; -import { getTotalSize } from "../../common/attachments"; -import { useStore } from "../../stores/attachment-store"; -import { formatBytes } from "../../utils/filename"; -import Field from "../field"; -import ListContainer from "../list-container"; -import Dialog from "./dialog"; -import Placeholder from "../placeholders"; -import { AttachmentStream } from "../../common/attachment-stream"; -import { ZipStream } from "../../utils/zip-stream"; -import { createWriteStream } from "../../utils/stream-saver"; -import { register } from "../../utils/mitm"; - -function AttachmentsDialog({ onClose }) { - const attachments = useStore((store) => store.attachments); - const refresh = useStore((store) => store.refresh); - const filter = useStore((store) => store.filter); - useEffect(() => { - refresh(); - }, [refresh]); - return ( - { - await register(); - await new AttachmentStream(attachments) - .pipeThrough(new ZipStream()) - .pipeTo(createWriteStream("attachments.zip")); - } - }} - show - > - - filter(e.target.value)} - /> - } - type="attachments" - groupType="attachments" - placeholder={} - items={attachments} - /> - - - ); -} - -export default AttachmentsDialog; diff --git a/apps/web/src/components/dialogs/attachments-dialog.tsx b/apps/web/src/components/dialogs/attachments-dialog.tsx new file mode 100644 index 000000000..71f1795a7 --- /dev/null +++ b/apps/web/src/components/dialogs/attachments-dialog.tsx @@ -0,0 +1,514 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { useEffect, useState, memo, useMemo, useRef } from "react"; +import { + Box, + Button, + Checkbox, + Donut, + Flex, + Input, + Label, + Text +} from "@theme-ui/components"; +import { getTotalSize } from "../../common/attachments"; +import { useStore, store } from "../../stores/attachment-store"; +import { formatBytes } from "../../utils/filename"; +import Dialog from "./dialog"; +import { TableVirtuoso } from "react-virtuoso"; +import { + ChevronDown, + ChevronUp, + Close, + DoubleCheckmark, + Download, + FileDocument, + FileGeneral, + FileImage, + FileVideo, + Icon, + Trash, + Unlink, + Uploading +} from "../icons"; +import NavigationItem from "../navigation-menu/navigation-item"; +import { pluralize } from "../../utils/string"; +import { db } from "../../common/db"; +import { Perform } from "../../common/dialog-controller"; +import { Multiselect } from "../../common/multi-select"; +import { CustomScrollbarsVirtualList } from "../list-container"; +import { Attachment } from "../attachment"; +import { isDocument, isImage, isVideo } from "@notesnook/core/utils/filename"; + +type ToolbarAction = { + title: string; + icon: Icon; + onClick: ({ selected }: { selected: any[] }) => void; +}; + +const TOOLBAR_ACTIONS: ToolbarAction[] = [ + { + title: "Download", + icon: Download, + onClick: async ({ selected }) => { + await store.get().download(selected); + } + }, + { + title: "Recheck", + icon: DoubleCheckmark, + onClick: async ({ selected }) => { + await store.recheck(selected.map((a) => a.metadata.hash)); + } + }, + { + title: "Delete", + icon: Trash, + onClick: ({ selected }) => Multiselect.deleteAttachments(selected) + } +]; + +type AttachmentsDialogProps = { onClose: Perform }; +function AttachmentsDialog({ onClose }: AttachmentsDialogProps) { + const allAttachments = useStore((store) => store.attachments); + const [attachments, setAttachments] = useState(allAttachments); + const [selected, setSelected] = useState([]); + const [sortBy, setSortBy] = useState({ id: "name", direction: "asc" }); + const currentRoute = useRef("all"); + const refresh = useStore((store) => store.refresh); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + setAttachments(filterAttachments(currentRoute.current, allAttachments)); + }, [allAttachments]); + + useEffect(() => { + setAttachments((a) => { + const attachments = a.slice(); + if (sortBy.id === "name") { + attachments.sort( + sortBy.direction === "asc" + ? (a, b) => a.metadata.filename.localeCompare(b.metadata.filename) + : (a, b) => b.metadata.filename.localeCompare(a.metadata.filename) + ); + } else if (sortBy.id === "size") { + attachments.sort( + sortBy.direction === "asc" + ? (a, b) => a.length - b.length + : (a, b) => b.length - a.length + ); + } else if (sortBy.id === "dateUploaded") { + attachments.sort( + sortBy.direction === "asc" + ? (a, b) => a.dateUploaded - b.dateUploaded + : (a, b) => b.dateUploaded - a.dateUploaded + ); + } + return attachments; + }); + }, [sortBy]); + + const totalSize = useMemo( + () => getTotalSize(allAttachments), + [allAttachments] + ); + + return ( + onClose(false)} + noScroll + sx={{ bg: "transparent" }} + > + + { + setAttachments( + db.lookup?.attachments(db.attachments?.all || [], query) || [] + ); + }} + counts={getCounts(allAttachments)} + onRouteChange={(route) => { + currentRoute.current = route; + setSelected([]); + setAttachments(filterAttachments(route, allAttachments)); + }} + /> + + + + {TOOLBAR_ACTIONS.map((tool) => ( + + ))} + + {/* */} + + { + const attachment = attachments[props["data-item-index"]]; + return ( + -1} + onSelected={() => { + setSelected((s) => { + const copy = s.slice(); + const index = copy.indexOf(attachment.id); + if (index > -1) copy.splice(index, 1); + else copy.push(attachment.id); + return copy; + }); + }} + /> + ); + } + }} + style={{ height: "100%" }} + data={attachments} + fixedItemHeight={30} + defaultItemHeight={30} + fixedHeaderContent={() => ( + + + + + {[ + { id: "name", title: "Name" }, + { id: "status" }, + { id: "size", title: "Size" }, + { id: "dateUploaded", title: "Date uploaded" } + ].map((column) => + !column.title ? ( + + ) : ( + { + setSortBy((sortBy) => ({ + direction: + sortBy.id === column.id && + sortBy.direction === "asc" + ? "desc" + : "asc", + id: column.id + })); + }} + > + + + {column.title} + + {sortBy.id === column.id ? ( + sortBy.direction === "asc" ? ( + + ) : ( + + ) + ) : null} + + + ) + )} + + )} + itemContent={() => <>} + /> + + + + ); +} + +export default AttachmentsDialog; + +type Route = "all" | "images" | "documents" | "videos" | "uploads" | "orphaned"; + +const routes: { id: Route; icon: Icon; title: string }[] = [ + { + id: "all", + icon: FileGeneral, + title: "All files" + }, + { + id: "images", + icon: FileImage, + title: "Images" + }, + { + id: "documents", + icon: FileDocument, + title: "Documents" + }, + { + id: "videos", + icon: FileVideo, + title: "Videos" + }, + { + id: "uploads", + icon: Uploading, + title: "Uploads" + }, + { + id: "orphaned", + icon: Unlink, + title: "Orphaned" + } +]; + +type SidebarProps = { + onRouteChange: (route: Route) => void; + filter: (query: string) => void; + counts: Record; + totalSize: number; +}; +const Sidebar = memo( + function Sidebar(props: SidebarProps) { + const { onRouteChange, filter, counts, totalSize } = props; + const [route, setRoute] = useState("all"); + const downloadStatus = useStore((store) => store.status); + const cancelDownload = useStore((store) => store.cancel); + const download = useStore((store) => store.download); + + return ( + + + { + setRoute(e.target.value ? "none" : "all"); + if (e.target.value) filter(e.target.value); + }} + /> + {routes.map((item) => ( + { + onRouteChange(item.id); + setRoute(item.id); + }} + selected={route === item.id} + /> + ))} + + + + + + {pluralize(counts.all, "file", "files")} + + {formatBytes(totalSize)} + + + + + + ); + }, + (prev, next) => + prev.totalSize === next.totalSize && + prev.counts.all === next.counts.all && + prev.counts.documents === next.counts.documents && + prev.counts.images === next.counts.images && + prev.counts.videos === next.counts.videos && + prev.counts.uploads === next.counts.uploads && + prev.counts.orphaned === next.counts.orphaned +); + +function getCounts(attachments: any[]): Record { + const counts: Record = { + all: 0, + documents: 0, + images: 0, + videos: 0, + uploads: 0, + orphaned: 0 + }; + for (const attachment of attachments) { + counts.all++; + + if (isDocument(attachment.metadata.type)) counts.documents++; + else if (isImage(attachment.metadata.type)) counts.images++; + else if (isVideo(attachment.metadata.type)) counts.videos++; + + if (!attachment.dateUploaded) counts.uploads++; + if (!attachment.noteIds.length) counts.orphaned++; + } + return counts; +} + +function filterAttachments(route: Route, attachments: any[]): any[] { + return route === "all" + ? attachments + : route === "images" + ? attachments.filter((a) => a.metadata.type.startsWith("image/")) + : route === "videos" + ? attachments.filter((a) => a.metadata.type.startsWith("video/")) + : route === "documents" + ? attachments.filter((a) => isDocument(a.metadata.type)) + : route === "orphaned" + ? attachments.filter((a) => !a.noteIds.length) + : attachments.filter((a) => !a.dateUploaded); +} diff --git a/apps/web/src/components/dialogs/dialog.tsx b/apps/web/src/components/dialogs/dialog.tsx index b05b1e149..94d24eddc 100644 --- a/apps/web/src/components/dialogs/dialog.tsx +++ b/apps/web/src/components/dialogs/dialog.tsx @@ -22,6 +22,7 @@ import { Flex, Text, Button, ButtonProps } from "@theme-ui/components"; import * as Icon from "../icons"; import ReactModal from "react-modal"; import { FlexScrollContainer } from "../scroll-container"; +import { SxProp } from "@theme-ui/core"; ReactModal.setAppElement("#root"); @@ -32,7 +33,7 @@ type DialogButtonProps = ButtonProps & { loading?: boolean; }; -type DialogProps = { +type DialogProps = SxProp & { isOpen?: boolean; onClose?: ( event?: React.MouseEvent | React.KeyboardEvent @@ -47,6 +48,7 @@ type DialogProps = { positiveButton?: DialogButtonProps | null; negativeButton?: DialogButtonProps | null; footer?: React.Component; + noScroll?: boolean; }; function BaseDialog(props: React.PropsWithChildren) { @@ -94,7 +96,9 @@ function BaseDialog(props: React.PropsWithChildren) { position: "relative", overflow: "hidden", boxShadow: "4px 5px 18px 2px #00000038", - borderRadius: "dialog" + borderRadius: "dialog", + + ...props.sx }} > {props.showCloseButton && ( @@ -111,34 +115,42 @@ function BaseDialog(props: React.PropsWithChildren) { onClick={props.onClose} /> )} - - - {props.title} - - {props.description && ( - - {props.description} - - )} - - - - {props.children} - - + {props.title || props.description ? ( + + {props.title && ( + + {props.title} + + )} + {props.description && ( + + {props.description} + + )} + + ) : null} + {props.noScroll ? ( + <>{props.children} + ) : ( + + + {props.children} + + + )} {(props.positiveButton || props.negativeButton) && ( . */ -import { useState } from "react"; +import { memo } from "react"; import MDIIcon from "@mdi/react"; import { mdiPlus, @@ -191,7 +191,13 @@ import { mdiNoteMultipleOutline, mdiBookMultipleOutline, mdiArrowTopRight, - mdiBookmarkRemoveOutline + mdiBookmarkRemoveOutline, + mdiFileImageOutline, + mdiFileDocumentOutline, + mdiFileVideoOutline, + mdiWeb, + mdiUploadOutline, + mdiLinkOff } from "@mdi/js"; import { useTheme } from "@emotion/react"; import { Theme } from "@notesnook/theme"; @@ -205,7 +211,7 @@ type MDIIconWrapperProps = { color?: keyof Theme["colors"]; rotate?: boolean; }; -function MDIIconWrapper({ +function _MDIIconWrapper({ title, path, size = 24, @@ -237,12 +243,11 @@ function MDIIconWrapper({ /> ); } +const MDIIconWrapper = memo(_MDIIconWrapper, () => true); export type IconProps = FlexProps & MotionProps & - Omit & { - hoverColor?: keyof Theme["colors"]; - }; + Omit; export type Icon = { (props: IconProps): JSX.Element; @@ -251,7 +256,6 @@ export type Icon = { function createIcon(path: string, rotate = false) { const NNIcon: Icon = function Icon(props) { - const [isHovering, setIsHovering] = useState(false); const { sx, rotate: _rotate = rotate, size, ...restProps } = props; return ( setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} > ); @@ -455,6 +455,7 @@ export const Reupload = createIcon(mdiProgressUpload); export const Rename = createIcon(mdiFormTextbox); export const Upload = createIcon(mdiCloudOffOutline); export const Uploaded = createIcon(mdiCloudCheckOutline); +export const Uploading = createIcon(mdiUploadOutline); export const References = createIcon(mdiVectorLink); export const Codeblock = createIcon(mdiCodeBraces); export const Resize = createIcon(mdiArrowCollapseHorizontal); @@ -479,3 +480,13 @@ export const EditorFullWidth = createIcon( `M4 20q-.825 0-1.412-.587Q2 18.825 2 18V6q0-.825.588-1.412Q3.175 4 4 4h16q.825 0 1.413.588Q22 5.175 22 6v12q0 .825-.587 1.413Q20.825 20 20 20Zm0-2h1V6H4v12Zm3 0h10V6H7Zm12 0h1V6h-1ZM7 6v12Z` ); export const Suggestion = createIcon(mdiLightbulbOnOutline); + +export const FileImage = createIcon(mdiFileImageOutline); +export const FilePDF = createIcon( + `M14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8m4 18V9h-5V4H6v16h12m-7.08-7.69c-.24-.77-.77-3.23.63-3.27c1.4-.04.48 3.12.48 3.12c.39 1.49 2.02 2.56 2.02 2.56c.5-.15 3.35-.48 2.95 1c-.43 1.48-3.5.09-3.5.09c-1.95.14-3.41.66-3.41.66c-1.13 2.11-2.45 3.03-2.99 2.14c-.67-1.11 2.13-2.54 2.13-2.54c1.45-2.35 1.67-3.72 1.69-3.76m.65.84c-.4 1.3-1.2 2.69-1.2 2.69c.85-.34 2.71-.73 2.71-.73c-1.14-1-1.49-1.95-1.51-1.96m3.14 2.17s1.75.65 1.79.39c.07-.27-1.33-.51-1.79-.39m-5.66 1.49c-.77.3-1.51 1.58-1.33 1.58c.18.01.91-.6 1.33-1.58m2.52-5.55c0-.05.43-1.68 0-1.73c-.3-.03-.01 1.69 0 1.73z` +); +export const FileDocument = createIcon(mdiFileDocumentOutline); +export const FileVideo = createIcon(mdiFileVideoOutline); +export const FileGeneral = createIcon(mdiFileOutline); +export const FileWebClip = createIcon(mdiWeb); +export const Unlink = createIcon(mdiLinkOff); diff --git a/apps/web/src/components/list-container/index.tsx b/apps/web/src/components/list-container/index.tsx index a43e35802..5e63c3b76 100644 --- a/apps/web/src/components/list-container/index.tsx +++ b/apps/web/src/components/list-container/index.tsx @@ -39,19 +39,20 @@ import { useKeyboardListNavigation } from "../../hooks/use-keyboard-list-navigat import { AnimatedFlex } from "../animated"; import { domAnimation, LazyMotion } from "framer-motion"; -const CustomScrollbarsVirtualList = forwardRef( - function CustomScrollbarsVirtualList(props, ref) { - return ( - { - if (typeof ref === "function") ref(sRef); - else if (ref) ref.current = sRef; - }} - /> - ); - } -); +export const CustomScrollbarsVirtualList = forwardRef< + HTMLDivElement, + ScrollerProps +>(function CustomScrollbarsVirtualList(props, ref) { + return ( + { + if (typeof ref === "function") ref(sRef); + else if (ref) ref.current = sRef; + }} + /> + ); +}); type ListContainerProps = { type: keyof typeof ListProfiles; diff --git a/apps/web/src/components/list-container/list-profiles.tsx b/apps/web/src/components/list-container/list-profiles.tsx index ce6fb8f02..27563a17c 100644 --- a/apps/web/src/components/list-container/list-profiles.tsx +++ b/apps/web/src/components/list-container/list-profiles.tsx @@ -23,7 +23,6 @@ import Notebook from "../notebook"; import Tag from "../tag"; import Topic from "../topic"; import TrashItem from "../trash-item"; -import Attachment from "../attachment"; import { db } from "../../common/db"; import { getTotalNotes } from "../../common"; import Reminder from "../reminder"; @@ -111,10 +110,6 @@ const TrashProfile: ItemWrapper = ({ index, item, type }) => ( ); -const AttachmentProfile: ItemWrapper = ({ index, item }) => ( - -); - export const ListProfiles = { home: NotesProfile, notebooks: NotebooksProfile, @@ -122,8 +117,7 @@ export const ListProfiles = { reminders: RemindersProfile, tags: TagsProfile, topics: TopicsProfile, - trash: TrashProfile, - attachments: AttachmentProfile + trash: TrashProfile } as const; function getTags(item: Item) { @@ -191,8 +185,6 @@ function getReminder(noteId: string) { } function getDate(item: Item, groupType: keyof typeof ListProfiles) { - if (groupType === "attachments") return item.dateCreated; - const sortBy = db.settings?.getGroupOptions(groupType).sortBy; switch (sortBy) { case "dateEdited": diff --git a/apps/web/src/components/navigation-menu/navigation-item.tsx b/apps/web/src/components/navigation-menu/navigation-item.tsx index 9d822d85a..2fb844899 100644 --- a/apps/web/src/components/navigation-menu/navigation-item.tsx +++ b/apps/web/src/components/navigation-menu/navigation-item.tsx @@ -19,7 +19,7 @@ along with this program. If not, see . import { Button, Text } from "@theme-ui/components"; import { useStore as useAppStore } from "../../stores/app-store"; -import { useMenuTrigger } from "../../hooks/use-menu"; +import { Menu } from "../../hooks/use-menu"; import useMobile from "../../hooks/use-mobile"; import { PropsWithChildren } from "react"; import { Theme } from "@notesnook/theme"; @@ -61,7 +61,6 @@ function NavigationItem(props: PropsWithChildren) { index = 0 } = props; const toggleSideMenu = useAppStore((store) => store.toggleSideMenu); - const { openMenu } = useMenuTrigger(); const isMobile = useMobile(); return ( @@ -105,7 +104,7 @@ function NavigationItem(props: PropsWithChildren) { onContextMenu={(e) => { if (!menuItems) return; e.preventDefault(); - openMenu(menuItems); + Menu.openMenu(menuItems); }} onClick={() => { if (isMobile) toggleSideMenu(false); diff --git a/apps/web/src/components/properties/index.js b/apps/web/src/components/properties/index.js index 1c4807ce3..e9a4fd54b 100644 --- a/apps/web/src/components/properties/index.js +++ b/apps/web/src/components/properties/index.js @@ -32,7 +32,7 @@ import ScrollContainer from "../scroll-container"; import { formatDate } from "@notesnook/core/utils/date"; import Vault from "../../common/vault"; import TimeAgo from "../time-ago"; -import Attachment from "../attachment"; +import { Attachment } from "../attachment"; import { formatBytes } from "../../utils/filename"; import { getTotalSize } from "../../common/attachments"; import Notebook from "../notebook"; @@ -289,14 +289,17 @@ function Properties(props) { getTotalSize(attachments) )} occupied`} > - {attachments.map((attachment, i) => ( - - ))} + + + {attachments.map((attachment, i) => ( + + ))} + +
)} . +*/ + +export class ChunkDistributor { + /** + * @typedef {{length: number, data: Uint8Array, final: boolean}} Chunk + */ + constructor(chunkSize) { + this.chunkSize = chunkSize; + this.chunks = []; + this.filledCount = 0; + this.done = false; + } + + /** + * @returns {Chunk} + */ + get lastChunk() { + return this.chunks[this.chunks.length - 1]; + } + + /** + * @returns {boolean} + */ + get isLastChunkFilled() { + return this.lastChunk.length === this.chunkSize; + } + + /** + * @returns {Chunk} + */ + get firstChunk() { + const chunk = this.chunks.shift(); + if (chunk.data.length === this.chunkSize) this.filledCount--; + return chunk; + } + + close() { + if (!this.lastChunk) + throw new Error("No data available in this distributor."); + this.lastChunk.data = this.lastChunk.data.slice(0, this.lastChunk.length); + this.lastChunk.final = true; + this.done = true; + } + + /** + * @param {Uint8Array} data + */ + fill(data) { + if (this.done || !data || !data.length) return; + + const dataLength = data.length; + const totalBlocks = Math.ceil(dataLength / this.chunkSize); + + for (let i = 0; i < totalBlocks; ++i) { + const start = i * this.chunkSize; + + if (this.lastChunk && !this.isLastChunkFilled) { + const needed = this.chunkSize - this.lastChunk.length; + const end = Math.min(start + needed, dataLength); + const chunk = data.slice(start, end); + + this.lastChunk.data.set(chunk, this.lastChunk.length); + this.lastChunk.length += chunk.length; + + if (this.lastChunk.length === this.chunkSize) this.filledCount++; + + if (end !== dataLength) { + this.fill(data.slice(end)); + break; + } + } else { + const end = Math.min(start + this.chunkSize, dataLength); + let chunk = data.slice(start, end); + + const buffer = new Uint8Array(this.chunkSize); + buffer.set(chunk, 0); + + this.chunks.push({ data: buffer, final: false, length: chunk.length }); + if (chunk.length === this.chunkSize) this.filledCount++; + } + } + } +} diff --git a/apps/web/src/interfaces/fs.ts b/apps/web/src/interfaces/fs.ts index 3f6bb62e6..918af9038 100644 --- a/apps/web/src/interfaces/fs.ts +++ b/apps/web/src/interfaces/fs.ts @@ -30,10 +30,10 @@ import { saveAs } from "file-saver"; import { showToast } from "../utils/toast"; import { db } from "../common/db"; import { getFileNameWithExtension } from "@notesnook/core/utils/filename"; -import { ChunkedStream, IntoChunks } from "./chunked-stream"; -import { ProgressStream } from "./progress-stream"; -import { consumeReadableStream } from "./stream-utils"; -import { Base64DecoderStream } from "./base64-decoder-stream"; +import { ChunkedStream, IntoChunks } from "../utils/streams/chunked-stream"; +import { ProgressStream } from "../utils/streams/progress-stream"; +import { consumeReadableStream } from "../utils/stream"; +import { Base64DecoderStream } from "../utils/streams/base64-decoder-stream"; import { toBlob } from "@notesnook-importer/core/dist/src/utils/stream"; import { Cipher, OutputFormat, SerializedKey } from "@notesnook/crypto"; import { IDataType } from "hash-wasm/dist/lib/util"; @@ -175,7 +175,7 @@ async function readEncrypted( : new Base64DecoderStream() ) ) - ).join() + ).join("") : new Uint8Array( Buffer.concat( await consumeReadableStream( diff --git a/apps/web/src/stores/attachment-store.js b/apps/web/src/stores/attachment-store.js index ec2fe102f..f023e0307 100644 --- a/apps/web/src/stores/attachment-store.js +++ b/apps/web/src/stores/attachment-store.js @@ -24,9 +24,22 @@ import { AppEventManager, AppEvents } from "../common/app-events"; import { store as editorStore } from "./editor-store"; import { checkAttachment } from "../common/attachments"; import { showToast } from "../utils/toast"; +import { register } from "../utils/stream-saver/mitm"; +import { AttachmentStream } from "../utils/streams/attachment-stream"; +import { ZipStream } from "../utils/streams/zip-stream"; +import { createWriteStream } from "../utils/stream-saver"; +let abortController = undefined; class AttachmentStore extends BaseStore { attachments = []; + /** + * @type {{current: number, total: number}} + */ + status = undefined; + + refresh = () => { + this.set((state) => (state.attachments = db.attachments.all)); + }; init = () => { AppEventManager.subscribe( @@ -51,17 +64,40 @@ class AttachmentStore extends BaseStore { this.refresh(); }; - refresh = () => { - this.set((state) => (state.attachments = db.attachments.all)); - }; - - filter = (query) => { - if (!query || !query.trim().length) return this.refresh(); + download = async (attachments) => { + if (this.get().status) + throw new Error( + "Please wait for the previous download to finish or cancel it." + ); this.set( - (state) => - (state.attachments = db.lookup.attachments(db.attachments.all, query)) + (state) => (state.status = { current: 0, total: attachments.length }) ); + + await register(); + abortController = new AbortController(); + const attachmentStream = new AttachmentStream( + attachments, + abortController.signal, + (current) => { + this.set( + (state) => (state.status = { current, total: attachments.length }) + ); + } + ); + await attachmentStream + .pipeThrough(new ZipStream()) + .pipeTo(createWriteStream("attachments.zip")); + + this.set((state) => (state.status = undefined)); + }; + + cancel = async () => { + if (abortController) { + await abortController.abort(); + abortController = undefined; + this.set((state) => (state.status = undefined)); + } }; recheck = async (hashes) => { diff --git a/apps/web/src/utils/stream-saver.ts b/apps/web/src/utils/stream-saver/index.ts similarity index 100% rename from apps/web/src/utils/stream-saver.ts rename to apps/web/src/utils/stream-saver/index.ts diff --git a/apps/web/src/utils/mitm.ts b/apps/web/src/utils/stream-saver/mitm.ts similarity index 100% rename from apps/web/src/utils/mitm.ts rename to apps/web/src/utils/stream-saver/mitm.ts diff --git a/apps/web/src/interfaces/stream-utils.ts b/apps/web/src/utils/stream.ts similarity index 100% rename from apps/web/src/interfaces/stream-utils.ts rename to apps/web/src/utils/stream.ts diff --git a/apps/web/src/common/attachment-stream.ts b/apps/web/src/utils/streams/attachment-stream.ts similarity index 77% rename from apps/web/src/common/attachment-stream.ts rename to apps/web/src/utils/streams/attachment-stream.ts index 8ec5f4281..9c17c526a 100644 --- a/apps/web/src/common/attachment-stream.ts +++ b/apps/web/src/utils/streams/attachment-stream.ts @@ -17,22 +17,37 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { decryptFile } from "../interfaces/fs"; -import { db } from "./db"; -import { ZipFile } from "../utils/zip-stream"; +import { decryptFile } from "../../interfaces/fs"; +import { db } from "../../common/db"; +import { ZipFile } from "./zip-stream"; export const METADATA_FILENAME = "metadata.json"; - +const GROUP_ID = "all-attachments"; export class AttachmentStream extends ReadableStream { - constructor(attachments: Array) { + constructor( + attachments: Array, + signal?: AbortSignal, + onProgress?: (current: number) => void + ) { + if (signal) + signal.onabort = async () => { + await db.fs.cancel(GROUP_ID, "download"); + }; + let index = 0; super({ start() {}, async pull(controller) { + if (signal?.aborted) { + controller.close(); + return; + } + + onProgress && onProgress(index); const attachment = attachments[index++]; await db.fs.downloadFile( - "all-attachments", + GROUP_ID, attachment.metadata.hash, attachment.chunkSize, attachment.metadata diff --git a/apps/web/src/interfaces/base64-decoder-stream.ts b/apps/web/src/utils/streams/base64-decoder-stream.ts similarity index 88% rename from apps/web/src/interfaces/base64-decoder-stream.ts rename to apps/web/src/utils/streams/base64-decoder-stream.ts index b5e431f13..a370b2f61 100644 --- a/apps/web/src/interfaces/base64-decoder-stream.ts +++ b/apps/web/src/utils/streams/base64-decoder-stream.ts @@ -25,14 +25,17 @@ export class Base64DecoderStream extends TransformStream { transform(chunk, controller) { let part = backBuffer ? Buffer.concat( - [Buffer.from(backBuffer.buffer), Buffer.from(chunk.buffer)], + [Buffer.from(backBuffer), Buffer.from(chunk)], backBuffer.length + chunk.length ) : Buffer.from(chunk.buffer); const remaining = part.length % 3; if (remaining) { - backBuffer = part.subarray(part.length - remaining); + backBuffer = new Uint8Array(remaining); + for (let i = 0; i < remaining; ++i) { + backBuffer[i] = part[part.length - remaining + i]; + } part = part.subarray(0, part.length - remaining); } else { backBuffer = null; diff --git a/apps/web/src/interfaces/chunked-stream.ts b/apps/web/src/utils/streams/chunked-stream.ts similarity index 86% rename from apps/web/src/interfaces/chunked-stream.ts rename to apps/web/src/utils/streams/chunked-stream.ts index c38f63a33..bcac50d8b 100644 --- a/apps/web/src/interfaces/chunked-stream.ts +++ b/apps/web/src/utils/streams/chunked-stream.ts @@ -27,26 +27,24 @@ export class ChunkedStream extends TransformStream { transform(chunk, controller) { backBuffer = backBuffer ? Buffer.concat( - [Buffer.from(backBuffer.buffer), Buffer.from(chunk.buffer)], + [Buffer.from(backBuffer), Buffer.from(chunk)], backBuffer.length + chunk.length ) - : Buffer.from(chunk.buffer); + : Buffer.from(chunk); if (backBuffer.length >= chunkSize) { let remainingBytes = backBuffer.length; - while (remainingBytes > chunkSize) { + while (remainingBytes >= chunkSize) { const start = backBuffer.length - remainingBytes; const end = start + chunkSize; + controller.enqueue(backBuffer.subarray(start, end)); remainingBytes -= chunkSize; } backBuffer = remainingBytes > 0 - ? backBuffer.subarray( - backBuffer.length - remainingBytes, - backBuffer.length - ) + ? backBuffer.subarray(backBuffer.length - remainingBytes) : null; } }, diff --git a/apps/web/src/interfaces/progress-stream.ts b/apps/web/src/utils/streams/progress-stream.ts similarity index 100% rename from apps/web/src/interfaces/progress-stream.ts rename to apps/web/src/utils/streams/progress-stream.ts diff --git a/apps/web/src/utils/zip-stream.ts b/apps/web/src/utils/streams/zip-stream.ts similarity index 100% rename from apps/web/src/utils/zip-stream.ts rename to apps/web/src/utils/streams/zip-stream.ts diff --git a/packages/core/collections/attachments.js b/packages/core/collections/attachments.js index 5b856348e..1c4dcd604 100644 --- a/packages/core/collections/attachments.js +++ b/packages/core/collections/attachments.js @@ -24,7 +24,11 @@ import { EV, EVENTS, sendAttachmentsProgressEvent } from "../common"; import dataurl from "../utils/dataurl"; import dayjs from "dayjs"; import setManipulator from "../utils/set"; -import { getFileNameWithExtension } from "../utils/filename"; +import { + getFileNameWithExtension, + isImage, + isWebClip +} from "../utils/filename"; export default class Attachments extends Collection { constructor(db, name, cached) { @@ -387,31 +391,25 @@ export default class Attachments extends Collection { } get images() { - return this.all.filter((attachment) => - attachment.metadata.type.startsWith("image/") - ); + return this.all.filter((attachment) => isImage(attachment.metadata.type)); } get webclips() { - return this.all.filter( - (attachment) => - attachment.metadata.type === "application/vnd.notesnook.web-clip" - ); + return this.all.filter((attachment) => isWebClip(attachment.metadata.type)); } get media() { return this.all.filter( (attachment) => - attachment.metadata.type.startsWith("image/") || - attachment.metadata.type === "application/vnd.notesnook.web-clip" + isImage(attachment.metadata.type) || isWebClip(attachment.metadata.type) ); } get files() { return this.all.filter( (attachment) => - !attachment.metadata.type.startsWith("image/") && - attachment.metadata.type !== "application/vnd.notesnook.web-clip" + !isImage(attachment.metadata.type) && + !isWebClip(attachment.metadata.type) ); } diff --git a/packages/core/database/fs.js b/packages/core/database/fs.js index 0ec15e724..024c67707 100644 --- a/packages/core/database/fs.js +++ b/packages/core/database/fs.js @@ -55,13 +55,13 @@ export default class FileStorage { return result; } - async cancel(groupId, type = undefined) { + async cancel(groupId, type) { const [op] = this._deleteOp(groupId, type); if (!op) return; await op.cancel("Operation canceled."); } - _deleteOp(groupId, type = undefined) { + _deleteOp(groupId, type) { const opIndex = this._queue.findIndex( (item) => item.groupId === groupId && (!type || item.type === type) ); diff --git a/packages/core/utils/filename.js b/packages/core/utils/filename.js index 8b91ab906..3a0f7f1a6 100644 --- a/packages/core/utils/filename.js +++ b/packages/core/utils/filename.js @@ -45,3 +45,36 @@ export function getFileNameWithExtension(filename, mime) { return `${filename}.${extension}`; } + +export const PDFMimeType = "application/pdf"; +export const DocumentMimeTypes = [ + PDFMimeType, + "application/msword", + "application/vnd.ms-word", + "application/vnd.oasis.opendocument.text", + "application/vnd.openxmlformats-officedocument.wordprocessingml", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml", + "application/vnd.oasis.opendocument.presentation" +]; + +export const WebClipMimeType = "application/vnd.notesnook.web-clip"; + +export function isDocument(mime) { + return DocumentMimeTypes.some((a) => a.startsWith(mime)); +} + +export function isWebClip(mime) { + return mime === WebClipMimeType; +} + +export function isImage(mime) { + return mime.startsWith("image/"); +} + +export function isVideo(mime) { + return mime.startsWith("video/"); +}