Compare commits

...

12 Commits

Author SHA1 Message Date
Abdullah Atta
d522c65d49 web: bump version to 3.2.0-beta.0 2025-04-28 11:18:25 +05:00
Abdullah Atta
4b7cecccbe web: update @notesnook-imoporter/core to 2.2.2 2025-04-28 11:09:51 +05:00
01zulfi
151abb94e1 web: fix notestore is not defined error (#8012)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-04-28 11:09:05 +05:00
Abdullah Atta
b67a307453 web: update @notesnook-importer/core to 2.2.1 2025-04-28 10:31:54 +05:00
Abdullah Atta
bf83c87911 web: update @notesnook-importer/core to 2.2.0 2025-04-28 09:59:57 +05:00
Abdullah Atta
8e67d3dc23 web: open settings with importer page instead of importer.notesnook.com 2025-04-28 09:59:57 +05:00
Ammar Ahmed
4b24f2c4b6 mobile: fix default font setting not followed 2025-04-28 09:59:57 +05:00
01zulfi
6cd14a5a78 web: make nav menu header draggable (#7985)
* web: make part of nav menu header draggable
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* Update apps/web/src/app.css

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

* Update apps/web/src/components/navigation-menu/index.tsx

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

---------

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
2025-04-28 09:59:57 +05:00
Ammar Ahmed
6edae50960 mobile: remove READ_MEDIA_IMAGES permission 2025-04-28 09:59:57 +05:00
Abdullah Atta
2a6a377d06 web: refresh app after import 2025-04-28 09:59:57 +05:00
Abdullah Atta
c569dbd778 web: move importer into web/desktop app (#7472)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-04-28 09:59:57 +05:00
01zulfi
36022df189 web: add support for archiving notes (#7810)
* web: archive notes
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* global: arhives -> archived
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-04-28 09:45:59 +05:00
45 changed files with 2507 additions and 609 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.1.0",
"version": "3.2.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.1.0",
"version": "3.2.0-beta.0",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.1.0",
"version": "3.2.0-beta.0",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

View File

@@ -15,7 +15,6 @@
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" tools:node="remove" />

View File

@@ -108,6 +108,12 @@ export class AppModel {
return new TrashViewModel(this.page);
}
async goToArchive() {
await this.page.locator(getTestId("tab-home")).click();
await this.navigateTo("Archive");
return new NotesViewModel(this.page, "notes", "archive");
}
async goToSettings() {
await this.profileDropdown.open(
this.page.locator(getTestId("profile-dropdown")),

View File

@@ -37,6 +37,7 @@ abstract class BaseProperties {
private readonly pinToggle: ToggleModel;
private readonly favoriteToggle: ToggleModel;
private readonly lockToggle: ToggleModel;
private readonly archiveToggle: ToggleModel;
constructor(
page: Page,
@@ -47,6 +48,7 @@ abstract class BaseProperties {
this.pinToggle = new ToggleModel(page, `${itemPrefix}-pin`);
this.lockToggle = new ToggleModel(page, `${itemPrefix}-lock`);
this.favoriteToggle = new ToggleModel(page, `${itemPrefix}-favorite`);
this.archiveToggle = new ToggleModel(page, `${itemPrefix}-archive`);
}
async isPinned() {
@@ -126,6 +128,25 @@ abstract class BaseProperties {
await this.close();
}
async isArchived() {
await this.open();
const state = await this.archiveToggle.isToggled();
await this.close();
return state;
}
async archive() {
await this.open();
await this.archiveToggle.on();
await this.close();
}
async unarchive() {
await this.open();
await this.archiveToggle.off();
await this.close();
}
abstract isColored(color: string): Promise<boolean>;
abstract color(color: string): Promise<void>;
abstract open(): Promise<void>;

View File

@@ -201,6 +201,37 @@ for (const actor of actors) {
expect(await note?.getDescription()).toContain(NOTE.content);
expect(await note?.contextMenu.isLocked()).toBe(false);
});
test(`archive a note using ${actor}`, async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await note?.[actor].archive();
const archive = await app.goToArchive();
const archivedNote = await archive.findNote(NOTE);
expect(await archivedNote?.contextMenu.isArchived()).toBe(true);
expect(await archivedNote?.properties.isArchived()).toBe(true);
});
test(`unarchive a note using ${actor}`, async ({ page }) => {
const app = new AppModel(page);
await app.goto();
let notes = await app.goToNotes();
let note = await notes.createNote(NOTE);
await note?.contextMenu.archive();
const archive = await app.goToArchive();
const archivedNote = await archive.findNote(NOTE);
await archivedNote?.[actor].unarchive();
notes = await app.goToNotes();
note = await notes.findNote(NOTE);
expect(await note?.contextMenu.isArchived()).toBe(false);
expect(await note?.properties.isArchived()).toBe(false);
});
}
test("open a locked note", async ({ page }) => {
@@ -339,3 +370,48 @@ test(`sort notes`, async ({ page }, info) => {
}
}
});
test("archived favorite note shouldn't be in favorites note list", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await note?.contextMenu.favorite();
await note?.contextMenu.archive();
const favorites = await app.goToFavorites();
expect(await favorites.findNote(NOTE)).toBeUndefined();
});
test("archived tag note shouldn't be in tags note list", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const tags = await app.goToTags();
const tag = await tags.createItem({ title: "my-tag" });
const notes = await tag?.open();
const note = await notes?.createNote(NOTE);
expect(await notes?.findNote(NOTE)).toBeDefined();
await note?.contextMenu.archive();
expect(await notes?.findNote(NOTE)).toBeUndefined();
});
test("archived notebook note shouldn't be in notebooks note list", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notebooks = await app.goToNotebooks();
const notebook = await notebooks.createNotebook({ title: "my-notebook" });
const notes = await notebook?.openNotebook();
const note = await notes?.createNote(NOTE);
expect(await notes?.findNote(NOTE)).toBeDefined();
await note?.contextMenu.archive();
expect(await notes?.findNote(NOTE)).toBeUndefined();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.1.0",
"version": "3.2.0-beta.0",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",
@@ -19,7 +19,7 @@
"@lingui/react": "5.1.2",
"@mdi/js": "7.4.47",
"@mdi/react": "1.6.1",
"@notesnook-importer/core": "^2.1.1",
"@notesnook-importer/core": "^2.2.2",
"@notesnook/common": "file:../../packages/common",
"@notesnook/core": "file:../../packages/core",
"@notesnook/crypto": "file:../../packages/crypto",

View File

@@ -1,6 +1,7 @@
.tabsScroll,
.titlebarLogo,
.theme-scope-titleBar,
.navigation-menu-header,
.route-container-header {
-webkit-app-region: drag;
}

View File

@@ -45,11 +45,7 @@ export default function Accordion(
containerSx,
...restProps
} = props;
const [isContentHidden, setIsContentHidden] = useState(false);
useEffect(() => {
setIsContentHidden(isClosed);
}, [isClosed]);
const [isContentHidden, setIsContentHidden] = useState(isClosed);
return (
<Flex sx={{ flexDirection: "column", ...sx }} {...restProps}>

View File

@@ -40,6 +40,7 @@ import {
mdiClose,
mdiDotsVertical,
mdiTrashCanOutline,
mdiArchiveOutline,
mdiBookRemoveOutline,
mdiMagnify,
mdiMenu,
@@ -351,6 +352,7 @@ export const Cross = createIcon(mdiClose);
export const MoreVertical = createIcon(mdiDotsVertical);
export const MoreHorizontal = createIcon(mdiDotsHorizontal);
export const Trash = createIcon(mdiTrashCanOutline);
export const Archive = createIcon(mdiArchiveOutline);
export const TopicRemove = createIcon(mdiBookmarkRemoveOutline);
export const NotebookRemove = createIcon(mdiBookRemoveOutline);
export const Search = createIcon(mdiMagnify);

View File

@@ -0,0 +1,315 @@
/*
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 {
IFile,
IFileProvider,
ProviderSettings,
transform
} from "@notesnook-importer/core";
import { formatBytes } from "@notesnook/common";
import { ScrollContainer } from "@notesnook/ui";
import { Button, Flex, Input, Text } from "@theme-ui/components";
import { xxhash64 } from "hash-wasm";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import { importNote } from "../../../utils/importer";
import Accordion from "../../accordion";
import { TransformResult } from "../types";
import { useStore as useAppStore } from "../../../stores/app-store";
type FileProviderHandlerProps = {
provider: IFileProvider;
onTransformFinished: (result: TransformResult) => void;
};
type Progress = {
total: number;
done: number;
};
export function FileProviderHandler(props: FileProviderHandlerProps) {
const { provider, onTransformFinished } = props;
const [files, setFiles] = useState<File[]>([]);
const [filesProgress, setFilesProgress] = useState<Progress>({
done: 0,
total: 0
});
const [totalNoteCount, setTotalNoteCount] = useState(0);
const [_, setCounter] = useState<number>(0);
const logs = useRef<string[]>([]);
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((files) => {
const newFiles = [...acceptedFiles, ...files];
return newFiles;
});
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
file: provider?.supportedExtensions?.concat([".zip"])
}
});
useEffect(() => {
setFiles([]);
}, [provider]);
async function onStartImport() {
let totalNotes = 0;
const errors: Error[] = [];
const settings: ProviderSettings = {
clientType: "browser",
hasher: { type: "xxh64", hash: xxhash64 },
storage: {
clear: async () => undefined,
get: async () => [],
write: async (data) => {
logs.current.push(
`[${new Date().toLocaleString()}] Pushing ${
data.title
} into database`
);
await importNote(data);
},
iterate: async function* () {
return null;
}
},
log: (message) => {
logs.current.push(
`[${new Date(message.date).toLocaleString()}] ${message.text}`
);
setCounter((s) => ++s);
},
reporter: () => {
setTotalNoteCount(++totalNotes);
}
};
setTotalNoteCount(0);
setFilesProgress({
total: files.length,
done: 0
});
for (const file of files) {
setFilesProgress((p) => ({
...p,
done: p.done + 1
}));
const providerFile: IFile = {
name: file.name,
modifiedAt: file.lastModified,
size: file.size,
data: file
};
errors.push(...(await transform(provider, [providerFile], settings)));
}
await useAppStore.getState().refresh();
onTransformFinished({
totalNotes,
errors
});
}
if (filesProgress.done) {
return (
<Flex sx={{ flexDirection: "column", alignItems: "stretch" }}>
<Text variant="subtitle">
Processing {filesProgress.done} of {filesProgress.total} file(s)
</Text>
<Text variant="body" sx={{ mt: 4, textAlign: "center" }}>
Found {totalNoteCount} notes
</Text>
{logs.current.length > 0 && (
<Accordion
title="Logs"
isClosed={false}
sx={{
border: "1px solid var(--border)",
mt: 2
}}
>
<ScrollContainer>
<Text
as="pre"
variant="body"
sx={{
fontFamily: "monospace",
maxHeight: 250,
p: 2
}}
>
{logs.current.map((c, index) => (
<>
<span key={index.toString()}>{c}</span>
<br />
</>
))}
</Text>
</ScrollContainer>
</Accordion>
)}
</Flex>
);
}
return (
<Flex sx={{ flexDirection: "column", alignItems: "stretch" }}>
<Text variant="subtitle">Select {provider.name} files</Text>
<Text
variant="body"
as={"div"}
sx={{ mt: 1, color: "paragraph", whiteSpace: "pre-wrap" }}
>
Check out our step-by-step guide on{" "}
<a href={provider.helpLink} target="_blank" rel="noreferrer">
how to import from {provider?.name}.
</a>
</Text>
<Flex
{...getRootProps()}
sx={{
justifyContent: "center",
alignItems: "center",
height: 100,
border: "2px dashed var(--border)",
borderRadius: "default",
mt: 2,
cursor: "pointer",
":hover": {
bg: "background-secondary"
}
}}
>
<Input {...getInputProps()} />
<Text variant="body" sx={{ textAlign: "center" }}>
{isDragActive
? "Drop the files here"
: "Drag & drop files here, or click to select files"}
<br />
<Text variant="subBody">
Only {provider?.supportedExtensions.join(", ")} files are supported.{" "}
{provider?.supportedExtensions.includes(".zip") ? null : (
<>
You can also select .zip files containing{" "}
{provider?.supportedExtensions.join(", ")} files.
</>
)}
<br />
{provider.examples ? (
<>For example, {provider.examples.join(", ")}</>
) : null}
</Text>
</Text>
</Flex>
{files.length > 0 ? (
<Accordion
isClosed
title={`${files.length} ${
files.length > 1 ? "files" : "file"
} selected`}
sx={{
border: "1px solid var(--border)",
mt: 2,
borderRadius: "default"
}}
>
<Flex
sx={{ flexDirection: "column", overflowY: "auto", maxHeight: 400 }}
>
{files.map((file, index) => (
<Flex
key={file.name}
sx={{
p: 2,
bg: index % 2 ? "transparent" : "background-secondary",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
":hover": {
bg: "hover"
}
}}
onClick={() => {
setFiles((files) => {
const _files = files.slice();
_files.splice(index, 1);
return _files;
});
}}
title="Click to remove"
>
<Text variant="body">{file.name}</Text>
<Text variant="body">{formatBytes(file.size)}</Text>
</Flex>
))}
</Flex>
</Accordion>
) : null}
{!!files.length && (
<>
<Text
variant="body"
sx={{
bg: "primary",
color: "static",
mt: 2,
borderRadius: 5,
p: 1
}}
>
Please make sure you have at least{" "}
{formatBytes(files.reduce((prev, file) => prev + file.size, 0))} of
free space before proceeding.
</Text>
{provider.requiresNetwork ? (
<Text
variant="body"
sx={{
bg: "background-error",
color: "paragraph-error",
mt: 2,
borderRadius: 5,
p: 1
}}
>
Please make sure you have good Internet access before proceeding.
The importer may send network requests in order to download media
resources such as images, files, and other attachments.
</Text>
) : null}
<Button
variant="accent"
sx={{ alignSelf: "center", mt: 2, px: 4 }}
onClick={onStartImport}
>
Start importing
</Button>
</>
)}
</Flex>
);
}

View File

@@ -0,0 +1,60 @@
/*
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 { Button, Flex, Text } from "@theme-ui/components";
import Accordion from "../../accordion";
type ImportErrorsProps = {
errors: Error[];
};
export function ImportErrors(props: ImportErrorsProps) {
return (
<Accordion
isClosed={false}
title={`${props.errors.length} errors occured`}
sx={{ bg: "background-error", borderRadius: "default", mt: 2 }}
color="paragraph-error"
>
<Flex sx={{ flexDirection: "column", px: 2, pb: 2, overflowX: "auto" }}>
{props.errors.map((error, index) => (
<Text
variant="body"
sx={{ color: "paragraph-error", my: 1, fontFamily: "monospace" }}
>
{index + 1}. {error.message}
<br />
</Text>
))}
<Button
variant="error"
sx={{ alignSelf: "start", mt: 2 }}
onClick={() =>
window.open(
"https://github.com/streetwriters/notesnook-importer/issues/new",
"_blank"
)
}
>
Send us a bug report
</Button>
</Flex>
</Accordion>
);
}

View File

@@ -0,0 +1,94 @@
/*
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 { IProvider } from "@notesnook-importer/core";
import { strings } from "@notesnook/intl";
import { Button, Flex, Text } from "@theme-ui/components";
import { CheckCircleOutline } from "../../icons";
import { TransformResult } from "../types";
import { ImportErrors } from "./import-errors";
type ImportResultProps = {
result: TransformResult;
provider: IProvider;
onReset: () => void;
};
export function ImportResult(props: ImportResultProps) {
const { result, onReset } = props;
if (result.totalNotes <= 0) {
return (
<Flex sx={{ flexDirection: "column", alignItems: "stretch" }}>
<Text variant="title">Import unsuccessful</Text>
<Text variant="body" sx={{ mt: 2 }}>
We failed to import the selected files. Please try again.
</Text>
{result.errors.length > 0 && <ImportErrors errors={result.errors} />}
<Button
variant="accent"
onClick={onReset}
sx={{ alignSelf: "center", mt: 2, px: 4 }}
>
Start over
</Button>
</Flex>
);
}
return (
<>
<CheckCircleOutline color="accent" />
<Text variant="body" my={2} sx={{ textAlign: "center" }}>
{strings.importCompleted()}. {props.result.totalNotes} notes
successfully imported.
{strings.errorsOccured(result.errors.length)}
</Text>
<Button
variant="secondary"
sx={{ alignSelf: "center" }}
onClick={async () => {
onReset();
}}
>
{strings.startOver()}
</Button>
{result.errors.length > 0 && (
<Flex
my={1}
bg="var(--background-error)"
p={1}
sx={{ flexDirection: "column" }}
>
{result.errors.map((error) => (
<Text
key={error.message}
variant="body"
sx={{
color: "var(--paragraph-error)"
}}
>
{error.message}
</Text>
))}
</Flex>
)}
</>
);
}

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 {
INetworkProvider,
OneNote,
OneNoteSettings,
ProviderSettings,
transform
} from "@notesnook-importer/core";
import { ScrollContainer } from "@notesnook/ui";
import { Button, Flex, Text } from "@theme-ui/components";
import { xxhash64 } from "hash-wasm";
import { useRef, useState } from "react";
import { importNote } from "../../../utils/importer";
import Accordion from "../../accordion";
import { TransformResult } from "../types";
type NetworkProviderHandlerProps = {
provider: INetworkProvider<ProviderSettings>;
onTransformFinished: (result: TransformResult) => void;
};
type Progress = {
total: number;
done: number;
};
function getProviderSettings(
provider: INetworkProvider<ProviderSettings>,
settings: ProviderSettings
) {
if (provider instanceof OneNote) {
return {
...settings,
cache: false,
clientId: "6c32bdbd-c6c6-4cda-bcf0-0c8ec17e5804",
redirectUri:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.notesnook.com"
} as OneNoteSettings;
}
}
export function NetworkProviderHandler(props: NetworkProviderHandlerProps) {
const { provider, onTransformFinished } = props;
const [totalNoteCount, setTotalNoteCount] = useState(0);
const [_, setCounter] = useState<number>(0);
const logs = useRef<string[]>([]);
async function onStartImport() {
let totalNotes = 0;
const settings = getProviderSettings(provider, {
clientType: "browser",
hasher: { type: "xxh64", hash: xxhash64 },
storage: {
clear: async () => undefined,
get: async () => [],
write: async (data) => {
logs.current.push(
`[${new Date().toLocaleString()}] Pushing ${
data.title
} into database`
);
await importNote(data);
},
iterate: async function* () {
return null;
}
},
log: (message) => {
logs.current.push(
`[${new Date(message.date).toLocaleString()}] ${message.text}`
);
setCounter((s) => ++s);
},
reporter: () => {
setTotalNoteCount(++totalNotes);
}
});
if (!settings) return;
setTotalNoteCount(0);
const errors = await transform(provider, settings);
console.log(errors);
onTransformFinished({
totalNotes,
errors
});
}
return (
<Flex
sx={{
flexDirection: "column",
alignItems: "stretch"
}}
>
{totalNoteCount ? (
<>
<Text variant="title">Importing your notes from {provider.name}</Text>
<Text variant="body" sx={{ mt: 4 }}>
Found {totalNoteCount} notes
</Text>
{logs.current.length > 0 && (
<Accordion
isClosed={false}
title="Logs"
sx={{
border: "1px solid var(--border)",
mt: 2
}}
>
<ScrollContainer>
<Text
as="pre"
variant="body"
sx={{
fontFamily: "monospace",
maxHeight: 250,
p: 2
}}
>
{logs.current.map((c, index) => (
<>
<span key={index.toString()}>{c}</span>
<br />
</>
))}
</Text>
</ScrollContainer>
</Accordion>
)}
</>
) : (
<>
<Text variant="title">Connect your {provider.name} account</Text>
<Text variant="body" sx={{ color: "fontTertiary", mt: [2, 0] }}>
Check out our step-by-step guide on{" "}
<a href={provider.helpLink} target="_blank" rel="noreferrer">
how to import from {provider.name}.
</a>
</Text>
<Button
variant="accent"
onClick={onStartImport}
sx={{ my: 4, alignSelf: "center" }}
>
Start importing
</Button>
</>
)}
</Flex>
);
}

View File

@@ -0,0 +1,79 @@
/*
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 {
IProvider,
ProviderFactory,
Providers
} from "@notesnook-importer/core";
import { Flex, Text } from "@theme-ui/components";
type ProviderSelectorProps = {
onProviderChanged: (provider: IProvider) => void;
};
export function ProviderSelector(props: ProviderSelectorProps) {
return (
<Flex
sx={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "start",
gap: 4
}}
>
<Flex sx={{ flexDirection: "column", flex: 1 }}>
<Text variant="subtitle">Select a notes app to import from</Text>
<Text
variant="body"
as="div"
sx={{ mt: 1, color: "paragraph", whiteSpace: "pre-wrap" }}
>
Can&apos;t find your notes app in the list?{" "}
<a href="https://github.com/streetwriters/notesnook-importer/issues/new">
Send us a request.
</a>
</Text>
</Flex>
<select
style={{
backgroundColor: "var(--background-secondary)",
outline: "none",
border: "1px solid var(--border-secondary)",
borderRadius: "5px",
color: "var(--paragraph)",
padding: "5px",
overflow: "hidden"
}}
onChange={(e) => {
if (e.target.value === "") return;
const providerName: Providers = e.target.value as Providers;
props.onProviderChanged(ProviderFactory.getProvider(providerName));
}}
>
<option value="">Select notes app</option>
{ProviderFactory.getAvailableProviders().map((provider) => (
<option key={provider} value={provider}>
{ProviderFactory.getProvider(provider as Providers).name}
</option>
))}
</select>
</Flex>
);
}

View File

@@ -0,0 +1,82 @@
/*
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 { Flex } from "@theme-ui/components";
import { useState } from "react";
import { ProviderSelector } from "./components/provider-selector";
import { FileProviderHandler } from "./components/file-provider-handler";
import { ImportResult } from "./components/import-result";
import { IProvider } from "@notesnook-importer/core";
import { NetworkProviderHandler } from "./components/network-provider-handler";
import { TransformResult } from "./types";
export function Importer() {
const [selectedProvider, setSelectedProvider] = useState<IProvider>();
const [transformResult, setTransformResult] = useState<TransformResult>();
const [instanceKey, setInstanceKey] = useState<string>(`${Math.random()}`);
return (
<Flex sx={{ flexDirection: "column" }}>
<Flex
sx={{
flexDirection: "column",
alignItems: "stretch",
gap: 4
}}
>
<ProviderSelector
onProviderChanged={(provider) => {
setInstanceKey(`${Math.random()}`);
setSelectedProvider(provider);
setTransformResult(undefined);
}}
/>
{selectedProvider ? (
<>
{selectedProvider.type === "file" ? (
<FileProviderHandler
key={instanceKey}
provider={selectedProvider}
onTransformFinished={setTransformResult}
/>
) : selectedProvider.type === "network" ? (
<NetworkProviderHandler
key={instanceKey}
provider={selectedProvider}
onTransformFinished={setTransformResult}
/>
) : null}
</>
) : null}
{transformResult && selectedProvider ? (
<>
<ImportResult
result={transformResult}
provider={selectedProvider}
onReset={() => {
setTransformResult(undefined);
setInstanceKey(`${Math.random()}`);
}}
/>
</>
) : null}
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1,20 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export * from "./importer";

View File

@@ -0,0 +1,23 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type TransformResult = {
totalNotes: number;
errors: Error[];
};

View File

@@ -36,7 +36,7 @@ export type Context =
}
| NotebookContext
| {
type: "favorite" | "monographs";
type: "favorite" | "monographs" | "archive";
};
export type WithDateEdited<T> = { items: T[]; dateEdited: number };

View File

@@ -46,7 +46,8 @@ import {
Reset,
Rename,
ExpandSidebar,
HamburgerMenu
HamburgerMenu,
Archive
} from "../icons";
import { SortableNavigationItem } from "./navigation-item";
import {
@@ -111,7 +112,7 @@ import { showSortMenu } from "../group-header";
import { Freeze } from "react-freeze";
type Route = {
id: "notes" | "favorites" | "reminders" | "monographs" | "trash";
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
title: string;
path: string;
icon: Icon;
@@ -138,7 +139,13 @@ const routes: Route[] = [
path: "/monographs",
icon: Monographs
},
{ id: "trash", title: strings.routes.Trash(), path: "/trash", icon: Trash }
{ id: "trash", title: strings.routes.Trash(), path: "/trash", icon: Trash },
{
id: "archive",
title: strings.archive(),
path: "/archive",
icon: Archive
}
];
const tabs = [
@@ -272,7 +279,9 @@ function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
}}
>
<Flex
className="navigation-menu-header"
sx={{
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 1
@@ -704,6 +713,8 @@ function ItemCount({ item }: { item: Route | Color | Notebook | Tag }) {
return trash?.length || 0;
case "monographs":
return monographs?.length || 0;
case "archive":
return db.notes.archived.count();
default:
return 0;
}

View File

@@ -62,6 +62,7 @@ import {
AddReminder,
AddToNotebook,
Alert,
Archive,
Attachment,
AttachmentError,
Circle,
@@ -395,6 +396,15 @@ export const noteMenuItems: (
await AddReminderDialog.show({ note });
}
},
{
type: "button",
key: "archive",
title: strings.archive(),
isChecked: note.archived,
icon: Archive.path,
onClick: () => store.archive(!note.archived, ...ids),
multiSelect: true
},
{ key: "sep1", type: "separator" },
{
type: "button",

View File

@@ -31,7 +31,8 @@ import {
ChevronRight,
LinkedTo,
ReferencedIn as ReferencedInIcon,
Note as NoteIcon
Note as NoteIcon,
Archive
} from "../icons";
import { Box, Button, Flex, Text, FlexProps } from "@theme-ui/components";
import {
@@ -83,6 +84,12 @@ const tools = [
label: strings.readOnly(),
property: "readonly"
},
{
key: "archive",
icon: Archive,
label: strings.archive(),
property: "archived"
},
{
key: "local-only",
icon: SyncOff,
@@ -837,10 +844,17 @@ export function Section({
}
function changeToggleState(
prop: "lock" | "readonly" | "local-only" | "pin" | "favorite",
prop: "lock" | "readonly" | "local-only" | "pin" | "favorite" | "archive",
session: ReadonlyEditorSession | DefaultEditorSession
) {
const { id: sessionId, readonly, localOnly, pinned, favorite } = session.note;
const {
id: sessionId,
readonly,
localOnly,
pinned,
favorite,
archived
} = session.note;
if (!sessionId) return;
switch (prop) {
case "lock":
@@ -855,6 +869,8 @@ function changeToggleState(
return noteStore.pin(!pinned, sessionId);
case "favorite":
return noteStore.favorite(!favorite, sessionId);
case "archive":
return noteStore.archive(!archived, sessionId);
default:
return;
}

View File

@@ -93,28 +93,22 @@ const features: Record<FeatureKeys, Feature> = {
]
: [
{
title: "New sidebar UI/UX",
title: "Archive",
subtitle: (
<>
Your notebooks &amp; tags are now displayed directly in the
sidebar making organization &amp; navigation much easier.
You can now archive your notes to hide them from the main list.
</>
)
},
{
title: "Hierarchical tree view for notebooks",
title: "Integrated Notesnook Importer",
subtitle: (
<>
Notebooks are now displayed in a hierarchical tree view so you
can jump back and forth between them easily.
Notesnook Importer is now integrated directly into the app
allowing for faster and more seamless import of your notes. Just
drag and drop your notes from other note-taking apps and viola!
</>
)
},
{
title: "Set any tag, color or notebook as homepage",
subtitle: (
<>You can now set any tag, color or notebook as your homepage.</>
)
}
],
cta: {

View File

@@ -48,6 +48,7 @@ import { ErrorText } from "../components/error-text";
import { BuyDialog } from "./buy-dialog";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
import { SettingsDialog } from "./settings";
type Step = {
title: string;
@@ -297,7 +298,9 @@ function Importer({ onClose }: { onClose: () => void }) {
variant="accent"
sx={{ borderRadius: 50, alignSelf: "center", px: 30 }}
onClick={() => {
window.open("https://importer.notesnook.com/", "_blank");
SettingsDialog.show({
activeSection: "importer"
});
onClose();
}}
>

View File

@@ -1,318 +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 { strings } from "@notesnook/intl";
import { Button, Flex, Input, Link, Text, Box } from "@theme-ui/components";
import { useCallback, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import { db } from "../../../common/db";
import { CheckCircleOutline } from "../../../components/icons";
import Accordion from "../../../components/accordion";
import { importFiles } from "../../../utils/importer";
import { useStore as useAppStore } from "../../../stores/app-store";
type Provider = { title: string; link: string };
const POPULAR_PROVIDERS: Provider[] = [
{
title: "Evernote",
link: "https://help.notesnook.com/importing-notes/import-notes-from-evernote"
},
{
title: "Simplenote",
link: "https://help.notesnook.com/importing-notes/import-notes-from-simplenote"
},
{
title: "Google Keep",
link: "https://help.notesnook.com/importing-notes/import-notes-from-googlekeep"
},
{
title: "Obsidian",
link: "https://help.notesnook.com/importing-notes/import-notes-from-obsidian"
},
{
title: "Joplin",
link: "https://help.notesnook.com/importing-notes/import-notes-from-joplin"
},
{
title: "Markdown files",
link: "https://help.notesnook.com/importing-notes/import-notes-from-markdown-files"
},
{
title: "other apps",
link: "https://help.notesnook.com/importing-notes/"
}
];
export function Importer() {
const [isDone, setIsDone] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [errors, setErrors] = useState<Error[]>([]);
const notesCounter = useRef<HTMLSpanElement>(null);
const importProgress = useRef<HTMLDivElement>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((files) => {
const newFiles = [...acceptedFiles, ...files];
return newFiles;
});
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/zip": [".zip"]
}
});
return (
<Flex
sx={{
flexDirection: "column",
// justifyContent: "center",
overflow: "hidden"
}}
>
{isImporting ? (
<>
<Text variant="title" sx={{ textAlign: "center", mb: 4, mt: 150 }}>
<span ref={notesCounter}>0</span> {strings.notesImported()}.
</Text>
<Flex
ref={importProgress}
sx={{
alignSelf: "start",
borderRadius: "default",
height: "5px",
bg: "accent",
width: `0%`
}}
/>
</>
) : isDone ? (
<>
<CheckCircleOutline color="accent" sx={{ mt: 150 }} />
<Text variant="body" my={2} sx={{ textAlign: "center" }}>
{strings.importCompleted()}. {strings.errorsOccured(errors.length)}
</Text>
<Button
variant="secondary"
sx={{ alignSelf: "center" }}
onClick={async () => {
setErrors([]);
setFiles([]);
setIsDone(false);
setIsImporting(false);
}}
>
{strings.startOver()}
</Button>
{errors.length > 0 && (
<Flex
my={1}
bg="var(--background-error)"
p={1}
sx={{ flexDirection: "column" }}
>
{errors.map((error) => (
<Text
key={error.message}
variant="body"
sx={{
color: "var(--paragraph-error)"
}}
>
{error.message}
</Text>
))}
</Flex>
)}
</>
) : (
<>
<Accordion
isClosed={false}
title="How to import your notes from other apps?"
containerSx={{
px: 2,
pb: 2,
border: "1px solid var(--border)",
borderTopWidth: 0,
borderRadius: "default",
borderTopLeftRadius: 0,
borderTopRightRadius: 0
}}
>
<Text variant="subtitle" sx={{ mt: 2 }}>
Quick start guide:
</Text>
<Box as="ol" sx={{ my: 1 }}>
<Text as="li" variant="body">
Go to{" "}
<Link
href="https://importer.notesnook.com/"
target="_blank"
sx={{ color: "accent" }}
>
https://importer.notesnook.com/
</Link>
</Text>
<Text as="li" variant="body">
Select the app you want to import from.
</Text>
<Text as="li" variant="body">
Drag drop or select the files you exported from the other app.
</Text>
<Text as="li" variant="body">
Start the importer and wait for it to complete processing.
</Text>
<Text as="li" variant="body">
Download the .zip file from the Importer.
</Text>
<Text as="li" variant="body">
Drop the .zip file below to complete your import.
</Text>
</Box>
<Text variant={"body"} sx={{ fontWeight: "bold" }}>
For detailed steps with screenshots, refer to the help article for
each app:
</Text>
<Box
sx={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 1,
mt: 1
}}
>
{POPULAR_PROVIDERS.map((provider) => (
<Button
key={provider.link}
variant="icon"
sx={{
borderRadius: "default",
border: "1px solid var(--border)",
textAlign: "left"
}}
onClick={() => window.open(provider.link, "_blank")}
>
Import from {provider.title}
</Button>
))}
</Box>
</Accordion>
<Flex
{...getRootProps()}
data-test-id="import-dialog-select-files"
sx={{
justifyContent: "center",
alignItems: "center",
minHeight: 200,
flexShrink: 0,
width: "full",
border: "2px dashed var(--border)",
borderRadius: "default",
mt: 2,
flexDirection: "column"
}}
>
<Input {...getInputProps()} />
<Text variant="body" sx={{ textAlign: "center" }}>
{isDragActive
? strings.dropFilesHere()
: strings.dragAndDropFiles()}
<br />
<Text variant="subBody">{strings.onlyZipSupported()}</Text>
</Text>
<Box sx={{ display: "flex", flexWrap: "wrap", mt: 2 }}>
{files.map((file, i) => (
<Text
key={file.name}
p={1}
sx={{
":hover": { bg: "hover" },
cursor: "pointer",
borderRadius: "default"
}}
onClick={() => {
setFiles((files) => {
const cloned = files.slice();
cloned.splice(i, 1);
return cloned;
});
}}
variant="body"
title="Click to remove"
>
{file.name}
</Text>
))}
</Box>
</Flex>
{/* <Flex my={1} sx={{ flexDirection: "column" }}>
</Flex> */}
<Button
variant="accent"
sx={{ alignSelf: "end", mt: 1 }}
onClick={async () => {
setIsDone(false);
setIsImporting(true);
await db.syncer?.acquireLock(async () => {
try {
for await (const message of importFiles(files)) {
switch (message.type) {
case "error":
setErrors((errors) => [...errors, message.error]);
break;
case "progress": {
const { count } = message;
if (notesCounter.current)
notesCounter.current.innerText = `${count}`;
break;
}
}
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
setErrors((errors) => [...errors, e as Error]);
}
}
});
await useAppStore.getState().refresh();
setIsDone(true);
setIsImporting(false);
}}
disabled={!files.length}
>
{files.length > 0
? "Start import"
: "Select files to start importing"}
</Button>
</>
)}
</Flex>
);
}

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SettingsGroup } from "./types";
import { Importer } from "./components/importer";
import { Importer } from "../../components/importer";
export const ImporterSettings: SettingsGroup[] = [
{

View File

@@ -48,6 +48,7 @@ export type TipContext =
| "reminders"
| "monographs"
| "trash"
| "archive"
| "attachments";
export type Tip = {
@@ -207,5 +208,8 @@ const DEFAULT_TIPS: Record<TipContext, Omit<Tip, "contexts">> = {
trash: {
text: ""
},
archive: {
text: strings.yourArchiveIsEmpty()
},
search: { text: "" }
};

View File

@@ -105,6 +105,15 @@ const routes = defineRoutes({
component: Trash
});
},
"/archive": () => {
useNoteStore.getState().setContext({ type: "archive" });
return defineRoute({
key: "notes",
title: strings.archive(),
type: "notes",
component: Notes
});
},
"/tags/:tagId": async ({ tagId }) => {
const tag = await db.tags.tag(tagId);
if (!tag) return NOT_FOUND_ROUTE;

View File

@@ -382,6 +382,8 @@ class EditorStore extends BaseStore<EditorStore> {
event.item.localOnly ?? session.note.localOnly;
session.note.favorite =
event.item.favorite ?? session.note.favorite;
session.note.archived =
event.item.archived ?? session.note.archived;
session.note.dateEdited =
event.item.dateEdited ?? session.note.dateEdited;
});

View File

@@ -62,7 +62,11 @@ class NoteStore extends BaseStore<NoteStore> {
contextNotes: context
? await notesFromContext(context).grouped(
db.settings.getGroupOptions(
context.type === "favorite" ? "favorites" : "notes"
context.type === "favorite"
? "favorites"
: context.type === "archive"
? "archive"
: "notes"
)
)
: undefined
@@ -85,6 +89,11 @@ class NoteStore extends BaseStore<NoteStore> {
await this.refresh();
};
archive = async (state: boolean, ...ids: string[]) => {
await db.notes.archive(state, ...ids);
await this.refresh();
};
unlock = async (id: string) => {
return await Vault.unlockNote(id).then(async (res) => {
await this.refresh();
@@ -141,6 +150,8 @@ export function notesFromContext(context: Context) {
.selector;
case "favorite":
return db.notes.favorites;
case "archive":
return db.notes.archived;
case "monographs":
return db.monographs.all;
}

View File

@@ -17,80 +17,21 @@ 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 { db } from "../common/db";
import {
Note,
Notebook,
ContentType,
LegacyNotebook
Note,
Notebook
} from "@notesnook-importer/core/dist/src/models";
import {
ATTACHMENTS_DIRECTORY_NAME,
NOTE_DATA_FILENAME
} from "@notesnook-importer/core/dist/src/utils/note-stream";
import { path } from "@notesnook-importer/core/dist/src/utils/path";
import { type ZipEntry } from "./streams/unzip-stream";
import { hashBuffer, writeEncryptedFile } from "../interfaces/fs";
import { Notebook as NotebookType } from "@notesnook/core";
import { SerializedKey } from "@notesnook/crypto";
import { db } from "../common/db";
import { writeEncryptedFile } from "../interfaces/fs";
export async function* importFiles(zipFiles: File[]) {
const { createUnzipIterator } = await import("./streams/unzip-stream");
for (const zip of zipFiles) {
let count = 0;
let filesRead = 0;
const attachments: Record<string, any> = {};
for await (const entry of createUnzipIterator(zip)) {
++filesRead;
const isAttachment = entry.name.includes(
`/${ATTACHMENTS_DIRECTORY_NAME}/`
);
const isNote = !isAttachment && entry.name.endsWith(NOTE_DATA_FILENAME);
try {
if (isAttachment) {
await processAttachment(entry, attachments);
} else if (isNote) {
await processNote(entry, attachments);
++count;
}
} catch (e) {
if (e instanceof Error) yield { type: "error" as const, error: e };
}
yield {
type: "progress" as const,
count,
filesRead
};
}
}
}
async function processAttachment(
entry: ZipEntry,
attachments: Record<string, any>
) {
const name = path.basename(entry.name);
if (!name || attachments[name] || (await db.attachments?.exists(name)))
return;
const data = await entry.arrayBuffer();
const { hash } = await hashBuffer(new Uint8Array(data));
if (hash !== name) {
throw new Error(`integrity check failed: ${name} !== ${hash}`);
}
const file = new File([data], name, {
type: "application/octet-stream"
});
const key = await db.attachments?.generateKey();
const cipherData = await writeEncryptedFile(file, key, name);
attachments[name] = { ...cipherData, key };
}
type EncryptedAttachmentFields = Awaited<
ReturnType<typeof writeEncryptedFile>
> & {
key: SerializedKey;
};
const colorMap: Record<string, string | undefined> = {
default: undefined,
@@ -107,19 +48,50 @@ const colorMap: Record<string, string | undefined> = {
yellow: "#FFC107"
};
async function processNote(entry: ZipEntry, attachments: Record<string, any>) {
const note = await fileToJson<Note>(entry);
for (const attachment of note.attachments || []) {
const cipherData = attachments[attachment.hash];
if (!cipherData || (await db.attachments?.exists(attachment.hash)))
export async function importNote(note: Note) {
const encryptedAttachmentFieldsMap = await processAttachments(
note.attachments
);
await processNote(note, encryptedAttachmentFieldsMap);
}
async function processAttachments(attachments: Note["attachments"]) {
if (!attachments) return {};
const map: Record<string, EncryptedAttachmentFields | undefined> = {};
for (const { hash, filename, data } of attachments) {
if (!data || !hash || map[hash]) {
continue;
}
const exists = await db.attachments?.exists(hash);
if (exists) continue;
const file = new File([data], filename, {
type: "application/octet-stream"
});
const key = await db.attachments?.generateKey();
const cipherData = await writeEncryptedFile(file, key, hash);
map[hash] = { ...cipherData, key };
}
return map;
}
async function processNote(
note: Note,
map: Record<string, EncryptedAttachmentFields | undefined>
) {
for (const attachment of note.attachments || []) {
const cipherData = map[attachment.hash];
if (!cipherData || (await db.attachments?.exists(attachment.hash))) {
continue;
}
await db.attachments?.add({
...cipherData,
hash: attachment.hash,
hashType: attachment.hashType,
filename: attachment.filename,
type: attachment.mime
mimeType: attachment.mime
});
}
@@ -185,39 +157,13 @@ async function processNote(entry: ZipEntry, attachments: Record<string, any>) {
}
for (const nb of notebooks) {
if ("notebook" in nb) {
const notebookId = await importLegacyNotebook(nb).catch(() => undefined);
if (!notebookId) continue;
const notebookIds = await importNotebook(nb).catch(() => undefined);
if (!notebookIds) continue;
for (const notebookId of notebookIds)
await db.notes.addToNotebook(notebookId, noteId);
} else {
const notebookIds = await importNotebook(nb).catch(() => undefined);
if (!notebookIds) continue;
for (const notebookId of notebookIds)
await db.notes.addToNotebook(notebookId, noteId);
}
}
}
async function fileToJson<T>(file: ZipEntry) {
const text = await file.text();
return JSON.parse(text) as T;
}
/**
* @deprecated
*/
async function importLegacyNotebook(
notebook: LegacyNotebook | undefined
): Promise<string | undefined> {
if (!notebook) return;
const nb = await db.notebooks.find(notebook.notebook);
return nb
? nb.id
: await db.notebooks.add({
title: notebook.notebook
});
}
async function importNotebook(
notebook: Notebook,
parent?: NotebookType

View File

@@ -35,7 +35,12 @@ function Notes(props: NotesProps) {
const context = useNotesStore((store) => store.context);
const contextNotes = useNotesStore((store) => store.contextNotes);
const refreshContext = useNotesStore((store) => store.refreshContext);
const type = context?.type === "favorite" ? "favorites" : "notes";
const type =
context?.type === "favorite"
? "favorites"
: context?.type === "archive"
? "archive"
: "notes";
const isCompact = useNotesStore((store) => store.viewMode === "compact");
const filteredItems = useSearch(
context?.type === "notebook" ? "notebook" : "notes",
@@ -62,6 +67,8 @@ function Notes(props: NotesProps) {
context={
context.type === "favorite"
? "favorites"
: context.type === "archive"
? "archive"
: context.type === "monographs"
? "monographs"
: "notes"

View File

@@ -682,3 +682,54 @@ for (const group of groups) {
}));
}
}
test("get archived notes", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
expect(await db.notes.archived.count()).toBeGreaterThan(0);
}));
test("archive note", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
const note = await db.notes.note(id);
expect(note?.archived).toBe(true);
}));
test("unarchive note", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
await db.notes.archive(false, id);
const note = await db.notes.note(id);
expect(note?.archived).toBe(false);
}));
test("archiving note should update cache.archived", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
const note = await db.notes.note(id);
expect(db.notes.cache.archived).toEqual([note?.id]);
}));
test("un-archiving note should update cache.archived", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
const note = await db.notes.note(id);
expect(db.notes.cache.archived).toEqual([note?.id]);
await db.notes.archive(false, id);
expect(db.notes.cache.archived).toEqual([]);
}));
test("archived note shouldn't be in all notes", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.archive(true, id);
expect(await db.notes.all.count()).toBe(0);
}));
test("archived note shouldn't be in favorites", () =>
noteTest().then(async ({ db, id }) => {
await db.notes.favorite(true, id);
await db.notes.archive(true, id);
expect(await db.notes.favorites.count()).toBe(0);
}));

