web: improve attachments manager ui/ux

This commit is contained in:
Abdullah Atta
2023-05-23 06:18:36 +05:00
committed by Abdullah Atta
parent 1043b7df55
commit 8a475f654f
26 changed files with 1158 additions and 472 deletions

View File

@@ -19,15 +19,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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<Uint8Array>;
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);
});

View File

@@ -19,25 +19,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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<Uint8Array>
).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"
]);
});

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<ListItem
selectable
isCompact={isCompact}
item={{ ...attachment, title: attachment.metadata.filename }}
title={attachment.metadata.filename}
body={
attachment.failed && <Text variant={"error"}>{attachment.failed}</Text>
}
menu={{
items: menuItems,
extraData: { attachment }
}}
onClick={() => {
if (isCompact) showAttachmentsDialog();
}}
footer={
isCompact ? (
<Flex
sx={{
fontSize: "subBody",
color: "fontTertiary",
alignItems: "center"
}}
>
{status ? (
<Text variant="subBody" title={`${status.type}ing`}>
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}
</Text>
) : (
<Text mr={1}>{formatBytes(attachment.length)}</Text>
)}
{attachment.failed && (
<Icon.AttachmentError
sx={{ flexShrink: 0 }}
color={"error"}
size={13}
title={attachment.failed}
/>
)}
{attachment.isDeleting ? (
<Icon.Loading
sx={{ flexShrink: 0 }}
size={13}
title={"Deleting.."}
/>
) : attachment.dateUploaded ? (
<Icon.DoubleCheckmark
sx={{ flexShrink: 0 }}
color={"primary"}
size={13}
title={"Uploaded"}
/>
) : (
<Icon.Checkmark
sx={{ flexShrink: 0 }}
color={"icon"}
size={13}
title={"Waiting for upload"}
/>
)}
</Flex>
) : attachment.working ? (
<Flex>
<Icon.Loading size={13} />
<Text variant={"subBody"} ml={1}>
{workStatusMap[attachment.working]}
</Text>
</Flex>
) : (
<Flex sx={{ flexDirection: "column" }}>
{status && (
<Flex sx={{ flexDirection: "column" }}>
<Text variant="subBody">
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}{" "}
({status.type}ing)
</Text>
<Box
sx={{
my: 1,
bg: "primary",
height: "2px",
width: `${status.progress}%`
}}
/>
</Flex>
)}
<Flex
sx={{
fontSize: "subBody",
color: "fontTertiary",
alignItems: "center"
}}
>
<Text mr={1}>{formatBytes(attachment.length)}</Text>
<Text mr={1}>{attachment.metadata.type}</Text>
{attachment.noteIds && (
<Text mr={1}>{attachment.noteIds.length} notes</Text>
)}
{attachment.metadata.hash && (
<Text className="selectable" mr={0}>
{attachment.metadata.hash}
</Text>
)}
</Flex>
</Flex>
)
}
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
}
];

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<string, Icon> = {
"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 (
<Box
as="tr"
sx={{ height: 30, ":hover": { bg: "hover" } }}
onContextMenu={(e) => {
e.preventDefault();
Menu.openMenu(AttachmentMenuItems, {
attachment
});
}}
>
{!compact && (
<td>
<Label>
<Checkbox
sx={{ width: 18, height: 18 }}
checked={isSelected}
onChange={onSelected}
/>
</Label>
</td>
)}
<td>
<Flex sx={{ alignItems: "center" }}>
{attachment.status ? (
attachment.status.type === "download" ? (
<Download size={16} />
) : (
<Uploading size={16} />
)
) : attachment.failed ? (
<AttachmentError
color={"error"}
size={16}
title={attachment.failed}
/>
) : attachment.working ? (
<Loading size={16} />
) : (
<Icon size={16} />
)}
<Text
variant="body"
sx={{
ml: 1,
whiteSpace: "nowrap",
maxWidth: compact ? 180 : "80%",
overflow: "hidden",
textOverflow: "ellipsis"
}}
>
{attachment.metadata.filename}
</Text>
</Flex>
</td>
<Text as="td" variant="body">
{attachment.isDeleting ? (
<Loading sx={{ flexShrink: 0 }} size={16} title={"Deleting.."} />
) : attachment.dateUploaded ? (
<DoubleCheckmark
sx={{ flexShrink: 0 }}
color={"primary"}
size={16}
title={"Uploaded"}
/>
) : (
<Checkmark
sx={{ flexShrink: 0 }}
color={"icon"}
size={16}
title={"Waiting for upload"}
/>
)}
</Text>
<Text as="td" variant="body">
{attachment.status ? (
<>
{formatBytes(attachment.status.loaded, 1)}/
{formatBytes(attachment.status.total, 1)}
</>
) : (
formatBytes(attachment.length, compact ? 1 : 2)
)}
</Text>
{!compact && (
<Text as="td" variant="body">
{attachment.dateUploaded
? formatDate(attachment.dateUploaded, {
dateStyle: "short",
timeStyle: "short"
})
: "-"}
</Text>
)}
</Box>
);
}
type MenuActionParams = {
attachment: any;
};
type MenuItemValue<T> = T | ((options: MenuActionParams) => T);
type MenuItem = {
type?: "separator";
key: string;
title?: MenuItemValue<string>;
icon?: MenuItemValue<Icon>;
onClick?: (options: MenuActionParams) => void;
disabled?: MenuItemValue<boolean | string>;
color?: MenuItemValue<string>;
iconColor?: MenuItemValue<string>;
multiSelect?: boolean;
items?: MenuItemValue<MenuItem[]>;
};
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])
}
];

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<Dialog
isOpen={true}
width={500}
title="Attachments"
description={`${attachments.length} attachments | ${formatBytes(
getTotalSize(attachments)
)} occupied`}
onClose={onClose}
noScroll
negativeButton={{ text: "Close", onClick: onClose }}
positiveButton={{
text: "Download All Attachments",
onClick: async () => {
await register();
await new AttachmentStream(attachments)
.pipeThrough(new ZipStream())
.pipeTo(createWriteStream("attachments.zip"));
}
}}
show
>
<Flex px={2} sx={{ flexDirection: "column", height: 500 }}>
<Field
placeholder="Filter attachments by filename, type or hash"
sx={{ mb: 1, px: 1 }}
onChange={(e) => filter(e.target.value)}
/>
<ListContainer
header={<div />}
type="attachments"
groupType="attachments"
placeholder={<Placeholder context="attachments" />}
items={attachments}
/>
</Flex>
</Dialog>
);
}
export default AttachmentsDialog;

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<any[]>(allAttachments);
const [selected, setSelected] = useState<string[]>([]);
const [sortBy, setSortBy] = useState({ id: "name", direction: "asc" });
const currentRoute = useRef<Route>("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 (
<Dialog
isOpen={true}
width={"70%"}
onClose={() => onClose(false)}
noScroll
sx={{ bg: "transparent" }}
>
<Flex
sx={{
height: "80vw"
}}
>
<Sidebar
totalSize={totalSize}
filter={(query) => {
setAttachments(
db.lookup?.attachments(db.attachments?.all || [], query) || []
);
}}
counts={getCounts(allAttachments)}
onRouteChange={(route) => {
currentRoute.current = route;
setSelected([]);
setAttachments(filterAttachments(route, allAttachments));
}}
/>
<Flex
variant="columnFill"
sx={{
bg: "background",
flexDirection: "column",
px: 4,
pt: 2,
overflowY: "hidden",
overflow: "hidden",
table: { width: "100%" },
"tbody::before": {
content: `''`,
display: "block",
height: 5
}
}}
>
<Flex sx={{ justifyContent: "space-between" }}>
<Flex sx={{ gap: 1 }}>
{TOOLBAR_ACTIONS.map((tool) => (
<Button
variant="tool"
key={tool.title}
title={tool.title}
onClick={() =>
tool.onClick({
selected: attachments.filter(
(a) => selected.indexOf(a.id) > -1
)
})
}
disabled={!selected.length}
sx={{ bg: "transparent", p: 1 }}
>
<tool.icon size={18} />
</Button>
))}
</Flex>
{/* <Button
variant="tool"
sx={{ p: 1, display: "flex" }}
onClick={async () => {
if (!(await insertAttachment())) return;
refresh();
}}
>
<Plus size={18} />
<Text variant="body" sx={{ ml: 1 }}>
Upload
</Text>
</Button> */}
</Flex>
<TableVirtuoso
components={{
Scroller: CustomScrollbarsVirtualList,
TableRow: (props) => {
const attachment = attachments[props["data-item-index"]];
return (
<Attachment
{...props}
attachment={attachment}
isSelected={selected.indexOf(attachment.id) > -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={() => (
<Box
as="tr"
sx={{
height: 40,
th: { borderBottom: "1px solid var(--border)" },
bg: "background"
}}
>
<Text
as="th"
variant="body"
sx={{
width: 24,
textAlign: "left",
fontWeight: "normal",
mb: 2
}}
>
<Label>
<Checkbox
sx={{ width: 18, height: 18 }}
onChange={(e) => {
setSelected(
e.currentTarget.checked
? attachments.map((a) => a.id)
: []
);
}}
/>
</Label>
</Text>
{[
{ id: "name", title: "Name" },
{ id: "status" },
{ id: "size", title: "Size" },
{ id: "dateUploaded", title: "Date uploaded" }
].map((column) =>
!column.title ? (
<th key={column.id} />
) : (
<Box
as="th"
key={column.id}
sx={{
width: "auto",
cursor: "pointer",
px: 1,
mb: 2,
":hover": { bg: "hover" }
}}
onClick={() => {
setSortBy((sortBy) => ({
direction:
sortBy.id === column.id &&
sortBy.direction === "asc"
? "desc"
: "asc",
id: column.id
}));
}}
>
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between"
}}
>
<Text
variant="body"
sx={{ textAlign: "left", fontWeight: "normal" }}
>
{column.title}
</Text>
{sortBy.id === column.id ? (
sortBy.direction === "asc" ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)
) : null}
</Flex>
</Box>
)
)}
</Box>
)}
itemContent={() => <></>}
/>
</Flex>
</Flex>
</Dialog>
);
}
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<Route, number>;
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 (
<Flex
sx={{
flexDirection: "column",
justifyContent: "space-between",
width: 240,
bg: "bgSecondary",
"@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none))":
{
bg: "bgTransparent",
backdropFilter: "blur(8px)"
}
}}
>
<Flex sx={{ flexDirection: "column" }}>
<Input
placeholder="Search"
sx={{ m: 2, mb: 0, width: "auto", bg: "bgSecondary", py: "7px" }}
onChange={(e) => {
setRoute(e.target.value ? "none" : "all");
if (e.target.value) filter(e.target.value);
}}
/>
{routes.map((item) => (
<NavigationItem
key={item.id}
icon={item.icon}
title={item.title}
count={counts[item.id]}
onClick={() => {
onRouteChange(item.id);
setRoute(item.id);
}}
selected={route === item.id}
/>
))}
</Flex>
<Flex sx={{ flexDirection: "column" }}>
<Flex sx={{ pl: 2, m: 2, mt: 1, justifyContent: "space-between" }}>
<Flex sx={{ flexDirection: "column" }}>
<Text variant="body">
{pluralize(counts.all, "file", "files")}
</Text>
<Text variant="subBody">{formatBytes(totalSize)}</Text>
</Flex>
<Button
variant="tool"
sx={{
bg: "transparent",
borderRadius: 100,
position: "relative",
width: 38,
height: 38
}}
title="Download all attachments"
onClick={async () => {
if (downloadStatus) {
await cancelDownload();
} else {
await download(db.attachments?.all);
}
}}
>
{downloadStatus ? <Close size={18} /> : <Download size={18} />}
{downloadStatus ? (
<Donut
value={(downloadStatus.current / downloadStatus.total) * 100}
max={100}
size={38}
sx={{
position: "absolute",
top: 0,
left: 0
}}
/>
) : null}
</Button>
</Flex>
</Flex>
</Flex>
);
},
(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<Route, number> {
const counts: Record<Route, number> = {
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);
}

View File

@@ -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<Element, MouseEvent> | React.KeyboardEvent<Element>
@@ -47,6 +48,7 @@ type DialogProps = {
positiveButton?: DialogButtonProps | null;
negativeButton?: DialogButtonProps | null;
footer?: React.Component;
noScroll?: boolean;
};
function BaseDialog(props: React.PropsWithChildren<DialogProps>) {
@@ -94,7 +96,9 @@ function BaseDialog(props: React.PropsWithChildren<DialogProps>) {
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<DialogProps>) {
onClick={props.onClose}
/>
)}
<Flex sx={{ flexDirection: "column" }} p={4} pb={0}>
<Text
variant="heading"
sx={{
fontSize: "subheading",
textAlign: props.textAlignment || "left",
color: "text"
}}
>
{props.title}
</Text>
{props.description && (
<Text
variant="body"
sx={{
textAlign: props.textAlignment || "left",
color: "fontTertiary"
}}
>
{props.description}
</Text>
)}
</Flex>
<Flex variant="columnFill" sx={{ overflowY: "hidden" }} my={1}>
<FlexScrollContainer style={{ paddingRight: 20, paddingLeft: 20 }}>
{props.children}
</FlexScrollContainer>
</Flex>
{props.title || props.description ? (
<Flex sx={{ flexDirection: "column" }} p={4} pb={0}>
{props.title && (
<Text
variant="heading"
sx={{
fontSize: "subheading",
textAlign: props.textAlignment || "left",
color: "text"
}}
>
{props.title}
</Text>
)}
{props.description && (
<Text
variant="body"
sx={{
textAlign: props.textAlignment || "left",
color: "fontTertiary"
}}
>
{props.description}
</Text>
)}
</Flex>
) : null}
{props.noScroll ? (
<>{props.children}</>
) : (
<Flex variant="columnFill" sx={{ overflowY: "hidden" }} my={1}>
<FlexScrollContainer style={{ paddingRight: 20, paddingLeft: 20 }}>
{props.children}
</FlexScrollContainer>
</Flex>
)}
{(props.positiveButton || props.negativeButton) && (
<Flex

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<MDIIconWrapperProps, "path"> & {
hoverColor?: keyof Theme["colors"];
};
Omit<MDIIconWrapperProps, "path">;
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 (
<Flex
@@ -262,17 +266,13 @@ function createIcon(path: string, rotate = false) {
alignItems: "center",
flexShrink: 0
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<MDIIconWrapper
title={props.title}
path={path}
rotate={_rotate}
size={size}
color={
props.hoverColor && isHovering ? props.hoverColor : props.color
}
color={props.color}
/>
</Flex>
);
@@ -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);

View File

@@ -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<HTMLDivElement, ScrollerProps>(
function CustomScrollbarsVirtualList(props, ref) {
return (
<ScrollContainer
{...props}
forwardedRef={(sRef) => {
if (typeof ref === "function") ref(sRef);
else if (ref) ref.current = sRef;
}}
/>
);
}
);
export const CustomScrollbarsVirtualList = forwardRef<
HTMLDivElement,
ScrollerProps
>(function CustomScrollbarsVirtualList(props, ref) {
return (
<ScrollContainer
{...props}
forwardedRef={(sRef) => {
if (typeof ref === "function") ref(sRef);
else if (ref) ref.current = sRef;
}}
/>
);
});
type ListContainerProps = {
type: keyof typeof ListProfiles;

View File

@@ -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 }) => (
<TrashItem index={index} item={item} date={getDate(item, type)} />
);
const AttachmentProfile: ItemWrapper = ({ index, item }) => (
<Attachment index={index} item={item} isCompact={false} />
);
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":

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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<NavigationItemProps>) {
index = 0
} = props;
const toggleSideMenu = useAppStore((store) => store.toggleSideMenu);
const { openMenu } = useMenuTrigger();
const isMobile = useMobile();
return (
@@ -105,7 +104,7 @@ function NavigationItem(props: PropsWithChildren<NavigationItemProps>) {
onContextMenu={(e) => {
if (!menuItems) return;
e.preventDefault();
openMenu(menuItems);
Menu.openMenu(menuItems);
}}
onClick={() => {
if (isMobile) toggleSideMenu(false);

View File

@@ -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) => (
<Attachment
key={attachment.id}
item={attachment}
index={i}
isCompact
/>
))}
<table style={{ borderSpacing: 0 }}>
<tbody>
{attachments.map((attachment, i) => (
<Attachment
key={attachment.id}
compact
attachment={attachment}
/>
))}
</tbody>
</table>
</Card>
)}
<Card

View File

@@ -0,0 +1,100 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
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++;
}
}
}
}

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -17,22 +17,37 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<ZipFile> {
constructor(attachments: Array<any>) {
constructor(
attachments: Array<any>,
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

View File

@@ -25,14 +25,17 @@ export class Base64DecoderStream extends TransformStream<Uint8Array, string> {
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;

View File

@@ -27,26 +27,24 @@ export class ChunkedStream extends TransformStream<Uint8Array, Uint8Array> {
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;
}
},

View File

@@ -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)
);
}

View File

@@ -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)
);

View File

@@ -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/");
}