web: fix note editor properties

This commit is contained in:
Abdullah Atta
2023-11-15 15:11:51 +05:00
parent 7e8e981145
commit 2168609577
36 changed files with 1516 additions and 1094 deletions

View File

@@ -33,6 +33,7 @@
"@react-pdf-viewer/core": "^3.12.0", "@react-pdf-viewer/core": "^3.12.0",
"@react-pdf-viewer/toolbar": "^3.12.0", "@react-pdf-viewer/toolbar": "^3.12.0",
"@tanstack/react-query": "^4.29.19", "@tanstack/react-query": "^4.29.19",
"@tanstack/react-virtual": "^3.0.0-beta.68",
"@theme-ui/color": "^0.14.7", "@theme-ui/color": "^0.14.7",
"@theme-ui/components": "^0.14.7", "@theme-ui/components": "^0.14.7",
"@theme-ui/core": "^0.14.7", "@theme-ui/core": "^0.14.7",
@@ -68,7 +69,6 @@
"react-modal": "3.13.1", "react-modal": "3.13.1",
"react-qrcode-logo": "^2.2.1", "react-qrcode-logo": "^2.2.1",
"react-scroll-sync": "^0.9.0", "react-scroll-sync": "^0.9.0",
"react-virtuoso": "^4.4.2",
"timeago.js": "4.0.2", "timeago.js": "4.0.2",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",
"w3c-keyname": "^2.2.6", "w3c-keyname": "^2.2.6",
@@ -24335,6 +24335,7 @@
"async-mutex": "^0.3.2", "async-mutex": "^0.3.2",
"dayjs": "1.11.9", "dayjs": "1.11.9",
"entities": "^4.3.1", "entities": "^4.3.1",
"fuzzyjs": "^5.0.1",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"katex": "0.16.2", "katex": "0.16.2",
@@ -40764,6 +40765,31 @@
} }
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.0.0-beta.68",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.68.tgz",
"integrity": "sha512-YEFNCf+N3ZlNou2r4qnh+GscMe24foYEjTL05RS0ZHvah2RoUDPGuhnuedTv0z66dO2vIq6+Bl4TXatht5T7GQ==",
"dependencies": {
"@tanstack/virtual-core": "3.0.0-beta.68"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.0.0-beta.68",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.68.tgz",
"integrity": "sha512-CnvsEJWK7cugigckt13AeY80FMzH+OMdEP0j0bS3/zjs44NiRe49x8FZC6R9suRXGMVMXtUHet0zbTp/Ec9Wfg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@theme-ui/color": { "node_modules/@theme-ui/color": {
"version": "0.14.7", "version": "0.14.7",
"license": "MIT", "license": "MIT",
@@ -46406,17 +46432,6 @@
"react-dom": "0.14.x || 15.x || 16.x || 17.x" "react-dom": "0.14.x || 15.x || 16.x || 17.x"
} }
}, },
"node_modules/react-virtuoso": {
"version": "4.6.2",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16 || >=17 || >= 18",
"react-dom": ">=16 || >=17 || >= 18"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"license": "MIT", "license": "MIT",

View File

@@ -32,6 +32,7 @@
"@react-pdf-viewer/core": "^3.12.0", "@react-pdf-viewer/core": "^3.12.0",
"@react-pdf-viewer/toolbar": "^3.12.0", "@react-pdf-viewer/toolbar": "^3.12.0",
"@tanstack/react-query": "^4.29.19", "@tanstack/react-query": "^4.29.19",
"@tanstack/react-virtual": "^3.0.0-beta.68",
"@theme-ui/color": "^0.14.7", "@theme-ui/color": "^0.14.7",
"@theme-ui/components": "^0.14.7", "@theme-ui/components": "^0.14.7",
"@theme-ui/core": "^0.14.7", "@theme-ui/core": "^0.14.7",
@@ -67,7 +68,6 @@
"react-modal": "3.13.1", "react-modal": "3.13.1",
"react-qrcode-logo": "^2.2.1", "react-qrcode-logo": "^2.2.1",
"react-scroll-sync": "^0.9.0", "react-scroll-sync": "^0.9.0",
"react-virtuoso": "^4.4.2",
"timeago.js": "4.0.2", "timeago.js": "4.0.2",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",
"w3c-keyname": "^2.2.6", "w3c-keyname": "^2.2.6",

View File

@@ -45,12 +45,12 @@ export async function saveAttachment(hash: string) {
if (!response) return; if (!response) return;
const { attachment, key } = response; const { attachment, key } = response;
await lazify(import("../interfaces/fs"), ({ default: FS }) => await lazify(import("../interfaces/fs"), ({ saveFile }) =>
FS.saveFile(attachment.metadata.hash, { saveFile(attachment.metadata.hash, {
key, key,
iv: attachment.iv, iv: attachment.iv,
name: attachment.metadata.filename, name: attachment.filename,
type: attachment.metadata.type, type: attachment.mimeType,
isUploaded: !!attachment.dateUploaded isUploaded: !!attachment.dateUploaded
}) })
); );
@@ -76,12 +76,12 @@ export async function downloadAttachment<
if (type === "base64" || type === "text") if (type === "base64" || type === "text")
return (await db.attachments.read(hash, type)) as TOutputType; return (await db.attachments.read(hash, type)) as TOutputType;
const blob = await lazify(import("../interfaces/fs"), ({ default: FS }) => const blob = await lazify(import("../interfaces/fs"), ({ decryptFile }) =>
FS.decryptFile(attachment.metadata.hash, { decryptFile(attachment.metadata.hash, {
key, key,
iv: attachment.iv, iv: attachment.iv,
name: attachment.metadata.filename, name: attachment.filename,
type: attachment.metadata.type, type: attachment.mimeType,
isUploaded: !!attachment.dateUploaded isUploaded: !!attachment.dateUploaded
}) })
); );
@@ -95,8 +95,9 @@ export async function checkAttachment(hash: string) {
if (!attachment) return { failed: "Attachment not found." }; if (!attachment) return { failed: "Attachment not found." };
try { try {
const size = await lazify(import("../interfaces/fs"), ({ default: FS }) => const size = await lazify(
FS.getUploadedFileSize(hash) import("../interfaces/fs"),
({ getUploadedFileSize }) => getUploadedFileSize(hash)
); );
if (size <= 0) return { failed: "File length is 0." }; if (size <= 0) return { failed: "File length is 0." };
} catch (e) { } catch (e) {
@@ -104,12 +105,3 @@ export async function checkAttachment(hash: string) {
} }
return { success: true }; return { success: true };
} }
const ABYTES = 17;
export function getTotalSize(attachments: Attachment[]) {
let size = 0;
for (const attachment of attachments) {
size += attachment.length + ABYTES;
}
return size;
}

View File

@@ -33,18 +33,16 @@ import {
FileWebClip, FileWebClip,
Icon, Icon,
Loading, Loading,
References,
Rename, Rename,
Reupload, Reupload,
Uploading Uploading
} from "../icons"; } from "../icons";
import { showToast } from "../../utils/toast";
import { hashNavigate } from "../../navigation"; import { hashNavigate } from "../../navigation";
import { import {
closeOpenedDialog, closeOpenedDialog,
showPromptDialog showPromptDialog
} from "../../common/dialog-controller"; } from "../../common/dialog-controller";
import { store } from "../../stores/attachment-store"; import { store, useStore } from "../../stores/attachment-store";
import { db } from "../../common/db"; import { db } from "../../common/db";
import { saveAttachment } from "../../common/attachments"; import { saveAttachment } from "../../common/attachments";
import { reuploadAttachment } from "../editor/picker"; import { reuploadAttachment } from "../editor/picker";
@@ -55,11 +53,11 @@ import {
WebClipMimeType, WebClipMimeType,
PDFMimeType PDFMimeType
} from "@notesnook/core/dist/utils/filename"; } from "@notesnook/core/dist/utils/filename";
import { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { AppEventManager, AppEvents } from "../../common/app-events"; import { AppEventManager, AppEvents } from "../../common/app-events";
import { getFormattedDate } from "@notesnook/common"; import { getFormattedDate } from "@notesnook/common";
import { MenuItem } from "@notesnook/ui"; import { MenuItem } from "@notesnook/ui";
import { Attachment } from "@notesnook/core"; import { Attachment as AttachmentType } from "@notesnook/core";
const FILE_ICONS: Record<string, Icon> = { const FILE_ICONS: Record<string, Icon> = {
"image/": FileImage, "image/": FileImage,
@@ -86,24 +84,29 @@ type AttachmentProgressStatus = {
}; };
type AttachmentProps = { type AttachmentProps = {
attachment: Attachment; item: AttachmentType;
isSelected?: boolean; isSelected?: boolean;
onSelected?: () => void; onSelected?: () => void;
compact?: boolean; compact?: boolean;
style?: React.CSSProperties;
rowRef?: React.Ref<HTMLTableRowElement>;
}; };
export function Attachment({ export function Attachment({
attachment, item,
isSelected, isSelected,
onSelected, onSelected,
compact compact,
rowRef,
style
}: AttachmentProps) { }: AttachmentProps) {
const [status, setStatus] = useState<AttachmentProgressStatus>(); const [status, setStatus] = useState<AttachmentProgressStatus>();
const processing = useStore((store) => store.processing[item.hash]);
useEffect(() => { useEffect(() => {
const event = AppEventManager.subscribe( const event = AppEventManager.subscribe(
AppEvents.UPDATE_ATTACHMENT_PROGRESS, AppEvents.UPDATE_ATTACHMENT_PROGRESS,
(progress: any) => { (progress: any) => {
if (progress.hash === attachment.metadata.hash) { if (progress.hash === item.hash) {
const percent = Math.round((progress.loaded / progress.total) * 100); const percent = Math.round((progress.loaded / progress.total) * 100);
setStatus( setStatus(
percent < 100 percent < 100
@@ -120,18 +123,20 @@ export function Attachment({
return () => { return () => {
event.unsubscribe(); event.unsubscribe();
}; };
}, [attachment.metadata.hash]); }, [item.hash]);
const FileIcon = getFileIcon(attachment.metadata.type); const FileIcon = getFileIcon(item.type);
return ( return (
<Box <Box
as="tr" as="tr"
sx={{ height: 30, ":hover": { bg: "hover" } }} sx={{ height: 30, ":hover": { bg: "hover" } }}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
Menu.openMenu(AttachmentMenuItems(attachment, status)); Menu.openMenu(AttachmentMenuItems(item, status));
}} }}
onClick={onSelected} onClick={onSelected}
style={style}
ref={rowRef}
> >
{!compact && ( {!compact && (
<td> <td>
@@ -161,7 +166,7 @@ export function Attachment({
) : ( ) : (
<Uploading size={16} color="accent" /> <Uploading size={16} color="accent" />
) )
) : attachment.failed ? ( ) : processing?.failed || item.failed ? (
<AttachmentError <AttachmentError
color={"icon-error"} color={"icon-error"}
size={16} size={16}
@@ -171,7 +176,7 @@ export function Attachment({
: attachment.failed : attachment.failed
} }
/> />
) : attachment.working ? ( ) : processing?.working ? (
<Loading size={16} /> <Loading size={16} />
) : ( ) : (
<FileIcon size={16} /> <FileIcon size={16} />
@@ -185,14 +190,12 @@ export function Attachment({
textOverflow: "ellipsis" textOverflow: "ellipsis"
}} }}
> >
{attachment.metadata.filename} {item.filename}
</Text> </Text>
</Flex> </Flex>
</td> </td>
<Text as="td" variant="body"> <Text as="td" variant="body">
{attachment.isDeleting ? ( {item.dateUploaded ? (
<Loading sx={{ flexShrink: 0 }} size={16} title={"Deleting.."} />
) : attachment.dateUploaded ? (
<DoubleCheckmark <DoubleCheckmark
sx={{ flexShrink: 0 }} sx={{ flexShrink: 0 }}
color={"accent"} color={"accent"}
@@ -217,13 +220,13 @@ export function Attachment({
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)} {formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}
</> </>
) : ( ) : (
formatBytes(attachment.length, compact ? 1 : 2) formatBytes(item.size, compact ? 1 : 2)
)} )}
</Text> </Text>
{!compact && ( {!compact && (
<Text as="td" variant="body"> <Text as="td" variant="body">
{attachment.dateUploaded {item.dateUploaded
? getFormattedDate(attachment.dateUploaded, "date") ? getFormattedDate(item.dateUploaded, "date")
: "-"} : "-"}
</Text> </Text>
)} )}
@@ -232,37 +235,28 @@ export function Attachment({
} }
const AttachmentMenuItems: ( const AttachmentMenuItems: (
attachment: any, attachment: AttachmentType,
status?: AttachmentProgressStatus status?: AttachmentProgressStatus
) => MenuItem[] = (attachment, status) => { ) => MenuItem[] = (attachment, status) => {
return [ return [
{ {
type: "button",
key: "notes", key: "notes",
title: "Notes", type: "lazy-loader",
icon: References.path, async items() {
menu: { const menuItems: MenuItem[] = [];
items: (attachment.noteIds as string[]).reduce((prev, curr) => { for await (const note of db.relations.from(attachment, "note")
const note = db.notes.note(curr); .selector) {
if (!note) menuItems.push({
prev.push({ type: "button",
type: "button", key: note.id,
key: curr, title: note.title,
title: `Note with id ${curr}`, onClick: () => {
onClick: () => showToast("error", "This note does not exist.") hashNavigate(`/notes/${note.id}/edit`);
}); closeOpenedDialog();
else }
prev.push({ });
type: "button", }
key: note.id, return menuItems;
title: note.title,
onClick: () => {
hashNavigate(`/notes/${curr}/edit`);
closeOpenedDialog();
}
});
return prev;
}, [] as MenuItem[])
} }
}, },
{ {
@@ -272,7 +266,7 @@ const AttachmentMenuItems: (
icon: DoubleCheckmark.path, icon: DoubleCheckmark.path,
isDisabled: !attachment.dateUploaded, isDisabled: !attachment.dateUploaded,
onClick: async () => { onClick: async () => {
await store.recheck([attachment.metadata.hash]); await store.recheck([attachment.id]);
} }
}, },
{ {
@@ -283,11 +277,11 @@ const AttachmentMenuItems: (
onClick: async () => { onClick: async () => {
const newName = await showPromptDialog({ const newName = await showPromptDialog({
title: "Rename attachment", title: "Rename attachment",
description: attachment.metadata.filename, description: attachment.filename,
defaultValue: attachment.metadata.filename defaultValue: attachment.filename
}); });
if (!newName) return; if (!newName) return;
await store.rename(attachment.metadata.hash, newName); await store.rename(attachment.hash, newName);
} }
}, },
{ {
@@ -298,8 +292,8 @@ const AttachmentMenuItems: (
onClick: async () => { onClick: async () => {
const isDownloading = status?.type === "download"; const isDownloading = status?.type === "download";
if (isDownloading) { if (isDownloading) {
await db.fs().cancel(attachment.metadata.hash, "download"); await db.fs().cancel(attachment.hash, "download");
} else await saveAttachment(attachment.metadata.hash); } else await saveAttachment(attachment.hash);
} }
}, },
{ {
@@ -310,12 +304,8 @@ const AttachmentMenuItems: (
onClick: async () => { onClick: async () => {
const isDownloading = status?.type === "upload"; const isDownloading = status?.type === "upload";
if (isDownloading) { if (isDownloading) {
await db.fs().cancel(attachment.metadata.hash, "upload"); await db.fs().cancel(attachment.hash, "upload");
} else } else await reuploadAttachment(attachment.type, attachment.hash);
await reuploadAttachment(
attachment.metadata.type,
attachment.metadata.hash
);
} }
}, },
{ {
@@ -324,7 +314,7 @@ const AttachmentMenuItems: (
variant: "dangerous", variant: "dangerous",
title: "Delete permanently", title: "Delete permanently",
icon: DeleteForver.path, icon: DeleteForver.path,
onClick: () => Multiselect.deleteAttachments([attachment]) onClick: () => Multiselect.deleteAttachments([attachment.id])
} }
]; ];
}; };

View File

@@ -56,7 +56,8 @@ function CachedRouter() {
sx={{ sx={{
display: key === RouteResult.key ? "flex" : "none", display: key === RouteResult.key ? "flex" : "none",
flexDirection: "column", flexDirection: "column",
flex: 1 flex: 1,
overflow: "hidden"
}} }}
> >
<Component key={key} {...RouteResult.props} /> <Component key={key} {...RouteResult.props} />

View File

@@ -62,15 +62,10 @@ import {
isDeleted isDeleted
} from "@notesnook/core/dist/types"; } from "@notesnook/core/dist/types";
import { isEncryptedContent } from "@notesnook/core/dist/collections/content"; import { isEncryptedContent } from "@notesnook/core/dist/collections/content";
import { PreviewSession } from "./types";
const PDFPreview = React.lazy(() => import("../pdf-preview")); const PDFPreview = React.lazy(() => import("../pdf-preview"));
type PreviewSession = {
content: { data: string; type: ContentType };
dateCreated: number;
dateEdited: number;
};
type DocumentPreview = { type DocumentPreview = {
url?: string; url?: string;
hash: string; hash: string;

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Attachment } from "@notesnook/editor"; import { Attachment } from "@notesnook/editor";
import { ContentType } from "@notesnook/core";
export type NoteStatistics = { export type NoteStatistics = {
words: { words: {
@@ -37,3 +38,9 @@ export interface IEditor {
attachFile: (file: Attachment) => void; attachFile: (file: Attachment) => void;
sendAttachmentProgress: (hash: string, progress: number) => void; sendAttachmentProgress: (hash: string, progress: number) => void;
} }
export type PreviewSession = {
content: { data: string; type: ContentType };
dateCreated: number;
dateEdited: number;
};

View File

@@ -17,15 +17,9 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { forwardRef, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Flex, Button } from "@theme-ui/components"; import { Flex, Button } from "@theme-ui/components";
import { Plus } from "../icons"; import { Plus } from "../icons";
import {
ItemProps,
ScrollerProps,
Virtuoso,
VirtuosoHandle
} from "react-virtuoso";
import { import {
useStore as useSelectionStore, useStore as useSelectionStore,
store as selectionStore store as selectionStore
@@ -34,31 +28,17 @@ import GroupHeader from "../group-header";
import { DEFAULT_ITEM_HEIGHT, ListItemWrapper } from "./list-profiles"; import { DEFAULT_ITEM_HEIGHT, ListItemWrapper } from "./list-profiles";
import Announcements from "../announcements"; import Announcements from "../announcements";
import { ListLoader } from "../loaders/list-loader"; import { ListLoader } from "../loaders/list-loader";
import ScrollContainer from "../scroll-container"; import { FlexScrollContainer } from "../scroll-container";
import { useKeyboardListNavigation } from "../../hooks/use-keyboard-list-navigation"; import { useKeyboardListNavigation } from "../../hooks/use-keyboard-list-navigation";
import { Context } from "./types"; import { Context } from "./types";
import { import {
VirtualizedGrouping, VirtualizedGrouping,
GroupHeader as GroupHeaderType,
GroupingKey, GroupingKey,
Item, Item,
isGroupHeader isGroupHeader
} from "@notesnook/core"; } from "@notesnook/core";
import { VirtualizedList } from "../virtualized-list";
export const CustomScrollbarsVirtualList = forwardRef< import { Virtualizer } from "@tanstack/react-virtual";
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 ListContainerProps = {
group?: GroupingKey; group?: GroupingKey;
@@ -87,8 +67,7 @@ function ListContainer(props: ListContainerProps) {
(store) => store.toggleSelectionMode (store) => store.toggleSelectionMode
); );
const listRef = useRef<VirtuosoHandle>(null); const listRef = useRef<Virtualizer<Element, Element>>();
const listContainerRef = useRef(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -125,7 +104,7 @@ function ListContainer(props: ListContainerProps) {
}); });
return ( return (
<Flex variant="columnFill"> <Flex variant="columnFill" sx={{ overflow: "hidden" }}>
{!props.items.ids.length && props.placeholder ? ( {!props.items.ids.length && props.placeholder ? (
<> <>
{header} {header}
@@ -139,29 +118,25 @@ function ListContainer(props: ListContainerProps) {
</> </>
) : ( ) : (
<> <>
<Flex <FlexScrollContainer
ref={listContainerRef} style={{ display: "flex", flexDirection: "column", flex: 1 }}
variant="columnFill"
data-test-id={`${group}-list`} data-test-id={`${group}-list`}
> >
<Virtuoso {header ? header : <Announcements />}
ref={listRef} <VirtualizedList
data={items.ids} virtualizerRef={listRef}
computeItemKey={(index) => items.getKey(index)} estimatedSize={DEFAULT_ITEM_HEIGHT}
defaultItemHeight={DEFAULT_ITEM_HEIGHT} getItemKey={(index) => items.getKey(index)}
totalCount={items.ids.length} items={items.ids}
mode="dynamic"
tabIndex={-1}
onBlur={() => setFocusedGroupIndex(-1)} onBlur={() => setFocusedGroupIndex(-1)}
onKeyDown={(e) => onKeyDown(e.nativeEvent)} onKeyDown={(e) => onKeyDown(e.nativeEvent)}
components={{ itemWrapperProps={(_, index) => ({
Scroller: CustomScrollbarsVirtualList, onFocus: () => onFocus(index),
Item: VirtuosoItem, onMouseDown: (e) => onMouseDown(e.nativeEvent, index)
Header: () => (header ? header : <Announcements />) })}
}} renderItem={({ index, item }) => {
context={{
onMouseDown,
onFocus
}}
itemContent={(index, item) => {
if (isGroupHeader(item)) { if (isGroupHeader(item)) {
if (!group) return null; if (!group) return null;
return ( return (
@@ -197,8 +172,7 @@ function ListContainer(props: ListContainerProps) {
(v) => isGroupHeader(v) && v.title === title (v) => isGroupHeader(v) && v.title === title
); );
if (index < 0) return; if (index < 0) return;
listRef.current?.scrollToIndex({ listRef.current?.scrollToIndex(index, {
index,
align: "center", align: "center",
behavior: "auto" behavior: "auto"
}); });
@@ -220,7 +194,7 @@ function ListContainer(props: ListContainerProps) {
); );
}} }}
/> />
</Flex> </FlexScrollContainer>
</> </>
)} )}
{button && ( {button && (
@@ -250,29 +224,6 @@ function ListContainer(props: ListContainerProps) {
} }
export default ListContainer; export default ListContainer;
function VirtuosoItem({
item: _item,
context,
...props
}: ItemProps<string | GroupHeaderType> & {
context?: {
onMouseDown: (e: MouseEvent, itemIndex: number) => void;
onFocus: (itemIndex: number) => void;
};
}) {
return (
<div
{...props}
onFocus={() => context?.onFocus(props["data-item-index"])}
onMouseDown={(e) =>
context?.onMouseDown(e.nativeEvent, props["data-item-index"])
}
>
{props.children}
</div>
);
}
/** /**
* Scroll the element at the specified index into view and * Scroll the element at the specified index into view and
* wait until it renders into the DOM. This function keeps * wait until it renders into the DOM. This function keeps
@@ -281,29 +232,28 @@ function VirtuosoItem({
* 50ms interval. * 50ms interval.
*/ */
function waitForElement( function waitForElement(
list: VirtuosoHandle, list: Virtualizer<Element, Element>,
index: number, index: number,
elementId: string, elementId: string,
callback: (element: HTMLElement) => void callback: (element: HTMLElement) => void
) { ) {
let waitInterval = 0; let waitInterval = 0;
let maxAttempts = 3; let maxAttempts = 3;
list.scrollIntoView({ list.scrollToIndex(index);
index, function scrollDone() {
done: function scrollDone() { if (!maxAttempts) return;
if (!maxAttempts) return; clearTimeout(waitInterval);
clearTimeout(waitInterval);
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) { if (!element) {
--maxAttempts; --maxAttempts;
waitInterval = setTimeout(() => { waitInterval = setTimeout(() => {
scrollDone(); scrollDone();
}, 50) as unknown as number; }, 50) as unknown as number;
return; return;
}
callback(element);
} }
});
callback(element);
}
scrollDone();
} }

View File

@@ -23,121 +23,70 @@ import Tag from "../tag";
import TrashItem from "../trash-item"; import TrashItem from "../trash-item";
import { db } from "../../common/db"; import { db } from "../../common/db";
import Reminder from "../reminder"; import Reminder from "../reminder";
import { import { Context } from "./types";
Context,
TagsWithDateEdited,
WithDateEdited,
NotebooksWithDateEdited
} from "./types";
import { getSortValue } from "@notesnook/core/dist/utils/grouping"; import { getSortValue } from "@notesnook/core/dist/utils/grouping";
import { import { GroupingKey, Item, VirtualizedGrouping } from "@notesnook/core";
GroupingKey, import { Attachment } from "../attachment";
Item, import { isNoteResolvedData, useResolvedItem } from "./resolved-item";
VirtualizedGrouping,
Color,
Reminder as ReminderItem
} from "@notesnook/core";
import { useEffect, useRef, useState } from "react";
import SubNotebook from "../sub-notebook";
const SINGLE_LINE_HEIGHT = 1.4; const SINGLE_LINE_HEIGHT = 1.4;
const DEFAULT_LINE_HEIGHT = const DEFAULT_LINE_HEIGHT =
(document.getElementById("p")?.clientHeight || 16) - 1; (document.getElementById("p")?.clientHeight || 16) - 1;
export const DEFAULT_ITEM_HEIGHT = SINGLE_LINE_HEIGHT * 2 * DEFAULT_LINE_HEIGHT; export const DEFAULT_ITEM_HEIGHT = SINGLE_LINE_HEIGHT * 4 * DEFAULT_LINE_HEIGHT;
type ListItemWrapperProps<TItem = Item> = { type ListItemWrapperProps = {
group?: GroupingKey; group?: GroupingKey;
items: VirtualizedGrouping<TItem>; items: VirtualizedGrouping<Item>;
id: string; id: string;
context?: Context; context?: Context;
compact?: boolean; compact?: boolean;
simplified?: boolean;
}; };
export function ListItemWrapper(props: ListItemWrapperProps) { export function ListItemWrapper(props: ListItemWrapperProps) {
const { id, items, group, compact, context } = props; const { group, compact, context, simplified } = props;
const [item, setItem] = useState<Item>();
const tags = useRef<TagsWithDateEdited>();
const notebooks = useRef<NotebooksWithDateEdited>();
const reminder = useRef<ReminderItem>();
const color = useRef<Color>();
const totalNotes = useRef<number>(0);
useEffect(() => { const resolvedItem = useResolvedItem(props);
(async function () { if (!resolvedItem)
const { item, data } = (await items.item(id, resolveItems)) || {};
if (!item) return;
if (item.type === "note" && isNoteResolvedData(data)) {
tags.current = data.tags;
notebooks.current = data.notebooks;
reminder.current = data.reminder;
color.current = data.color;
} else if (item.type === "notebook" && typeof data === "number") {
totalNotes.current = data;
} else if (item.type === "tag" && typeof data === "number") {
totalNotes.current = data;
}
setItem(item);
})();
}, [id, items]);
if (!item)
return <div style={{ height: DEFAULT_ITEM_HEIGHT, width: "100%" }} />; return <div style={{ height: DEFAULT_ITEM_HEIGHT, width: "100%" }} />;
const { type } = item; const { data, item } = resolvedItem;
switch (type) { switch (item.type) {
case "note": { case "note": {
return ( return (
<Note <Note
compact={compact} compact={compact}
item={item} item={item}
tags={tags.current}
color={color.current}
notebooks={notebooks.current}
reminder={reminder.current}
date={getDate(item, group)} date={getDate(item, group)}
context={context} context={context}
{...(isNoteResolvedData(data) ? data : {})}
/> />
); );
} }
case "notebook": case "notebook":
if (context?.type === "notebook")
return (
<SubNotebook
item={item}
totalNotes={totalNotes.current}
notebookId={context.id}
/>
);
return ( return (
<Notebook <Notebook
item={item} item={item}
totalNotes={totalNotes.current} totalNotes={typeof data === "number" ? data : 0}
date={getDate(item, group)} date={getDate(item, group)}
simplified={simplified}
/> />
); );
case "trash": case "trash":
return <TrashItem item={item} date={getDate(item, type)} />; return <TrashItem item={item} date={getDate(item, group)} />;
case "reminder": case "reminder":
return <Reminder item={item} />; return <Reminder item={item} simplified={simplified} />;
case "tag": case "tag":
return <Tag item={item} totalNotes={totalNotes.current} />; return (
<Tag item={item} totalNotes={typeof data === "number" ? data : 0} />
);
case "attachment":
return <Attachment item={item} compact={compact} />;
default: default:
return null; return null;
} }
} }
function withDateEdited<
T extends { dateEdited: number } | { dateModified: number }
>(items: T[]): WithDateEdited<T> {
let latestDateEdited = 0;
items.forEach((item) => {
const date = "dateEdited" in item ? item.dateEdited : item.dateModified;
if (latestDateEdited < date) latestDateEdited = date;
});
return { dateEdited: latestDateEdited, items };
}
function getDate(item: Item, groupType?: GroupingKey): number { function getDate(item: Item, groupType?: GroupingKey): number {
return ( return (
getSortValue( getSortValue(
@@ -152,123 +101,3 @@ function getDate(item: Item, groupType?: GroupingKey): number {
) || 0 ) || 0
); );
} }
export async function resolveItems(ids: string[], items: Record<string, Item>) {
const { type } = items[ids[0]];
if (type === "note") return resolveNotes(ids);
else if (type === "notebook") {
const data: Record<string, number> = {};
for (const id of ids) data[id] = await db.notebooks.totalNotes(id);
return data;
} else if (type === "tag") {
const data: Record<string, number> = {};
for (const id of ids)
data[id] = await db.relations.from({ id, type: "tag" }, "note").count();
return data;
}
return {};
}
type NoteResolvedData = {
notebooks?: NotebooksWithDateEdited;
reminder?: ReminderItem;
color?: Color;
tags?: TagsWithDateEdited;
};
async function resolveNotes(ids: string[]) {
console.time("relations");
const relations = [
...(await db.relations
.to({ type: "note", ids }, ["notebook", "tag", "color"])
.get()),
...(await db.relations.from({ type: "note", ids }, "reminder").get())
];
console.timeEnd("relations");
console.log(
relations,
ids,
await db.relations
.from({ type: "notebook", id: "6549b4c373c7f3a40852f80c" }, "note")
.get()
);
const relationIds: {
notebooks: Set<string>;
colors: Set<string>;
tags: Set<string>;
reminders: Set<string>;
} = {
colors: new Set(),
notebooks: new Set(),
tags: new Set(),
reminders: new Set()
};
const grouped: Record<
string,
{
notebooks: string[];
color?: string;
tags: string[];
reminder?: string;
}
> = {};
for (const relation of relations) {
const noteId =
relation.toType === "relation" ? relation.fromId : relation.toId;
const data = grouped[noteId] || {
notebooks: [],
tags: []
};
if (relation.toType === "relation" && !data.reminder) {
data.reminder = relation.fromId;
relationIds.reminders.add(relation.fromId);
} else if (relation.fromType === "notebook" && data.notebooks.length < 2) {
data.notebooks.push(relation.fromId);
relationIds.notebooks.add(relation.fromId);
} else if (relation.fromType === "tag" && data.tags.length < 3) {
data.tags.push(relation.fromId);
relationIds.tags.add(relation.fromId);
} else if (relation.fromType === "color" && !data.color) {
data.color = relation.fromId;
relationIds.colors.add(relation.fromId);
}
grouped[relation.toId] = data;
}
console.time("resolve");
const resolved = {
notebooks: await db.notebooks.all.records(
Array.from(relationIds.notebooks)
),
tags: await db.tags.all.records(Array.from(relationIds.tags)),
colors: await db.colors.all.records(Array.from(relationIds.colors)),
reminders: await db.reminders.all.records(Array.from(relationIds.reminders))
};
console.timeEnd("resolve");
const data: Record<string, NoteResolvedData> = {};
for (const noteId in grouped) {
const group = grouped[noteId];
data[noteId] = {
color: group.color ? resolved.colors[group.color] : undefined,
reminder: group.reminder ? resolved.reminders[group.reminder] : undefined,
tags: withDateEdited(group.tags.map((id) => resolved.tags[id])),
notebooks: withDateEdited(
group.notebooks.map((id) => resolved.notebooks[id])
)
};
}
return data;
}
function isNoteResolvedData(data: unknown): data is NoteResolvedData {
return (
typeof data === "object" &&
!!data &&
"notebooks" in data &&
"reminder" in data &&
"color" in data &&
"tags" in data
);
}

View File

@@ -0,0 +1,174 @@
/*
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 { Color, Item, Reminder, VirtualizedGrouping } from "@notesnook/core";
import usePromise from "../../hooks/use-promise";
import {
NotebooksWithDateEdited,
TagsWithDateEdited,
WithDateEdited
} from "./types";
import { db } from "../../common/db";
import React from "react";
type ResolvedItemProps = {
items: VirtualizedGrouping<Item>;
id: string;
children: (item: { item: Item; data: unknown }) => React.ReactNode;
};
export function ResolvedItem(props: ResolvedItemProps) {
const { id, items, children } = props;
const result = usePromise(() => items.item(id, resolveItems), [id, items]);
if (result.status !== "fulfilled" || !result.value) return null;
return <>{children(result.value)}</>;
}
export function useResolvedItem(props: Omit<ResolvedItemProps, "children">) {
const { id, items } = props;
const result = usePromise(() => items.item(id, resolveItems), [id, items]);
if (result.status !== "fulfilled" || !result.value) return null;
return result.value;
}
function withDateEdited<
T extends { dateEdited: number } | { dateModified: number }
>(items: T[]): WithDateEdited<T> {
let latestDateEdited = 0;
items.forEach((item) => {
const date = "dateEdited" in item ? item.dateEdited : item.dateModified;
if (latestDateEdited < date) latestDateEdited = date;
});
return { dateEdited: latestDateEdited, items };
}
export async function resolveItems(ids: string[], items: Record<string, Item>) {
const { type } = items[ids[0]];
if (type === "note") return resolveNotes(ids);
else if (type === "notebook") {
const data: Record<string, number> = {};
for (const id of ids) data[id] = await db.notebooks.totalNotes(id);
return data;
} else if (type === "tag") {
const data: Record<string, number> = {};
for (const id of ids)
data[id] = await db.relations.from({ id, type: "tag" }, "note").count();
return data;
}
return {};
}
type NoteResolvedData = {
notebooks?: NotebooksWithDateEdited;
reminder?: Reminder;
color?: Color;
tags?: TagsWithDateEdited;
};
async function resolveNotes(ids: string[]) {
console.time("relations");
const relations = [
...(await db.relations
.to({ type: "note", ids }, ["notebook", "tag", "color"])
.get()),
...(await db.relations.from({ type: "note", ids }, "reminder").get())
];
console.timeEnd("relations");
const relationIds: {
notebooks: Set<string>;
colors: Set<string>;
tags: Set<string>;
reminders: Set<string>;
} = {
colors: new Set(),
notebooks: new Set(),
tags: new Set(),
reminders: new Set()
};
const grouped: Record<
string,
{
notebooks: string[];
color?: string;
tags: string[];
reminder?: string;
}
> = {};
for (const relation of relations) {
const noteId =
relation.toType === "reminder" ? relation.fromId : relation.toId;
const data = grouped[noteId] || {
notebooks: [],
tags: []
};
if (relation.toType === "reminder" && !data.reminder) {
data.reminder = relation.toId;
relationIds.reminders.add(relation.toId);
} else if (relation.fromType === "notebook" && data.notebooks.length < 2) {
data.notebooks.push(relation.fromId);
relationIds.notebooks.add(relation.fromId);
} else if (relation.fromType === "tag" && data.tags.length < 3) {
data.tags.push(relation.fromId);
relationIds.tags.add(relation.fromId);
} else if (relation.fromType === "color" && !data.color) {
data.color = relation.fromId;
relationIds.colors.add(relation.fromId);
}
grouped[noteId] = data;
}
console.time("resolve");
const resolved = {
notebooks: await db.notebooks.all.records(
Array.from(relationIds.notebooks)
),
tags: await db.tags.all.records(Array.from(relationIds.tags)),
colors: await db.colors.all.records(Array.from(relationIds.colors)),
reminders: await db.reminders.all.records(Array.from(relationIds.reminders))
};
console.timeEnd("resolve");
const data: Record<string, NoteResolvedData> = {};
for (const noteId in grouped) {
const group = grouped[noteId];
data[noteId] = {
color: group.color ? resolved.colors[group.color] : undefined,
reminder: group.reminder ? resolved.reminders[group.reminder] : undefined,
tags: withDateEdited(group.tags.map((id) => resolved.tags[id])),
notebooks: withDateEdited(
group.notebooks.map((id) => resolved.notebooks[id])
)
};
}
return data;
}
export function isNoteResolvedData(data: unknown): data is NoteResolvedData {
return (
typeof data === "object" &&
!!data &&
"notebooks" in data &&
"reminder" in data &&
"color" in data &&
"tags" in data
);
}

View File

@@ -183,7 +183,7 @@ function Note(props: NoteProps) {
icon={Notebook} icon={Notebook}
/> />
))} ))}
{reminder && isReminderActive(reminder) && ( {reminder && isReminderActive(reminder) ? (
<IconTag <IconTag
icon={Reminder} icon={Reminder}
text={getFormattedReminderTime(reminder, true)} text={getFormattedReminderTime(reminder, true)}
@@ -197,7 +197,7 @@ function Note(props: NoteProps) {
: {} : {}
} }
/> />
)} ) : null}
</Flex> </Flex>
} }
footer={ footer={

View File

@@ -1,426 +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 React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Pin,
StarOutline,
Unlock,
Readonly,
SyncOff,
ArrowLeft,
Circle,
Checkmark,
Lock
} from "../icons";
import { Flex, Text } from "@theme-ui/components";
import { useStore, store } from "../../stores/editor-store";
import { COLORS } from "../../common/constants";
import { db } from "../../common/db";
import { useStore as useAppStore } from "../../stores/app-store";
import { useStore as useAttachmentStore } from "../../stores/attachment-store";
import { store as noteStore } from "../../stores/note-store";
import { AnimatedFlex } from "../animated";
import Toggle from "./toggle";
import ScrollContainer from "../scroll-container";
import Vault from "../../common/vault";
import TimeAgo from "../time-ago";
import { Attachment } from "../attachment";
import { formatBytes } from "@notesnook/common";
import { getTotalSize } from "../../common/attachments";
import Notebook from "../notebook";
import Reminder from "../reminder";
import { getFormattedDate } from "@notesnook/common";
import { ScopedThemeProvider } from "../theme-provider";
const tools = [
{ key: "pin", property: "pinned", icon: Pin, label: "Pin" },
{
key: "favorite",
property: "favorite",
icon: StarOutline,
label: "Favorite"
},
{ key: "lock", icon: Unlock, label: "Lock", property: "locked" },
{
key: "readonly",
icon: Readonly,
label: "Readonly",
property: "readonly"
},
{
key: "local-only",
icon: SyncOff,
label: "Disable sync",
property: "localOnly"
}
];
const metadataItems = [
{
key: "dateCreated",
label: "Created at",
value: (date) => getFormattedDate(date || Date.now())
},
{
key: "dateEdited",
label: "Last edited at",
value: (date) => (date ? getFormattedDate(date) : "never")
}
];
function Properties(props) {
const { onOpenPreviewSession } = props;
const [versionHistory, setVersionHistory] = useState([]);
const toggleProperties = useStore((store) => store.toggleProperties);
const isFocusMode = useAppStore((store) => store.isFocusMode);
const session = useStore((store) => store.session);
const attachments = useAttachmentStore((store) =>
store.attachments.filter((a) => a.noteIds.includes(session.id))
);
const {
id: sessionId,
color,
notebooks = [],
sessionType,
dateCreated
} = session;
const isPreviewMode = sessionType === "preview";
const reminders = db.relations
.from({ id: session.id, type: "note" }, "reminder")
.resolved();
const allNotebooks = useMemo(
() =>
[
...notebooks.map((ref) => db.notebooks.notebook(ref.id)?.data),
...db.relations.to({ id: sessionId, type: "note" }, "notebook")
].filter(Boolean),
[sessionId, notebooks]
);
const changeState = useCallback(
function changeState(prop) {
switch (prop) {
case "lock":
return store.get().session.locked
? noteStore.unlock(sessionId)
: noteStore.lock(sessionId);
case "readonly":
return noteStore.readonly(sessionId);
case "local-only":
return noteStore.localOnly(sessionId);
case "pin":
return noteStore.pin(sessionId);
case "favorite":
return noteStore.favorite(sessionId);
default:
return;
}
},
[sessionId]
);
useEffect(() => {
if (!sessionId) return;
(async () => {
const history = await db.noteHistory.get(sessionId);
setVersionHistory(history);
})();
}, [sessionId]);
if (isFocusMode || !sessionId) return null;
return (
<AnimatedFlex
animate={{
x: 0
}}
transition={{
duration: 0.1,
bounceDamping: 1,
bounceStiffness: 1,
ease: "easeOut"
}}
initial={{ x: 600 }}
sx={{
display: "flex",
position: "absolute",
right: 0,
zIndex: 3,
height: "100%",
width: "300px",
borderLeft: "1px solid",
borderLeftColor: "border"
}}
>
<ScopedThemeProvider
scope="editorSidebar"
sx={{
flex: 1,
display: "flex",
bg: "background",
overflowY: "hidden",
overflowX: "hidden",
flexDirection: "column"
}}
>
<ScrollContainer>
<Card
title="Properties"
button={
<ArrowLeft
data-test-id="properties-close"
onClick={() => toggleProperties(false)}
size={18}
sx={{ mr: 1, cursor: "pointer" }}
/>
}
>
{!isPreviewMode && (
<>
{tools.map((tool) => (
<Toggle
{...tool}
key={tool.key}
toggleKey={tool.property}
onToggle={(state) => changeState(tool.key, state)}
testId={`properties-${tool.key}`}
/>
))}
</>
)}
{metadataItems.map((item) => (
<Flex
key={item.key}
py={2}
px={2}
sx={{
borderBottom: "1px solid var(--separator)",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Text variant="subBody" sx={{ fontSize: "body" }}>
{item.label}
</Text>
<Text
className="selectable"
variant="subBody"
sx={{ fontSize: "body" }}
>
{item.value(session[item.key])}
</Text>
</Flex>
))}
{!isPreviewMode && (
<>
<Flex
py={2}
px={2}
sx={{
cursor: "pointer",
justifyContent: "center"
}}
>
{COLORS.map((label) => (
<Flex
key={label}
onClick={() => noteStore.get().setColor(sessionId, label)}
sx={{
cursor: "pointer",
position: "relative",
alignItems: "center",
justifyContent: "space-between"
}}
data-test-id={`properties-${label}`}
>
<Circle
size={35}
color={label.toLowerCase()}
data-test-id={`toggle-state-${
label.toLowerCase() === color?.toLowerCase()
? "on"
: "off"
}`}
/>
{label.toLowerCase() === color?.toLowerCase() && (
<Checkmark
color="white"
size={18}
sx={{ position: "absolute", left: "8px" }}
/>
)}
</Flex>
))}
</Flex>
</>
)}
</Card>
{allNotebooks?.length > 0 && (
<Card title="Notebooks">
{allNotebooks.map((notebook) => (
<Notebook
key={notebook.id}
item={notebook}
date={notebook.dateCreated}
totalNotes={0} // getTotalNotes(notebook)}
simplified
/>
))}
</Card>
)}
{reminders.length > 0 && (
<Card title="Reminders">
{reminders.map((reminder) => {
return (
<Reminder key={reminder.id} item={reminder} simplified />
);
})}
</Card>
)}
{attachments.length > 0 && (
<Card
title="Attachments"
subtitle={`${attachments.length} attachments | ${formatBytes(
getTotalSize(attachments)
)} occupied`}
>
<table style={{ borderSpacing: 0 }}>
<tbody>
{attachments.map((attachment, i) => (
<Attachment
key={attachment.id}
compact
attachment={attachment}
/>
))}
</tbody>
</table>
</Card>
)}
<Card
title="Previous Sessions"
subtitle={"Your session history is local only."}
>
{versionHistory.map((session) => {
const fromDate = getFormattedDate(session.dateCreated, "date");
const toDate = getFormattedDate(session.dateModified, "date");
const fromTime = getFormattedDate(session.dateCreated, "time");
const toTime = getFormattedDate(session.dateModified, "time");
const label = `${fromDate}, ${fromTime}${
fromDate !== toDate ? `${toDate}, ` : ""
}${toTime}`;
const isSelected =
isPreviewMode && session.dateCreated === dateCreated;
return (
<Flex
key={session.id}
data-test-id={`session-item`}
py={1}
px={2}
sx={{
cursor: "pointer",
bg: isSelected ? "background-selected" : "transparent",
":hover": {
bg: isSelected ? "hover-selected" : "hover"
},
alignItems: "center",
justifyContent: "space-between"
}}
title="Click to preview"
onClick={async () => {
toggleProperties(false);
const content = await db.noteHistory.content(session.id);
if (session.locked) {
await Vault.askPassword(async (password) => {
try {
const decryptedContent =
await db.vault.decryptContent(content, password);
onOpenPreviewSession({
content: decryptedContent,
dateCreated: session.dateCreated,
dateEdited: session.dateModified
});
return true;
} catch (e) {
return false;
}
});
} else {
onOpenPreviewSession({
content,
dateCreated: session.dateCreated,
dateEdited: session.dateModified
});
}
}}
>
<Text variant={"body"} data-test-id="title">
{label}
</Text>
<Flex
sx={{
fontSize: "subBody",
color: "paragraph-secondary"
}}
>
{session.locked && <Lock size={14} data-test-id="locked" />}
<TimeAgo
live
datetime={session.dateModified}
locale={"en_short"}
/>
</Flex>
</Flex>
);
})}
</Card>
</ScrollContainer>
</ScopedThemeProvider>
</AnimatedFlex>
);
}
export default React.memo(Properties);
function Card({ title, subtitle, button, children }) {
return (
<Flex
sx={{
borderRadius: "default",
flexDirection: "column"
}}
>
<Flex mx={2} mt={2} sx={{ alignItems: "center" }}>
{button}
<Text variant="subtitle">{title}</Text>
</Flex>
{subtitle && (
<Text variant="subBody" mb={1} mx={2}>
{subtitle}
</Text>
)}
{children}
</Flex>
);
}

View File

@@ -0,0 +1,430 @@
/*
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 React, { PropsWithChildren } from "react";
import {
Pin,
StarOutline,
Unlock,
Readonly,
SyncOff,
ArrowLeft,
Circle,
Checkmark
} from "../icons";
import { Flex, Text } from "@theme-ui/components";
import { useStore, store, EditorSession } from "../../stores/editor-store";
import { db } from "../../common/db";
import { useStore as useAppStore } from "../../stores/app-store";
import { store as noteStore } from "../../stores/note-store";
import { AnimatedFlex } from "../animated";
import Toggle from "./toggle";
import ScrollContainer from "../scroll-container";
import { getFormattedDate } from "@notesnook/common";
import { ScopedThemeProvider } from "../theme-provider";
import { PreviewSession } from "../editor/types";
import usePromise from "../../hooks/use-promise";
import { ListItemWrapper } from "../list-container/list-profiles";
import { VirtualizedList } from "../virtualized-list";
import { ResolvedItem } from "../list-container/resolved-item";
import { SessionItem } from "../session-item";
import { COLORS } from "../../common/constants";
import { DefaultColors } from "@notesnook/core";
const tools = [
{ key: "pin", property: "pinned", icon: Pin, label: "Pin" },
{
key: "favorite",
property: "favorite",
icon: StarOutline,
label: "Favorite"
},
{ key: "lock", icon: Unlock, label: "Lock", property: "locked" },
{
key: "readonly",
icon: Readonly,
label: "Readonly",
property: "readonly"
},
{
key: "local-only",
icon: SyncOff,
label: "Disable sync",
property: "localOnly"
}
] as const;
type MetadataItem<T extends keyof EditorSession = keyof EditorSession> = {
key: T;
label: string;
value: (value: EditorSession[T]) => string;
};
const metadataItems = [
{
key: "dateCreated",
label: "Created at",
value: (date) => getFormattedDate(date || Date.now())
} as MetadataItem<"dateCreated">,
{
key: "dateEdited",
label: "Last edited at",
value: (date) => (date ? getFormattedDate(date) : "never")
} as MetadataItem<"dateEdited">
];
type EditorPropertiesProps = {
onOpenPreviewSession: (session: PreviewSession) => void;
};
function EditorProperties(props: EditorPropertiesProps) {
const { onOpenPreviewSession } = props;
const toggleProperties = useStore((store) => store.toggleProperties);
const isFocusMode = useAppStore((store) => store.isFocusMode);
const session = useStore((store) => store.session);
const { id: sessionId, sessionType, dateCreated } = session;
const isPreviewMode = sessionType === "preview";
if (isFocusMode || !sessionId) return null;
return (
<AnimatedFlex
animate={{
x: 0
}}
transition={{
duration: 0.1,
bounceDamping: 1,
bounceStiffness: 1,
ease: "easeOut"
}}
initial={{ x: 600 }}
sx={{
display: "flex",
position: "absolute",
right: 0,
zIndex: 3,
height: "100%",
width: "300px",
borderLeft: "1px solid",
borderLeftColor: "border"
}}
>
<ScopedThemeProvider
scope="editorSidebar"
sx={{
flex: 1,
display: "flex",
bg: "background",
overflowY: "hidden",
overflowX: "hidden",
flexDirection: "column"
}}
>
<ScrollContainer>
<Section
title="Properties"
button={
<ArrowLeft
data-test-id="properties-close"
onClick={() => toggleProperties(false)}
size={18}
sx={{ mr: 1, cursor: "pointer" }}
/>
}
>
{!isPreviewMode && (
<>
{tools.map((tool) => (
<Toggle
{...tool}
key={tool.key}
toggleKey={tool.property}
onToggle={() => changeToggleState(tool.key)}
testId={`properties-${tool.key}`}
/>
))}
</>
)}
{metadataItems.map((item) => (
<Flex
key={item.key}
py={2}
px={2}
sx={{
borderBottom: "1px solid var(--separator)",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Text variant="subBody" sx={{ fontSize: "body" }}>
{item.label}
</Text>
<Text
className="selectable"
variant="subBody"
sx={{ fontSize: "body" }}
>
{item.value(session[item.key])}
</Text>
</Flex>
))}
{!isPreviewMode && <Colors noteId={sessionId} />}
</Section>
<Notebooks noteId={sessionId} />
<Reminders noteId={sessionId} />
<Attachments noteId={sessionId} />
<SessionHistory
noteId={sessionId}
dateCreated={dateCreated || 0}
isPreviewMode={isPreviewMode}
onOpenPreviewSession={onOpenPreviewSession}
/>
</ScrollContainer>
</ScopedThemeProvider>
</AnimatedFlex>
);
}
export default React.memo(EditorProperties);
function Colors({ noteId }: { noteId: string }) {
const result = usePromise(async () =>
(
await db.relations.to({ id: noteId, type: "note" }, "color").resolve(1)
).at(0)
);
return (
<Flex
py={2}
px={2}
sx={{
cursor: "pointer",
justifyContent: "center"
}}
>
{COLORS.map((label) => (
<Flex
key={label.key}
onClick={() => noteStore.get().setColor(label.key, noteId)}
sx={{
cursor: "pointer",
position: "relative",
alignItems: "center",
justifyContent: "space-between"
}}
data-test-id={`properties-${label}`}
>
<Circle
size={35}
color={DefaultColors[label.key]}
data-test-id={`toggle-state-${
result.status === "fulfilled" &&
label.key === result.value?.colorCode
? "on"
: "off"
}`}
/>
{result.status === "fulfilled" &&
label.key === result.value?.colorCode && (
<Checkmark
color="white"
size={18}
sx={{ position: "absolute", left: "8px" }}
/>
)}
</Flex>
))}
</Flex>
);
}
function Notebooks({ noteId }: { noteId: string }) {
const result = usePromise(() =>
db.relations
.to({ id: noteId, type: "note" }, "notebook")
.selector.sorted(db.settings.getGroupOptions("notebooks"))
);
if (result.status !== "fulfilled" || result.value.ids.length <= 0)
return null;
return (
<Section title="Notebooks">
<VirtualizedList
mode="fixed"
estimatedSize={50}
getItemKey={(index) => result.value.getKey(index)}
items={result.value.ids}
renderItem={(id) => (
<ListItemWrapper id={id as string} items={result.value} simplified />
)}
/>
</Section>
);
}
function Reminders({ noteId }: { noteId: string }) {
const result = usePromise(() =>
db.relations
.from({ id: noteId, type: "note" }, "reminder")
.selector.sorted(db.settings.getGroupOptions("reminders"))
);
if (result.status !== "fulfilled" || result.value.ids.length <= 0)
return null;
return (
<Section title="Reminders">
<VirtualizedList
mode="fixed"
estimatedSize={54}
getItemKey={(index) => result.value.getKey(index)}
items={result.value.ids}
renderItem={(id) => (
<ListItemWrapper id={id as string} items={result.value} simplified />
)}
/>
</Section>
);
}
function Attachments({ noteId }: { noteId: string }) {
const result = usePromise(() =>
db.attachments
.ofNote(noteId, "all")
.sorted({ sortBy: "dateCreated", sortDirection: "desc" })
);
if (result.status !== "fulfilled" || result.value.ids.length <= 0)
return null;
return (
<Section title="Attachments">
{result.value.ids.map((id, index) => (
<ListItemWrapper
key={result.value.getKey(index)}
id={id as string}
items={result.value}
compact
/>
))}
</Section>
);
}
function SessionHistory({
noteId,
dateCreated,
isPreviewMode,
onOpenPreviewSession
}: {
noteId: string;
dateCreated: number;
isPreviewMode: boolean;
onOpenPreviewSession: (session: PreviewSession) => void;
}) {
const result = usePromise(() =>
db.noteHistory
.get(noteId)
.sorted({ sortBy: "dateModified", sortDirection: "desc" })
);
if (result.status !== "fulfilled" || result.value.ids.length <= 0)
return null;
return (
<Section
title="Previous Sessions"
subtitle={"Your session history is local only."}
>
<VirtualizedList
mode="fixed"
estimatedSize={28}
getItemKey={(index) => result.value.getKey(index)}
items={result.value.ids}
renderItem={(id) => (
<ResolvedItem id={id as string} items={result.value}>
{({ item }) =>
item.type === "session" ? (
<SessionItem
noteId={noteId}
session={item}
dateCreated={dateCreated}
isPreviewMode={isPreviewMode}
onOpenPreviewSession={onOpenPreviewSession}
/>
) : null
}
</ResolvedItem>
)}
/>
</Section>
);
}
type SectionProps = { title: string; subtitle?: string; button?: JSX.Element };
function Section({
title,
subtitle,
button,
children
}: PropsWithChildren<SectionProps>) {
return (
<Flex
sx={{
borderRadius: "default",
flexDirection: "column"
}}
>
<Flex mx={2} mt={2} sx={{ alignItems: "center" }}>
{button}
<Text variant="subtitle">{title}</Text>
</Flex>
{subtitle && (
<Text variant="subBody" mb={1} mx={2}>
{subtitle}
</Text>
)}
{children}
</Flex>
);
}
function changeToggleState(
prop: "lock" | "readonly" | "local-only" | "pin" | "favorite"
) {
const {
id: sessionId,
locked,
readonly,
localOnly,
pinned,
favorite
} = store.get().session;
if (!sessionId) return;
switch (prop) {
case "lock":
return locked ? noteStore.unlock(sessionId) : noteStore.lock(sessionId);
case "readonly":
return noteStore.readonly(!readonly, sessionId);
case "local-only":
return noteStore.localOnly(!localOnly, sessionId);
case "pin":
return noteStore.pin(!pinned, sessionId);
case "favorite":
return noteStore.favorite(!favorite, sessionId);
default:
return;
}
}

View File

@@ -111,10 +111,10 @@ export default React.memo(Reminder, (prev, next) => {
return prev?.item?.title === next?.item?.title; return prev?.item?.title === next?.item?.title;
}); });
const menuItems: ( const menuItems: (reminder: ReminderType, items?: string[]) => MenuItem[] = (
reminder: ReminderType, reminder,
items?: ReminderType[] items = []
) => MenuItem[] = (reminder, items = []) => { ) => {
return [ return [
{ {
type: "button", type: "button",

View File

@@ -0,0 +1,109 @@
/*
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 { getFormattedDate } from "@notesnook/common";
import { HistorySession } from "@notesnook/core";
import { Flex, Text } from "@theme-ui/components";
import TimeAgo from "../time-ago";
import { Lock } from "../icons";
import Vault from "../../common/vault";
import { db } from "../../common/db";
import { PreviewSession } from "../editor/types";
type SessionItemProps = {
session: HistorySession;
isPreviewMode: boolean;
noteId: string;
dateCreated: number;
onOpenPreviewSession: (session: PreviewSession) => void;
};
export function SessionItem(props: SessionItemProps) {
const { session, isPreviewMode, dateCreated, noteId, onOpenPreviewSession } =
props;
const fromDate = getFormattedDate(session.dateCreated, "date");
const toDate = getFormattedDate(session.dateModified, "date");
const fromTime = getFormattedDate(session.dateCreated, "time");
const toTime = getFormattedDate(session.dateModified, "time");
const label = `${fromDate}, ${fromTime}${
fromDate !== toDate ? `${toDate}, ` : ""
}${toTime}`;
const isSelected = isPreviewMode && session.dateCreated === dateCreated;
return (
<Flex
key={session.id}
data-test-id={`session-item`}
py={1}
px={2}
sx={{
cursor: "pointer",
bg: isSelected ? "background-selected" : "transparent",
":hover": {
bg: isSelected ? "hover-selected" : "hover"
},
alignItems: "center",
justifyContent: "space-between"
}}
title="Click to preview"
onClick={async () => {
const content = await db.noteHistory.content(session.id);
if (!content) return;
// toggleProperties(false);
if (session.locked) {
await Vault.askPassword(async (password) => {
try {
const decryptedContent = await db.vault.decryptContent(
content,
noteId,
password
);
onOpenPreviewSession({
content: decryptedContent,
dateCreated: session.dateCreated,
dateEdited: session.dateModified
});
return true;
} catch (e) {
return false;
}
});
} else {
onOpenPreviewSession({
content,
dateCreated: session.dateCreated,
dateEdited: session.dateModified
});
}
}}
>
<Text variant={"body"} data-test-id="title">
{label}
</Text>
<Flex
sx={{
fontSize: "subBody",
color: "paragraph-secondary"
}}
>
{session.locked && <Lock size={14} data-test-id="locked" />}
<TimeAgo live datetime={session.dateModified} locale={"en_short"} />
</Flex>
</Flex>
);
}

View File

@@ -66,7 +66,6 @@ export function ThemePreview(props: ThemePreviewProps) {
theme.previewColors.background theme.previewColors.background
].map((color) => ( ].map((color) => (
<Circle <Circle
key={color}
color={color} color={color}
size={18} size={18}
sx={{ sx={{

View File

@@ -0,0 +1,121 @@
/*
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 { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { Box } from "@theme-ui/components";
import React, { useEffect, useRef } from "react";
type VirtualizedGridProps<T> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | undefined
>;
mode?: "fixed" | "dynamic";
items: T[];
estimatedSize: number;
getItemKey: (index: number) => string;
scrollElement?: Element | null;
renderItem: (props: { item: T; index: number }) => JSX.Element | null;
scrollMargin?: number;
columns: number;
onEndReached?: () => void;
};
export function VirtualizedGrid<T>(props: VirtualizedGridProps<T>) {
const {
items,
getItemKey,
scrollElement,
scrollMargin,
renderItem: Item,
estimatedSize,
mode,
virtualizerRef,
columns,
onEndReached
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
lanes: columns,
count: items.length,
estimateSize: () => estimatedSize,
getItemKey,
getScrollElement: () =>
scrollElement || containerRef.current?.closest(".ms-container") || null,
scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0
});
useEffect(() => {
if (!virtualizer.scrollElement || !onEndReached) return;
function onScroll() {
if (!virtualizer.scrollElement) return;
const { clientHeight, scrollHeight, scrollTop } =
virtualizer.scrollElement;
const endThreshold = scrollHeight - clientHeight - 50;
if (scrollTop > endThreshold) {
onEndReached?.();
}
}
(virtualizer.scrollElement as HTMLElement)?.addEventListener(
"scroll",
onScroll
);
return () => {
(virtualizer.scrollElement as HTMLElement)?.removeEventListener(
"scroll",
onScroll
);
};
}, [virtualizer.scrollElement, onEndReached]);
if (virtualizerRef) virtualizerRef.current = virtualizer;
const virtualItems = virtualizer.getVirtualItems();
return (
<Box ref={containerRef} className="Grid">
<Box
sx={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative"
}}
>
{virtualItems.map((row) => (
<Box
key={row.key}
data-index={row.index}
ref={mode === "dynamic" ? virtualizer.measureElement : null}
style={{
position: "absolute",
top: 0,
left: `${(100 / columns) * row.lane}%`,
width: `${100 / columns}%`,
height: mode === "dynamic" ? "unset" : `${row.size}px`,
transform: `translateY(${
row.start - virtualizer.options.scrollMargin
}px)`
}}
>
<Item key={row.key} item={items[row.index]} index={row.index} />
</Box>
))}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,96 @@
/*
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 { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { Box, BoxProps } from "@theme-ui/components";
import React, { useRef } from "react";
type VirtualizedListProps<T> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | undefined
>;
mode?: "fixed" | "dynamic";
items: T[];
estimatedSize: number;
getItemKey: (index: number) => string;
scrollElement?: Element | null;
itemWrapperProps?: (item: T, index: number) => BoxProps;
renderItem: (props: { item: T; index: number }) => JSX.Element | null;
scrollMargin?: number;
} & BoxProps;
export function VirtualizedList<T>(props: VirtualizedListProps<T>) {
const {
items,
getItemKey,
scrollElement,
scrollMargin,
renderItem: Item,
estimatedSize,
mode,
virtualizerRef,
itemWrapperProps,
...containerProps
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
estimateSize: () => estimatedSize,
getItemKey,
getScrollElement: () =>
scrollElement || containerRef.current?.closest(".ms-container") || null,
scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0
});
if (virtualizerRef) virtualizerRef.current = virtualizer;
const virtualItems = virtualizer.getVirtualItems();
return (
<Box {...containerProps} ref={containerRef} className="List">
<Box
sx={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative"
}}
>
{virtualItems.map((row) => (
<Box
{...itemWrapperProps?.(items[row.index], row.index)}
key={row.key}
data-index={row.index}
ref={mode === "dynamic" ? virtualizer.measureElement : null}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: mode === "dynamic" ? "unset" : `${row.size}px`,
transform: `translateY(${
row.start - virtualizer.options.scrollMargin
}px)`
}}
>
<Item key={row.key} item={items[row.index]} index={row.index} />
</Box>
))}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,106 @@
/*
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 { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import { Box } from "@theme-ui/components";
import React, { useRef } from "react";
export type VirtualizedTableRowProps<T, C> = {
item: T;
index: number;
style: React.CSSProperties;
rowRef?: React.Ref<HTMLTableRowElement>;
context?: C;
};
type VirtualizedTableProps<T, C> = {
virtualizerRef?: React.MutableRefObject<
Virtualizer<Element, Element> | undefined
>;
mode?: "fixed" | "dynamic";
items: T[];
estimatedSize: number;
getItemKey: (index: number) => string;
scrollElement?: Element | null;
context?: C;
renderRow: (props: VirtualizedTableRowProps<T, C>) => JSX.Element | null;
scrollMargin?: number;
header: React.ReactNode;
style?: React.CSSProperties;
};
export function VirtualizedTable<T, C>(props: VirtualizedTableProps<T, C>) {
const {
items,
getItemKey,
scrollElement,
scrollMargin,
renderRow: Row,
estimatedSize,
mode,
virtualizerRef,
header,
style,
context
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
estimateSize: () => estimatedSize,
getItemKey,
getScrollElement: () =>
scrollElement || containerRef.current?.closest(".ms-container") || null,
scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0
});
if (virtualizerRef) virtualizerRef.current = virtualizer;
const virtualItems = virtualizer.getVirtualItems();
return (
<Box
ref={containerRef}
sx={{
height: virtualizer.getTotalSize()
}}
>
<table style={style}>
<thead>{header}</thead>
<tbody>
{virtualItems.map((row, index) => (
<Row
key={row.key}
item={items[row.index]}
index={row.index}
rowRef={mode === "dynamic" ? virtualizer.measureElement : null}
context={context}
style={{
height: mode === "dynamic" ? "unset" : `${row.size}px`,
transform: `translateY(${
row.start -
index * row.size -
virtualizer.options.scrollMargin
}px)`
}}
/>
))}
</tbody>
</table>
</Box>
);
}

View File

@@ -120,26 +120,27 @@ export default function AddReminderDialog(props: AddReminderDialogProps) {
useEffect(() => { useEffect(() => {
if (!reminderId) return; if (!reminderId) return;
const reminder = db.reminders.reminder(reminderId); db.reminders.reminder(reminderId).then((reminder) => {
if (!reminder) return; if (!reminder) return;
setSelectedDays(reminder.selectedDays || []); setSelectedDays(reminder.selectedDays || []);
setRecurringMode(reminder.recurringMode || RecurringModes.DAY); setRecurringMode(reminder.recurringMode || RecurringModes.DAY);
setMode(reminder.mode || Modes.ONCE); setMode(reminder.mode || Modes.ONCE);
setPriority(reminder.priority || Priorities.VIBRATE); setPriority(reminder.priority || Priorities.VIBRATE);
setDate(dayjs(reminder.date).format("YYYY-MM-DD")); setDate(dayjs(reminder.date).format("YYYY-MM-DD"));
setTime(dayjs(reminder.date).format("HH:mm")); setTime(dayjs(reminder.date).format("HH:mm"));
setTitle(reminder.title); setTitle(reminder.title);
setDescription(reminder.description); setDescription(reminder.description);
});
}, [reminderId]); }, [reminderId]);
useEffect(() => { useEffect(() => {
if (!noteId) return; if (!noteId) return;
const note = db.notes.note(noteId); db.notes.note(noteId).then((note) => {
if (!note) return; if (!note) return;
setTitle(note.title);
setTitle(note.title); setDescription(note.headline);
setDescription(note.headline); });
}, [noteId]); }, [noteId]);
const repeatsDaily = const repeatsDaily =

View File

@@ -31,7 +31,6 @@ import {
import { store, useStore } from "../stores/attachment-store"; import { store, useStore } from "../stores/attachment-store";
import { formatBytes } from "@notesnook/common"; import { formatBytes } from "@notesnook/common";
import Dialog from "../components/dialog"; import Dialog from "../components/dialog";
import { ItemProps, TableVirtuoso } from "react-virtuoso";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@@ -51,15 +50,20 @@ import NavigationItem from "../components/navigation-menu/navigation-item";
import { pluralize } from "@notesnook/common"; import { pluralize } from "@notesnook/common";
import { db } from "../common/db"; import { db } from "../common/db";
import { Perform } from "../common/dialog-controller"; import { Perform } from "../common/dialog-controller";
import { CustomScrollbarsVirtualList } from "../components/list-container";
import { Attachment } from "../components/attachment"; import { Attachment } from "../components/attachment";
import { ScopedThemeProvider } from "../components/theme-provider"; import { ScopedThemeProvider } from "../components/theme-provider";
import { import {
Attachment as AttachmentType, Attachment as AttachmentType,
VirtualizedGrouping VirtualizedGrouping
} from "@notesnook/core"; } from "@notesnook/core";
import usePromise from "../hooks/use-promise";
import { Multiselect } from "../common/multi-select"; import { Multiselect } from "../common/multi-select";
import { ResolvedItem } from "../components/list-container/resolved-item";
import {
VirtualizedTable,
VirtualizedTableRowProps
} from "../components/virtualized-table";
import { FlexScrollContainer } from "../components/scroll-container";
import usePromise from "../hooks/use-promise";
type ToolbarAction = { type ToolbarAction = {
title: string; title: string;
@@ -141,12 +145,6 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
})(); })();
}, [allAttachments]); }, [allAttachments]);
const totalSize = 0;
// useMemo(
// () => getTotalSize(allAttachments),
// [allAttachments]
// );
return ( return (
<Dialog <Dialog
isOpen={true} isOpen={true}
@@ -157,16 +155,14 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
> >
<Flex <Flex
sx={{ sx={{
height: "80vw" height: "80vw",
overflow: "hidden"
}} }}
> >
<Sidebar <Sidebar
totalSize={totalSize}
onDownloadAll={() => download(allAttachments?.ungrouped || [])} onDownloadAll={() => download(allAttachments?.ungrouped || [])}
filter={(query) => { filter={async (query) => {
// setAttachments( setAttachments(await db.lookup.attachments(query));
// db.lookup?.attachments(db.attachments.all || [], query) || []
// );
}} }}
counts={counts} counts={counts}
onRouteChange={async (route) => { onRouteChange={async (route) => {
@@ -185,41 +181,35 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
sx={{ sx={{
bg: "background", bg: "background",
flexDirection: "column", flexDirection: "column",
px: 4, pl: 4,
pt: 2, pt: 2,
overflowY: "hidden", overflow: "hidden"
overflow: "hidden",
table: { width: "100%", tableLayout: "fixed" },
"tbody::before": {
content: `''`,
display: "block",
height: 5
}
}} }}
> >
<Flex sx={{ justifyContent: "space-between" }}> <FlexScrollContainer>
<Flex sx={{ gap: 1 }}> <Flex sx={{ justifyContent: "space-between" }}>
{TOOLBAR_ACTIONS.map((tool) => ( <Flex sx={{ gap: 1 }}>
<Button {TOOLBAR_ACTIONS.map((tool) => (
variant="secondary" <Button
key={tool.title} variant="secondary"
title={tool.title} key={tool.title}
onClick={() => title={tool.title}
tool.onClick({ onClick={() =>
selected tool.onClick({
// : attachments.filter( selected
// (a) => selected.indexOf(a.id) > -1 // : attachments.filter(
// ) // (a) => selected.indexOf(a.id) > -1
}) // )
} })
disabled={!selected.length} }
sx={{ bg: "transparent", p: 1 }} disabled={!selected.length}
> sx={{ bg: "transparent", p: 1 }}
<tool.icon size={18} /> >
</Button> <tool.icon size={18} />
))} </Button>
</Flex> ))}
{/* <Button </Flex>
{/* <Button
variant="tool" variant="tool"
sx={{ p: 1, display: "flex" }} sx={{ p: 1, display: "flex" }}
onClick={async () => { onClick={async () => {
@@ -232,114 +222,113 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
Upload Upload
</Text> </Text>
</Button> */} </Button> */}
</Flex> </Flex>
{attachments && ( {attachments && (
<TableVirtuoso <VirtualizedTable
components={{ style={{ tableLayout: "fixed", borderCollapse: "collapse" }}
Scroller: CustomScrollbarsVirtualList, header={
TableRow <Box
}} as="tr"
style={{ height: "100%" }}
computeItemKey={(index) => attachments.getKey(index)}
data={attachments.ungrouped}
context={{
isSelected: (id: string) => selected.indexOf(id) > -1,
select: (id: string) => {
setSelected((s) => {
const copy = s.slice();
const index = copy.indexOf(id);
if (index > -1) copy.splice(index, 1);
else copy.push(id);
return copy;
});
},
attachments
}}
fixedItemHeight={30}
defaultItemHeight={30}
fixedHeaderContent={() => (
<Box
as="tr"
sx={{
height: 40,
th: { borderBottom: "1px solid var(--separator)" },
bg: "background"
}}
>
<Text
as="th"
variant="body"
sx={{ sx={{
width: 24, height: 40,
textAlign: "left", th: { borderBottom: "1px solid var(--separator)" },
fontWeight: "normal", bg: "background"
mb: 2
}} }}
> >
<Label> <Text
<Checkbox as="th"
sx={{ width: 18, height: 18 }} variant="body"
onChange={(e) => { sx={{
setSelected( width: 24,
e.currentTarget.checked ? attachments.ungrouped : [] textAlign: "left",
); fontWeight: "normal",
}} mb: 2
/> }}
</Label> >
</Text> <Label>
{COLUMNS.map((column) => <Checkbox
!column.title ? ( sx={{ width: 18, height: 18 }}
<th key={column.id} /> onChange={(e) => {
) : ( setSelected(
<Box e.currentTarget.checked
as="th" ? attachments.ungrouped
key={column.id} : []
sx={{ );
width: column.width, }}
cursor: "pointer", />
px: 1, </Label>
mb: 2, </Text>
":hover": { bg: "hover" } {COLUMNS.map((column) =>
}} !column.title ? (
onClick={() => { <th key={column.id} />
setSortBy((sortBy) => ({ ) : (
direction: <Box
sortBy.id === column.id && as="th"
sortBy.direction === "asc" key={column.id}
? "desc"
: "asc",
id: column.id
}));
}}
>
<Flex
sx={{ sx={{
alignItems: "center", width: column.width,
justifyContent: "space-between" 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
}));
}} }}
> >
<Text <Flex
variant="body" sx={{
sx={{ textAlign: "left", fontWeight: "normal" }} alignItems: "center",
justifyContent: "space-between"
}}
> >
{column.title} <Text
</Text> variant="body"
{sortBy.id === column.id ? ( sx={{ textAlign: "left", fontWeight: "normal" }}
sortBy.direction === "asc" ? ( >
<ChevronUp size={16} /> {column.title}
) : ( </Text>
<ChevronDown size={16} /> {sortBy.id === column.id ? (
) sortBy.direction === "asc" ? (
) : null} <ChevronUp size={16} />
</Flex> ) : (
</Box> <ChevronDown size={16} />
) )
)} ) : null}
</Box> </Flex>
)} </Box>
itemContent={() => <></>} )
/> )}
)} </Box>
}
mode="fixed"
estimatedSize={30}
getItemKey={(index) => attachments.getKey(index)}
items={attachments.ungrouped}
context={{
isSelected: (id: string) => selected.indexOf(id) > -1,
select: (id: string) => {
setSelected((s) => {
const copy = s.slice();
const index = copy.indexOf(id);
if (index > -1) copy.splice(index, 1);
else copy.push(id);
return copy;
});
},
attachments
}}
renderRow={AttachmentRow}
/>
)}
</FlexScrollContainer>
</Flex> </Flex>
</Flex> </Flex>
</Dialog> </Dialog>
@@ -348,6 +337,34 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
export default AttachmentsDialog; export default AttachmentsDialog;
function AttachmentRow(
props: VirtualizedTableRowProps<
string,
{
isSelected: (id: string) => boolean;
select: (id: string) => void;
attachments: VirtualizedGrouping<AttachmentType>;
}
>
) {
if (!props.context) return null;
return (
<ResolvedItem id={props.item} items={props.context.attachments}>
{({ item }) =>
item.type === "attachment" ? (
<Attachment
rowRef={props.rowRef}
style={props.style}
item={item}
isSelected={props.context?.isSelected(props.item)}
onSelected={() => props.context?.select(props.item)}
/>
) : null
}
</ResolvedItem>
);
}
type Route = "all" | "images" | "documents" | "videos" | "uploads" | "orphaned"; type Route = "all" | "images" | "documents" | "videos" | "uploads" | "orphaned";
const routes: { id: Route; icon: Icon; title: string }[] = [ const routes: { id: Route; icon: Icon; title: string }[] = [
@@ -388,14 +405,14 @@ type SidebarProps = {
onRouteChange: (route: Route) => void; onRouteChange: (route: Route) => void;
filter: (query: string) => void; filter: (query: string) => void;
counts: Record<Route, number>; counts: Record<Route, number>;
totalSize: number;
}; };
const Sidebar = memo( const Sidebar = memo(
function Sidebar(props: SidebarProps) { function Sidebar(props: SidebarProps) {
const { onRouteChange, filter, counts, totalSize, onDownloadAll } = props; const { onRouteChange, filter, counts, onDownloadAll } = props;
const [route, setRoute] = useState("all"); const [route, setRoute] = useState("all");
const downloadStatus = useStore((store) => store.status); const downloadStatus = useStore((store) => store.status);
const cancelDownload = useStore((store) => store.cancel); const cancelDownload = useStore((store) => store.cancel);
const result = usePromise(() => db.attachments.totalSize());
return ( return (
<ScopedThemeProvider scope="navigationMenu" injectCssVars={false}> <ScopedThemeProvider scope="navigationMenu" injectCssVars={false}>
@@ -410,6 +427,8 @@ const Sidebar = memo(
> >
<Flex sx={{ flexDirection: "column" }}> <Flex sx={{ flexDirection: "column" }}>
<Input <Input
id="search"
name="search"
placeholder="Search" placeholder="Search"
sx={{ m: 2, mb: 0, width: "auto", bg: "background", py: "7px" }} sx={{ m: 2, mb: 0, width: "auto", bg: "background", py: "7px" }}
onChange={(e) => { onChange={(e) => {
@@ -435,7 +454,11 @@ const Sidebar = memo(
<Flex sx={{ pl: 2, m: 2, mt: 1, justifyContent: "space-between" }}> <Flex sx={{ pl: 2, m: 2, mt: 1, justifyContent: "space-between" }}>
<Flex sx={{ flexDirection: "column" }}> <Flex sx={{ flexDirection: "column" }}>
<Text variant="body">{pluralize(counts.all, "file")}</Text> <Text variant="body">{pluralize(counts.all, "file")}</Text>
<Text variant="subBody">{formatBytes(totalSize)}</Text> {result.status === "fulfilled" && (
<Text variant="subBody">
{formatBytes(result.value || 0)}
</Text>
)}
</Flex> </Flex>
<Button <Button
variant="secondary" variant="secondary"
@@ -478,7 +501,6 @@ const Sidebar = memo(
); );
}, },
(prev, next) => (prev, next) =>
prev.totalSize === next.totalSize &&
prev.counts.all === next.counts.all && prev.counts.all === next.counts.all &&
prev.counts.documents === next.counts.documents && prev.counts.documents === next.counts.documents &&
prev.counts.images === next.counts.images && prev.counts.images === next.counts.images &&
@@ -487,36 +509,6 @@ const Sidebar = memo(
prev.counts.orphaned === next.counts.orphaned prev.counts.orphaned === next.counts.orphaned
); );
function TableRow(
props: ItemProps<string> & {
context?: {
isSelected: (id: string) => boolean;
select: (id: string) => void;
attachments: VirtualizedGrouping<AttachmentType>;
};
}
) {
const { context, item, ...restProps } = props;
const result = usePromise(
() => context?.attachments.item(item),
[item, context]
);
if (result.status !== "fulfilled" || !result.value)
return <div {...restProps} />;
return (
<Attachment
{...restProps}
key={item}
attachment={result.value}
isSelected={context?.isSelected(item)}
onSelected={() => {
context?.select(item);
}}
/>
);
}
async function getCounts(): Promise<Record<Route, number>> { async function getCounts(): Promise<Record<Route, number>> {
return { return {
all: await db.attachments.all.count(), all: await db.attachments.all.count(),

View File

@@ -37,10 +37,10 @@ import {
import { ThemeMetadata } from "@notesnook/themes-server"; import { ThemeMetadata } from "@notesnook/themes-server";
import { showThemeDetails } from "../../../common/dialog-controller"; import { showThemeDetails } from "../../../common/dialog-controller";
import { ThemePreview } from "../../../components/theme-preview"; import { ThemePreview } from "../../../components/theme-preview";
import { VirtuosoGrid } from "react-virtuoso";
import { Loader } from "../../../components/loader"; import { Loader } from "../../../components/loader";
import { showToast } from "../../../utils/toast"; import { showToast } from "../../../utils/toast";
import { showFilePicker, readFile } from "../../../utils/file-picker"; import { showFilePicker, readFile } from "../../../utils/file-picker";
import { VirtualizedGrid } from "../../../components/virtualized-grid";
const ThemesClient = ThemesTRPC.createClient({ const ThemesClient = ThemesTRPC.createClient({
links: [ links: [
@@ -107,6 +107,18 @@ function ThemesList() {
} }
); );
const items = [
{
...darkTheme,
previewColors: getPreviewColors(darkTheme)
},
{
...lightTheme,
previewColors: getPreviewColors(lightTheme)
},
...(themes.data?.pages.flatMap((a) => a.themes) || [])
];
const setTheme = useCallback( const setTheme = useCallback(
async (theme: ThemeMetadata) => { async (theme: ThemeMetadata) => {
if (isThemeCurrentlyApplied(theme.id)) return; if (isThemeCurrentlyApplied(theme.id)) return;
@@ -198,38 +210,22 @@ function ThemesList() {
<Box <Box
sx={{ sx={{
".virtuoso-grid-list": {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 2
},
mt: 2 mt: 2
}} }}
> >
{themes.isInitialLoading ? ( {themes.isInitialLoading ? (
<Loader title={"Loading themes..."} /> <Loader title={"Loading themes..."} />
) : ( ) : (
<VirtuosoGrid <VirtualizedGrid
customScrollParent={ columns={2}
document.getElementById("settings-scrollbar") || undefined items={items}
} getItemKey={(index) => items[index].id}
data={[ estimatedSize={285}
{ mode="dynamic"
...darkTheme, onEndReached={() =>
previewColors: getPreviewColors(darkTheme)
},
{
...lightTheme,
previewColors: getPreviewColors(lightTheme)
},
...(themes.data?.pages.flatMap((a) => a.themes) || [])
]}
endReached={() =>
themes.hasNextPage ? themes.fetchNextPage() : null themes.hasNextPage ? themes.fetchNextPage() : null
} }
context={{ darkTheme, lightTheme, setTheme }} renderItem={({ item: theme }) => (
computeItemKey={(_index, item) => item.id}
itemContent={(_index, theme) => (
<ThemeItem <ThemeItem
key={theme.id} key={theme.id}
theme={theme} theme={theme}

View File

@@ -242,6 +242,8 @@ function SettingsSideBar(props: SettingsSideBarProps) {
}} }}
> >
<Input <Input
id="search"
name="search"
placeholder="Search" placeholder="Search"
data-test-id="settings-search" data-test-id="settings-search"
sx={{ sx={{

View File

@@ -61,7 +61,7 @@ export const ProfileSettings: SettingsGroup[] = [
key: "manage-attachments", key: "manage-attachments",
title: "Attachments", title: "Attachments",
description: "Manage all your attachments in one place.", description: "Manage all your attachments in one place.",
isHidden: () => !useUserStore.getState().isLoggedIn, // isHidden: () => !useUserStore.getState().isLoggedIn,
components: [ components: [
{ {
type: "button", type: "button",

View File

@@ -46,8 +46,8 @@ enum SESSION_STATES {
opening = "opening" opening = "opening"
} }
type EditorSession = { export type EditorSession = {
sessionType: "default" | "locked"; sessionType: "default" | "locked" | "preview";
content?: NoteContent<false>; content?: NoteContent<false>;
isDeleted: boolean; isDeleted: boolean;
attachmentsLength: number; attachmentsLength: number;

View File

@@ -46,14 +46,11 @@ export class Compressor implements ICompressor {
return await desktop.compress.gunzip.query(data); return await desktop.compress.gunzip.query(data);
await this.init(); await this.init();
const bytes = new Memory(Buffer.from(data, "base64"));
return gunzip(Buffer.from(data, "base64")) const res = gunzip(bytes);
.copyAndDispose() const text = Buffer.from(res.bytes).toString("utf-8");
.toString("utf-8"); res.free();
// return new Promise<string>((resolve, reject) => { return text;
// gunzip(Buffer.from(data, "base64"), (err, data) =>
// err ? reject(err) : resolve(Buffer.from(data.buffer).toString("utf-8"))
// );
// });
} }
} }

View File

@@ -22,7 +22,7 @@ import Database from ".";
import { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common"; import { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common";
import { tinyToTiptap } from "../migrations"; import { tinyToTiptap } from "../migrations";
import { isCipher } from "../database/crypto"; import { isCipher } from "../database/crypto";
import { EncryptedContentItem, Note } from "../types"; import { Note } from "../types";
import { import {
isEncryptedContent, isEncryptedContent,
isUnencryptedContent isUnencryptedContent
@@ -110,6 +110,7 @@ export default class Vault {
try { try {
const content = await this.decryptContent( const content = await this.decryptContent(
encryptedContent, encryptedContent,
note.id,
oldPassword oldPassword
); );
contentItems.push({ contentItems.push({
@@ -239,18 +240,18 @@ export default class Vault {
} }
async decryptContent( async decryptContent(
encryptedContent: EncryptedContentItem, encryptedContent: NoteContent<true>,
noteId: string,
password?: string password?: string
) { ) {
if (!password) password = await this.getVaultPassword(); if (!password) password = await this.getVaultPassword();
if ( if (
encryptedContent.noteId &&
typeof encryptedContent.data !== "object" && typeof encryptedContent.data !== "object" &&
!isCipher(encryptedContent.data) !isCipher(encryptedContent.data)
) { ) {
await this.db.notes.add({ await this.db.notes.add({
id: encryptedContent.noteId, id: noteId,
locked: false locked: false
}); });
return { data: encryptedContent.data, type: encryptedContent.type }; return { data: encryptedContent.data, type: encryptedContent.type };
@@ -330,7 +331,11 @@ export default class Vault {
const encryptedContent = await this.db.content.get(note.contentId); const encryptedContent = await this.db.content.get(note.contentId);
if (!encryptedContent || !isEncryptedContent(encryptedContent)) return; if (!encryptedContent || !isEncryptedContent(encryptedContent)) return;
const content = await this.decryptContent(encryptedContent, password); const content = await this.decryptContent(
encryptedContent,
note.id,
password
);
if (perm) { if (perm) {
await this.db.notes.add({ await this.db.notes.add({

View File

@@ -29,6 +29,7 @@ import { Attachment } from "../types";
import Database from "../api"; import Database from "../api";
import { FilteredSelector, SQLCollection } from "../database/sql-collection"; import { FilteredSelector, SQLCollection } from "../database/sql-collection";
import { isFalse } from "../database"; import { isFalse } from "../database";
import { sql } from "kysely";
export class Attachments implements ICollection { export class Attachments implements ICollection {
name = "attachments"; name = "attachments";
@@ -470,6 +471,13 @@ export class Attachments implements ICollection {
); );
} }
async totalSize(selector: FilteredSelector<Attachment> = this.all) {
const result = await selector.filter
.select((eb) => eb.fn.sum<number>(sql.raw(`size + 17`)).as("totalSize"))
.executeTakeFirst();
return result?.totalSize;
}
private async encryptKey(key: SerializedKey) { private async encryptKey(key: SerializedKey) {
const encryptionKey = await this._getEncryptionKey(); const encryptionKey = await this._getEncryptionKey();
const encryptedKey = await this.db const encryptedKey = await this.db

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import Database from "../api"; import Database from "../api";
import { isCipher } from "../database/crypto"; import { isCipher } from "../database/crypto";
import { SQLCollection } from "../database/sql-collection"; import { FilteredSelector, SQLCollection } from "../database/sql-collection";
import { HistorySession, isDeleted } from "../types"; import { HistorySession, isDeleted } from "../types";
import { makeSessionContentId } from "../utils/id"; import { makeSessionContentId } from "../utils/id";
import { ICollection } from "./collection"; import { ICollection } from "./collection";
@@ -39,25 +39,31 @@ export class NoteHistory implements ICollection {
await this.sessionContent.init(); await this.sessionContent.init();
} }
async get(noteId: string, order: "asc" | "desc" = "desc") { // async get(noteId: string, order: "asc" | "desc" = "desc") {
if (!noteId) return []; // if (!noteId) return [];
// const indices = this.collection.indexer.indices; // // const indices = this.collection.indexer.indices;
// const sessionIds = indices.filter((id) => id.startsWith(noteId)); // // const sessionIds = indices.filter((id) => id.startsWith(noteId));
// if (sessionIds.length === 0) return []; // // if (sessionIds.length === 0) return [];
// const history = await this.getSessions(sessionIds); // // const history = await this.getSessions(sessionIds);
// return history.sort(function (a, b) { // // return history.sort(function (a, b) {
// return b.dateModified - a.dateModified; // // return b.dateModified - a.dateModified;
// }); // // });
const history = await this.db // const history = await this.db
.sql() // .sql()
.selectFrom("notehistory") // .selectFrom("notehistory")
.where("noteId", "==", noteId) // .where("noteId", "==", noteId)
.orderBy(`dateModified ${order}`) // .orderBy(`dateModified ${order}`)
.selectAll() // .selectAll()
.execute(); // .execute();
return history as HistorySession[]; // return history as HistorySession[];
// }
get(noteId: string) {
return new FilteredSelector<HistorySession>(
"notehistory",
this.db.sql().selectFrom("notehistory").where("noteId", "==", noteId)
);
} }
async add( async add(

View File

@@ -70,7 +70,9 @@ export class SessionContent implements ICollection {
}); });
} }
async get(sessionContentId: string) { async get(
sessionContentId: string
): Promise<NoteContent<boolean> | undefined> {
const session = await this.collection.get(sessionContentId); const session = await this.collection.get(sessionContentId);
if (!session || isDeleted(session)) return; if (!session || isDeleted(session)) return;

View File

@@ -38,7 +38,7 @@ const DEFAULT_GROUP_OPTIONS = (key: GroupingKey) =>
sortBy: sortBy:
key === "trash" key === "trash"
? "dateDeleted" ? "dateDeleted"
: key === "tags" : key === "tags" || key === "reminders"
? "dateCreated" ? "dateCreated"
: key === "reminders" : key === "reminders"
? "dueDate" ? "dueDate"

View File

@@ -345,31 +345,37 @@ export default class Backup {
if ("sessionContentId" in item && item.type !== "session") if ("sessionContentId" in item && item.type !== "session")
(item as any).type = "notehistory"; (item as any).type = "notehistory";
await migrateItem( if (
item, (await migrateItem(
version, item,
CURRENT_DATABASE_VERSION, version,
item.type, CURRENT_DATABASE_VERSION,
this.db, item.type,
"backup" this.db,
); "backup"
)) === "skip"
)
continue;
// since items in trash can have their own set of migrations, // since items in trash can have their own set of migrations,
// we have to run the migration again to account for that. // we have to run the migration again to account for that.
if (item.type === "trash" && item.itemType) if (item.type === "trash" && item.itemType)
await migrateItem( if (
item as unknown as Note | Notebook, (await migrateItem(
version, item as unknown as Note | Notebook,
CURRENT_DATABASE_VERSION, version,
item.itemType, CURRENT_DATABASE_VERSION,
this.db, item.itemType,
"backup" this.db,
); "backup"
)) === "skip"
)
continue;
const itemType = const itemType =
// colors are naively of type "tag" instead of "color" so we have to fix that. // colors are naively of type "tag" instead of "color" so we have to fix that.
item.type === "tag" && COLORS.includes(item.title.toLowerCase()) item.type === "tag" && COLORS.includes(item.title.toLowerCase())
? "color" ? "color"
: "itemType" in item && item.itemType : item.type === "trash" && "itemType" in item && item.itemType
? item.itemType ? item.itemType
: item.type; : item.type;

View File

@@ -159,7 +159,7 @@ export class NNMigrationProvider implements MigrationProvider {
.addColumn("salt", "text") .addColumn("salt", "text")
.addColumn("size", "integer") .addColumn("size", "integer")
.addColumn("alg", "text") .addColumn("alg", "text")
.addColumn("encryptionKey", "text") .addColumn("key", "text")
.addColumn("chunkSize", "integer") .addColumn("chunkSize", "integer")
.addColumn("hash", "text", (c) => c.unique()) .addColumn("hash", "text", (c) => c.unique())
.addColumn("hashType", "text") .addColumn("hashType", "text")

View File

@@ -19,3 +19,4 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
export * from "./types"; export * from "./types";
export { VirtualizedGrouping } from "./utils/virtualized-grouping"; export { VirtualizedGrouping } from "./utils/virtualized-grouping";
export { DefaultColors } from "./collections/colors";

View File

@@ -61,7 +61,7 @@ type Migration = {
item: MigrationItemMap[P], item: MigrationItemMap[P],
db: Database, db: Database,
migrationType: MigrationType migrationType: MigrationType
) => boolean | Promise<boolean> | void; ) => "skip" | boolean | Promise<boolean | "skip"> | void;
}; };
collection?: (collection: IndexedCollection) => Promise<void> | void; collection?: (collection: IndexedCollection) => Promise<void> | void;
}; };
@@ -196,12 +196,22 @@ const migrations: Migration[] = [
.items() .items()
.find((t) => item.title === t.title && t.id !== oldTagId)) .find((t) => item.title === t.title && t.id !== oldTagId))
) )
return false; return "skip";
const colorCode = ColorToHexCode[item.title]; const colorCode = ColorToHexCode[item.title];
if (colorCode) { if (colorCode) {
const newColor = await db.colors.all.find((eb) =>
eb.or([eb("title", "in", [alias, item.title])])
);
if (newColor) return "skip";
(item as unknown as Color).type = "color"; (item as unknown as Color).type = "color";
(item as unknown as Color).colorCode = colorCode; (item as unknown as Color).colorCode = colorCode;
} else {
const newTag = await db.tags.all.find((eb) =>
eb.or([eb("title", "in", [alias, item.title])])
);
if (newTag) return "skip";
} }
item.title = alias || item.title; item.title = alias || item.title;
@@ -305,6 +315,7 @@ const migrations: Migration[] = [
await db.relations.add(item, { id: subNotebookId, type: "notebook" }); await db.relations.add(item, { id: subNotebookId, type: "notebook" });
} }
delete item.topics; delete item.topics;
delete item.totalNotes;
return true; return true;
}, },
shortcut: (item) => { shortcut: (item) => {
@@ -409,7 +420,9 @@ export async function migrateItem<TItemType extends MigrationItemType>(
const itemMigrator = migration.items[type]; const itemMigrator = migration.items[type];
if (!itemMigrator) continue; if (!itemMigrator) continue;
if (await itemMigrator(item, database, migrationType)) { const result = await itemMigrator(item, database, migrationType);
if (result === "skip") return "skip";
if (result) {
if (item.type && item.type !== type) type = item.type as TItemType; if (item.type && item.type !== type) type = item.type as TItemType;
count++; count++;
} }

View File

@@ -25,6 +25,7 @@ export type SortOptions = {
| "dateCreated" | "dateCreated"
| "dateDeleted" | "dateDeleted"
| "dateEdited" | "dateEdited"
| "dateModified"
| "title" | "title"
| "filename" | "filename"
| "size" | "size"
@@ -197,6 +198,10 @@ export interface Notebook extends BaseItem<"notebook"> {
* @deprecated only kept here for migration purposes. * @deprecated only kept here for migration purposes.
*/ */
topics?: Topic[]; topics?: Topic[];
/**
* @deprecated only kept here for migration purposes.
*/
totalNotes?: number;
} }
/** /**