mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
web: improve attachments manager ui/ux
This commit is contained in:
committed by
Abdullah Atta
parent
1043b7df55
commit
8a475f654f
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
294
apps/web/src/components/attachment/index.tsx
Normal file
294
apps/web/src/components/attachment/index.tsx
Normal 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])
|
||||
}
|
||||
];
|
||||
@@ -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;
|
||||
514
apps/web/src/components/dialogs/attachments-dialog.tsx
Normal file
514
apps/web/src/components/dialogs/attachments-dialog.tsx
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
100
apps/web/src/interfaces/chunk-distributor.js
Normal file
100
apps/web/src/interfaces/chunk-distributor.js
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user