View File

@@ -40,6 +40,7 @@ import { ICollection } from "./collection.js";
import { SQLCollection } from "../database/sql-collection.js";
import { isFalse } from "../database/index.js";
import { logger } from "../logger.js";
import { addItems, deleteItems } from "../utils/array.js";
export type ExportOptions = {
format: "html" | "md" | "txt" | "md-frontmatter";
@@ -51,6 +52,7 @@ export type ExportOptions = {
export class Notes implements ICollection {
name = "notes";
cache: { archived: string[] } = { archived: [] };
/**
* @internal
*/
@@ -69,6 +71,13 @@ export class Notes implements ICollection {
async init() {
await this.collection.init();
this.totalNotes = await this.collection.count();
await this.buildCache();
}
async buildCache() {
this.cache.archived = [];
const archived = await this.archived.ids();
this.cache.archived = archived;
}
async add(
@@ -234,7 +243,11 @@ export class Notes implements ICollection {
get all() {
return this.collection.createFilter<Note>(
(qb) => qb.where(isFalse("dateDeleted")).where(isFalse("deleted")),
(qb) =>
qb
.where(isFalse("dateDeleted"))
.where(isFalse("deleted"))
.where(isFalse("archived")),
this.db.options?.batchSize
);
}
@@ -277,11 +290,23 @@ export class Notes implements ICollection {
qb
.where(isFalse("dateDeleted"))
.where(isFalse("deleted"))
.where(isFalse("archived"))
.where("favorite", "==", true),
this.db.options?.batchSize
);
}
get archived() {
return this.collection.createFilter<Note>(
(qb) =>
qb
.where(isFalse("dateDeleted"))
.where(isFalse("deleted"))
.where("archived", "==", true),
this.db.options?.batchSize
);
}
exists(id: string) {
return this.collection.exists(id);
}
@@ -300,6 +325,14 @@ export class Notes implements ICollection {
favorite(state: boolean, ...ids: string[]) {
return this.collection.update(ids, { favorite: state });
}
async archive(state: boolean, ...ids: string[]) {
await this.collection.update(ids, { archived: state });
if (state) {
addItems(this.cache.archived, ...ids);
} else {
deleteItems(this.cache.archived, ...ids);
}
}
readonly(state: boolean, ...ids: string[]) {
return this.collection.update(ids, { readonly: state });
}

View File

@@ -395,6 +395,11 @@ class RelationsArray<TType extends keyof RelatableTable> {
this.db.trash.cache.notes.length > 0,
(b) => b.where("fromId", "not in", this.db.trash.cache.notes)
)
.$if(
!!this.types?.includes("note" as TType) &&
this.db.notes.cache.archived.length > 0,
(b) => b.where("fromId", "not in", this.db.notes.cache.archived)
)
.$if(
!!this.types?.includes("notebook" as TType) &&
this.db.trash.cache.notebooks.length > 0,
@@ -424,6 +429,11 @@ class RelationsArray<TType extends keyof RelatableTable> {
this.db.trash.cache.notes.length > 0,
(b) => b.where("toId", "not in", this.db.trash.cache.notes)
)
.$if(
!!this.types?.includes("note" as TType) &&
this.db.notes.cache.archived.length > 0,
(b) => b.where("toId", "not in", this.db.notes.cache.archived)
)
.$if(
!!this.types?.includes("notebook" as TType) &&
this.db.trash.cache.notebooks.length > 0,

View File

@@ -62,6 +62,7 @@ const defaultSettings: SettingItemMap = {
"groupOptions:notes": DEFAULT_GROUP_OPTIONS("notes"),
"groupOptions:notebooks": DEFAULT_GROUP_OPTIONS("notebooks"),
"groupOptions:favorites": DEFAULT_GROUP_OPTIONS("favorites"),
"groupOptions:archive": DEFAULT_GROUP_OPTIONS("archive"),
"groupOptions:home": DEFAULT_GROUP_OPTIONS("home"),
"groupOptions:reminders": DEFAULT_GROUP_OPTIONS("reminders"),

View File

@@ -232,7 +232,8 @@ const BooleanProperties: Set<BooleanFields> = new Set([
"readonly",
"remote",
"synced",
"isGeneratedTitle"
"isGeneratedTitle",
"archived"
]);
const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {

View File

@@ -391,6 +391,14 @@ export class NNMigrationProvider implements MigrationProvider {
.addColumn("isGeneratedTitle", "boolean")
.execute();
}
},
"9": {
async up(db) {
await db.schema
.alterTable("notes")
.addColumn("archived", "boolean")
.execute();
}
}
};
}

View File

@@ -49,7 +49,8 @@ export const GroupingKey = [
"tags",
"trash",
"favorites",
"reminders"
"reminders",
"archive"
] as const;
export type GroupingKey = (typeof GroupingKey)[number];
@@ -200,6 +201,7 @@ export interface Note extends BaseItem<"note"> {
deletedBy: null;
isGeneratedTitle?: boolean;
archived?: boolean;
}
export interface Notebook extends BaseItem<"notebook"> {

View File

@@ -31,6 +31,13 @@ export function addItem<T>(array: T[], item: T) {
return true;
}
export function addItems<T>(array: T[], ...items: T[]) {
for (const item of items) {
addItem(array, item);
}
return array;
}
export function deleteItem<T>(array: T[], item: T) {
return deleteAtIndex(array, array.indexOf(item));
}

View File

@@ -15,10 +15,10 @@
font-family: "Inter";
}
p {
font-family: "Inter";
/* p { */
/* font-family: "Inter"; */
/* color: var(--nn_primary_paragraph) ## TODO: use fixed color */
}
/* } */
::selection {
color: white;
@@ -37,6 +37,10 @@
height: 100%;
display: flex;
}
#statusbar p {
font-family: "Inter";
}
</style>
<meta name="description" content="Notesnook editor for mobile" />
<!--

View File

@@ -866,6 +866,10 @@ msgstr "Apply changes"
msgid "Applying changes"
msgstr "Applying changes"
#: src/strings.ts:2468
msgid "Archive"
msgstr "Archive"
#: src/strings.ts:1431
msgid "Are you scrolling a lot to find a specific note? Pin it to the top from Note properties."
msgstr "Are you scrolling a lot to find a specific note? Pin it to the top from Note properties."
@@ -7007,6 +7011,10 @@ msgstr "Your account password must be strong & unique."
msgid "Your account will be downgraded in {days} days"
msgstr "Your account will be downgraded in {days} days"
#: src/strings.ts:2469
msgid "Your archive is empty"
msgstr "Your archive is empty"
#: src/strings.ts:1914
msgid "Your backup is ready to download"
msgstr "Your backup is ready to download"

View File

@@ -866,6 +866,10 @@ msgstr ""
msgid "Applying changes"
msgstr ""
#: src/strings.ts:2468
msgid "Archive"
msgstr ""
#: src/strings.ts:1431
msgid "Are you scrolling a lot to find a specific note? Pin it to the top from Note properties."
msgstr ""
@@ -6953,6 +6957,10 @@ msgstr ""
msgid "Your account will be downgraded in {days} days"
msgstr ""
#: src/strings.ts:2469
msgid "Your archive is empty"
msgstr ""
#: src/strings.ts:1914
msgid "Your backup is ready to download"
msgstr ""

View File

@@ -2464,5 +2464,7 @@ Use this if changes from other devices are not appearing on this device. This wi
setAsHomepage: () => t`Set as homepage`,
defaultSidebarTab: () => t`Default sidebar tab`,
defaultSidebarTabDesc: () => t`Select the default sidebar tab`,
unsetAsHomepage: () => t`Reset homepage`
unsetAsHomepage: () => t`Reset homepage`,
archive: () => t`Archive`,
yourArchiveIsEmpty: () => t`Your archive is empty`
};