Compare commits

..

2 Commits

Author SHA1 Message Date
Ammar Ahmed
00d3f693b0 mobile: fix crash when taking camera picture 2024-05-07 15:53:57 +05:00
Ammar Ahmed
978e63ea78 mobile: fix camera permission 2024-05-07 14:54:19 +05:00
96 changed files with 942 additions and 1795 deletions

View File

@@ -148,7 +148,7 @@ jobs:
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-x86_64.apk
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v2
with:
name: sourcemaps
path: |

View File

@@ -148,7 +148,7 @@ jobs:
apps/mobile/native/android/app/build/outputs/apk/release/notesnook-x86_64.apk
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v2
with:
name: sourcemaps
path: |

View File

@@ -98,7 +98,7 @@ jobs:
api-private-key: ${{ secrets.API_KEY }}
- name: Upload Notesnook.ipa to Github
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v2
with:
name: Notesnook.zip
path: |

View File

@@ -40,7 +40,7 @@ jobs:
run: npm run build:test:web
- name: Archive build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v2
with:
name: build
path: apps/web/build/**/*
@@ -56,7 +56,7 @@ jobs:
uses: actions/checkout@v3
- name: Download build
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: build
path: ./apps/web/build
@@ -88,7 +88,7 @@ jobs:
working-directory: apps/web
- name: Upload test results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
if: failure()
with:
name: test-results-${{ matrix.shard }}

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.0.3",
"version": "3.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.0.3",
"version": "3.0.2",
"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.0.3",
"version": "3.0.2",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/index.js",

View File

@@ -38,7 +38,7 @@ await fs.rm("./build/", { force: true, recursive: true });
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
await exec(
"yarn nx build:desktop @notesnook/web",
"npx nx build:desktop @notesnook/web",
path.join(__dirname, "..", "..", "..")
);
}
@@ -58,15 +58,15 @@ await fs.cp(path.join(webAppPath, "build"), "build", {
});
if (args.variant === "mas") {
await exec(`yarn run bundle:mas`);
await exec(`npm run bundle:mas`);
} else {
await exec(`yarn run bundle`);
await exec(`npm run bundle`);
}
await exec(`yarn tsc`);
await exec(`npx tsc`);
if (args.run) {
await exec(`yarn electron-builder --dir --x64`);
await exec(`npx electron-builder --dir --x64`);
if (process.platform === "win32") {
await exec(`.\\output\\win-unpacked\\Notesnook.exe`);
} else if (process.platform === "darwin") {

View File

@@ -53,11 +53,11 @@ async function onChange(first) {
if (first) {
await fs.rm("./build/", { force: true, recursive: true });
await exec("yarn electron-builder install-app-deps");
await exec("npx electron-builder install-app-deps");
}
await exec(`yarn run bundle`);
execAsync(`yarn`, [`tsc`]);
await exec(`npm run bundle`);
execAsync(`npx`, [`tsc`]);
if (await isBundleSame()) {
console.log("Bundle is same. Doing nothing.");
@@ -66,7 +66,7 @@ async function onChange(first) {
if (first) {
await spawnAndWaitUntil(
["yarn", "nx", "start:desktop", "@notesnook/web"],
["npx", "nx", "start:desktop", "@notesnook/web"],
path.join(__dirname, "..", "..", ".."),
(data) => data.includes("Network: use --host to expose")
);
@@ -78,7 +78,7 @@ async function onChange(first) {
}
execAsync(
"yarn",
"npx",
["electron", path.join("build", "electron.js")],
true,
cleanup

View File

@@ -58,11 +58,11 @@ export const osIntegrationRouter = t.router({
customDns: t.procedure.query(() => config.customDns),
setCustomDns: t.procedure
.input(z.boolean().optional())
.input(z.boolean())
.mutation(({ input: customDns }) => {
if (customDns) enableCustomDns();
else disableCustomDns();
config.customDns = !!customDns;
config.customDns = customDns;
}),
proxyRules: t.procedure.query(() => config.proxyRules),

View File

@@ -20,13 +20,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { Linking, View } from "react-native";
//import SettingsBackupAndRestore from '../../screens/settings/backup-restore';
import { useThemeColors } from "@notesnook/theme";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import { eCloseAnnouncementDialog } from "../../utils/events";
import Sync from "../../services/sync";
import { useThemeColors } from "@notesnook/theme";
import { eCloseAnnouncementDialog, eCloseSheet } from "../../utils/events";
import { SIZE } from "../../utils/size";
import { sleep } from "../../utils/time";
import { PricingPlans } from "../premium/pricing-plans";
import SheetProvider from "../sheet-provider";
import { Progress } from "../sheets/progress";
import { Button } from "../ui/button";
import { allowedOnPlatform, getStyle } from "./functions";
@@ -54,6 +56,13 @@ export const Cta = ({ actions, style = {}, color, inline }) => {
/>
)
});
} else if (item.type === "force-sync") {
eSendEvent(eCloseSheet);
await sleep(300);
Progress.present();
Sync.run("global", true, true, () => {
eSendEvent(eCloseSheet);
});
}
};
return (

View File

@@ -67,7 +67,7 @@ export const Login = ({ changeMode }) => {
Progress.present();
setTimeout(() => {
if (!useUserStore.getState().syncing) {
Sync.run("global", false, "full");
Sync.run("global", false, true);
}
}, 5000);
});

View File

@@ -112,7 +112,7 @@ export const SessionExpired = () => {
if (!res) throw new Error("no token found");
if (db.tokenManager._isTokenExpired(res))
throw new Error("token expired");
Sync.run("global", false, "full", async (complete) => {
Sync.run("global", false, true, async (complete) => {
if (!complete) {
let user = await db.user.getUser();
if (!user) return;

View File

@@ -17,13 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useState } from "react";
import {
Image,
Platform,
ScrollView,
TouchableOpacity,
View
} from "react-native";
import { Image, ScrollView, TouchableOpacity, View } from "react-native";
import { Image as ImageType } from "react-native-image-crop-picker";
import { useThemeColors } from "../../../../../../packages/theme/dist";
import { presentSheet } from "../../../services/event-manager";
@@ -71,10 +65,7 @@ export default function AttachImage({
<TouchableOpacity key={item.filename} activeOpacity={0.9}>
<Image
source={{
uri:
Platform.OS === "ios"
? item.sourceURL || item.path
: item.path
uri: item.sourceURL || item.path
}}
style={{
width: 100,

View File

@@ -80,7 +80,7 @@ export default function List(props: ListProps) {
const groupOptions = useGroupOptions(groupType);
const _onRefresh = async () => {
Sync.run("global", false, "full", () => {
Sync.run("global", false, true, () => {
props.onRefresh?.();
});
};

View File

@@ -19,13 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import React, { Fragment, useState } from "react";
import {
ActivityIndicator,
Linking,
Platform,
StyleSheet,
View
} from "react-native";
import { ActivityIndicator, Platform, StyleSheet, View } from "react-native";
import FileViewer from "react-native-file-viewer";
import Share from "react-native-share";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
@@ -35,12 +29,14 @@ import { requestInAppReview } from "../../../services/app-review";
import {
PresentSheetOptions,
ToastManager,
eSendEvent,
presentSheet
} from "../../../services/event-manager";
import Exporter from "../../../services/exporter";
import PremiumService from "../../../services/premium";
import { useUserStore } from "../../../stores/use-user-store";
import { getElevationStyle } from "../../../utils/elevation";
import { eCloseSheet } from "../../../utils/events";
import { SIZE, ph, pv } from "../../../utils/size";
import { sleep } from "../../../utils/time";
import { Dialog } from "../../dialog";
@@ -69,7 +65,6 @@ const ExportNotesSheet = ({
filePath: string;
name: string;
type: string;
fileDir: string;
}
| undefined
>();
@@ -300,9 +295,7 @@ const ExportNotesSheet = ({
successfully as {result?.fileName}
</Paragraph>
<Button
title={
Platform.OS === "android" ? "Open file location" : "Open"
}
title="Open"
type="accent"
width={250}
fontSize={SIZE.md}
@@ -312,23 +305,20 @@ const ExportNotesSheet = ({
}}
onPress={async () => {
if (!result?.filePath) return;
if (Platform.OS === "android") {
Linking.openURL(result.fileDir).catch((e) => {
ToastManager.error(e as Error);
eSendEvent(eCloseSheet);
await sleep(500);
FileViewer.open(result?.filePath, {
showOpenWithDialog: true,
showAppsSuggestions: true
}).catch((e) => {
console.log(e);
ToastManager.show({
heading: "Cannot open",
message: `No application found to open ${result.name} file.`,
type: "success",
context: "local"
});
} else {
FileViewer.open(result?.filePath, {
showOpenWithDialog: true,
showAppsSuggestions: true
}).catch((e) => {
ToastManager.show({
heading: "Cannot open",
message: `No application found to open ${result.name} file.`,
type: "success",
context: "local"
});
});
}
});
}}
/>
<Button
@@ -341,10 +331,10 @@ const ExportNotesSheet = ({
borderRadius: 100
}}
onPress={async () => {
if (!result) return;
if (!result?.filePath) return;
if (Platform.OS === "ios") {
Share.open({
url: result?.fileDir + result.fileName
url: result.filePath
}).catch(console.log);
} else {
FileViewer.open(result.filePath, {
@@ -357,7 +347,7 @@ const ExportNotesSheet = ({
/>
<Button
title="Export in another format"
type="inverted"
type="secondaryAccented"
width={250}
fontSize={SIZE.md}
style={{

View File

@@ -35,10 +35,7 @@ import Input from "../../ui/input";
import { Pressable } from "../../ui/pressable";
import Paragraph from "../../ui/typography/paragraph";
import type { LinkAttributes } from "@notesnook/editor/dist/extensions/link";
import {
EditorEvents,
editorController
} from "../../../screens/editor/tiptap/utils";
import { editorController } from "../../../screens/editor/tiptap/utils";
const ListNoteItem = ({
id,
@@ -193,13 +190,13 @@ export default function LinkNote(props: {
}
: undefined
);
editorController.current?.postMessage(EditorEvents.resolve, {
data: {
editorController.current.commands.createInternalLink(
{
href: link,
title: selectedNote.title
},
resolverId: props.resolverId
});
props.resolverId
);
};
const onSelectNote = async (note: Note) => {

View File

@@ -86,7 +86,6 @@ import { updateStatusBarColor } from "../utils/colors";
import { BETA } from "../utils/constants";
import {
eCloseSheet,
eEditorReset,
eLoginSessionExpired,
eOnLoadNote,
eOpenAnnouncementDialog,
@@ -201,13 +200,11 @@ const onRequestPartialSync = async (
`onRequestPartialSync full:${full}, force:${force}, lastSyncTime:${lastSyncTime}`
);
await Sync.run(
"global",
force,
full ? "full" : "send",
undefined,
lastSyncTime
);
if (full || force) {
await Sync.run("global", force, full, undefined, lastSyncTime);
} else {
await Sync.run("global", false, false, undefined, lastSyncTime);
}
};
const onLogout = async (reason: string) => {
@@ -236,7 +233,7 @@ async function checkForShareExtensionLaunchedInBackground() {
if (notesAddedFromIntent || shareExtensionOpened) {
const id = useTabStore.getState().getCurrentNoteId();
const note = id && (await db.notes.note(id));
eSendEvent(eEditorReset);
eSendEvent("webview_reset");
if (note) setTimeout(() => eSendEvent("loadingNote", note), 1);
MMKV.removeItem("shareExtensionOpened");
}
@@ -533,14 +530,6 @@ export const useAppEvents = () => {
}
//@ts-ignore
globalThis["IS_SHARE_EXTENSION"] = false;
if (
SettingsService.getBackgroundEnterTime() + 60 * 1000 * 10 <
Date.now()
) {
// Reset the editor if the app has been in background for more than 10 minutes.
eSendEvent(eEditorReset);
}
} else {
await saveEditorState();
if (

View File

@@ -39,7 +39,6 @@ import {
eSubscribeEvent
} from "../../services/event-manager";
import {
eEditorReset,
eOnLoadNote,
eUnlockNote,
eUnlockWithBiometrics,
@@ -55,10 +54,8 @@ import { syncTabs, useTabStore } from "./tiptap/use-tab-store";
import {
editorController,
editorState,
openInternalLink,
randId
openInternalLink
} from "./tiptap/utils";
import { tabBarRef } from "../../utils/global-refs";
const style: ViewStyle = {
height: "100%",
@@ -105,20 +102,24 @@ const Editor = React.memo(
noToolbar,
noHeader
});
const renderKey = useRef(randId("editor-id") + editorId);
const renderKey = useRef(`editor-0` + editorId);
useImperativeHandle(ref, () => ({
get: () => editor
}));
useLockedNoteHandler();
const onError = useCallback(() => {
renderKey.current = randId("editor-id") + editorId;
renderKey.current =
renderKey.current === `editor-0`
? `editor-1` + editorId
: `editor-0` + editorId;
editor.state.current.ready = false;
editor.setLoading(true);
}, [editor, editorId]);
useEffect(() => {
const sub = [eSubscribeEvent(eEditorReset, onError)];
const sub = [eSubscribeEvent("webview_reset", onError)];
return () => {
sub.forEach((s) => s?.unsubscribe());
};
@@ -145,7 +146,6 @@ const Editor = React.memo(
nestedScrollEnabled
onError={onError}
injectedJavaScriptBeforeContentLoaded={`
globalThis.__DEV__ = ${__DEV__}
globalThis.readonly=${readonly};
globalThis.noToolbar=${noToolbar};
globalThis.noHeader=${noHeader};
@@ -339,7 +339,7 @@ const useLockedNoteHandler = () => {
}),
eSubscribeEvent(eUnlockWithPassword, onSubmit)
];
if (tabRef.current?.locked && tabBarRef.current?.page() === 2) {
if (tabRef.current?.locked) {
unlock();
}
return () => {

View File

@@ -48,6 +48,5 @@ export const EventTypes = {
unlockWithBiometrics: "editor-events:unlock-biometrics",
disableReadonlyMode: "editor-events:disable-readonly-mode",
readonlyEditorLoaded: "readonlyEditorLoaded",
error: "editorError",
dbLogger: "editor-events:dbLogger"
error: "editorError"
};

View File

@@ -41,7 +41,6 @@ import { FILE_SIZE_LIMIT, IMAGE_SIZE_LIMIT } from "../../../utils/constants";
import { eCloseSheet } from "../../../utils/events";
import { useTabStore } from "./use-tab-store";
import { editorController, editorState } from "./utils";
import { basename } from "pathe";
const showEncryptionSheet = (file: DocumentPickerResponse) => {
presentSheet({
@@ -109,6 +108,7 @@ const file = async (fileOptions: PickerOptions) => {
return;
}
console.log("file uri: ", uri);
uri = Platform.OS === "ios" ? santizeUri(uri) : uri;
showEncryptionSheet(file);
const hash = await Sodium.hashFile({
@@ -164,14 +164,13 @@ const file = async (fileOptions: PickerOptions) => {
eSendEvent(eCloseSheet);
}, 1000);
} catch (e) {
eSendEvent(eCloseSheet);
ToastManager.show({
heading: (e as Error).message,
message: "You need internet access to attach a file",
type: "error",
context: "global"
});
DatabaseLogger.error(e);
console.log("attachment error: ", e);
}
};
@@ -266,7 +265,6 @@ const handleImageResponse = async (
response: Image[],
options: PickerOptions
) => {
console.log(response, "result-file-picker");
const result = await AttachImage.present(response, options.context);
if (!result) return;
const compress = result.compress;
@@ -302,12 +300,7 @@ const handleImageResponse = async (
type: "url"
});
const fileName = image.sourceURL
? basename(image.sourceURL)
: image.filename || "image";
console.log("attaching image...", fileName);
const fileName = image.filename || "image";
console.log("attaching file...");
if (!(await attachFile(uri, hash, image.mime, fileName, options))) return;

View File

@@ -37,7 +37,6 @@ export type EditorState = {
isAwaitingResult: boolean;
scrollPosition: number;
overlay?: boolean;
initialLoadCalled?: boolean;
};
export type Settings = {
@@ -75,8 +74,6 @@ export type EditorMessage<T> = {
type: string;
noteId: string;
tabId: number;
resolverId?: string;
hasTimeout?: boolean;
};
export type SavePayload = {
@@ -87,7 +84,6 @@ export type SavePayload = {
sessionHistoryId?: number;
ignoreEdit: boolean;
tabId: number;
pendingChanges?: boolean;
};
export type AppState = {

View File

@@ -77,6 +77,7 @@ import { EditorMessage, EditorProps, useEditorType } from "./types";
import { useTabStore } from "./use-tab-store";
import { EditorEvents, editorState, openInternalLink } from "./utils";
const publishNote = async () => {
const user = useUserStore.getState().user;
if (!user) {
@@ -353,15 +354,8 @@ export const useEditorEvents = (
const data = event.nativeEvent.data;
const editorMessage = JSON.parse(data) as EditorMessage<any>;
if (editorMessage.hasTimeout && editorMessage.resolverId) {
editor.postMessage(EditorEvents.resolve, {
data: true,
resolverId: editorMessage.resolverId
});
}
if (editorMessage.type === EventTypes.load) {
DatabaseLogger.log("Editor is ready");
console.log("Editor loaded");
editor.onLoad();
return;
}
@@ -389,36 +383,21 @@ export const useEditorEvents = (
content: editorMessage.value.html as string,
noteId: noteId,
tabId: editorMessage.tabId,
ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit,
pendingChanges: editorMessage.value?.pendingChanges
ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit
});
break;
case EventTypes.title:
DatabaseLogger.log("EventTypes.title");
editor.saveContent({
type: editorMessage.type,
title: editorMessage.value?.title as string,
title: editorMessage.value as string,
noteId: noteId,
tabId: editorMessage.tabId,
ignoreEdit: false,
pendingChanges: editorMessage.value?.pendingChanges
ignoreEdit: false
});
break;
case EventTypes.logger:
logger.info("[EDITOR LOG]", editorMessage.value);
break;
case EventTypes.dbLogger:
if (editorMessage.value.error) {
DatabaseLogger.error(
editorMessage.value.error,
editorMessage.value.error,
{
message: "[EDITOR_ERROR]" + editorMessage.value.message
}
);
} else {
DatabaseLogger.info("[EDITOR_LOG]" + editorMessage.value.message);
}
logger.info("[WEBVIEW LOG]", editorMessage.value);
break;
case EventTypes.contentchange:
editor.onContentChanged(editorMessage.noteId);
@@ -506,17 +485,17 @@ export const useEditorEvents = (
console.log(
"Got attachment data:",
!!data,
editorMessage.resolverId
(editorMessage.value as any).resolverId
);
editor.postMessage(EditorEvents.resolve, {
resolverId: editorMessage.resolverId,
editor.postMessage(EditorEvents.attachmentData, {
resolverId: (editorMessage.value as any).resolverId,
data
});
})
.catch((e) => {
DatabaseLogger.error(e);
editor.postMessage(EditorEvents.resolve, {
resolverId: editorMessage.resolverId,
editor.postMessage(EditorEvents.attachmentData, {
resolverId: (editorMessage.value as any).resolverId,
data: undefined
});
});
@@ -643,7 +622,7 @@ export const useEditorEvents = (
case EventTypes.createInternalLink: {
LinkNote.present(
editorMessage.value.attributes,
editorMessage.resolverId as string
editorMessage.value.resolverId
);
break;
}

View File

@@ -39,7 +39,6 @@ import { DatabaseLogger, db } from "../../../common/database";
import useGlobalSafeAreaInsets from "../../../hooks/use-global-safe-area-insets";
import { DDS } from "../../../services/device-detection";
import {
ToastManager,
eSendEvent,
eSubscribeEvent,
eUnSubscribeEvent
@@ -47,20 +46,16 @@ import {
import Navigation from "../../../services/navigation";
import Notifications from "../../../services/notifications";
import SettingsService from "../../../services/settings";
import { useSettingStore } from "../../../stores/use-setting-store";
import { useTagStore } from "../../../stores/use-tag-store";
import {
eEditorReset,
eEditorTabFocused,
eOnLoadNote,
eShowMergeDialog,
eUpdateNoteInEditor
} from "../../../utils/events";
import { tabBarRef } from "../../../utils/global-refs";
import { unlockVault } from "../../../utils/unlock-vault";
import { onNoteCreated } from "../../notes/common";
import Commands from "./commands";
import { EventTypes } from "./editor-events";
import { SessionHistory } from "./session-history";
import { EditorState, SavePayload } from "./types";
import { syncTabs, useTabStore } from "./use-tab-store";
@@ -73,7 +68,6 @@ import {
isEditorLoaded,
post
} from "./utils";
import { sleep } from "../../../utils/time";
type NoteWithContent = Note & {
content?: NoteContent<false>;
@@ -151,9 +145,8 @@ export const useEditor = (
useEffect(() => {
const event = eSubscribeEvent(eEditorTabFocused, (tabId) => {
console.log("Editot tab focus changed", lastTabFocused.current, tabId);
if (lastTabFocused.current !== tabId) lock.current = false;
lastTabFocused.current = tabId as number;
console.log(tabId);
});
return () => {
event?.unsubscribe();
@@ -172,11 +165,10 @@ export const useEditor = (
useEffect(() => {
if (loading) {
overlay(true);
state.current.ready = false;
setLoading(false);
}
}, [loading, overlay]);
}, [loading]);
const withTimer = useCallback(
(id: string, fn: () => void, duration: number) => {
@@ -224,8 +216,7 @@ export const useEditor = (
type,
ignoreEdit,
sessionHistoryId: currentSessionHistoryId,
tabId,
pendingChanges
tabId
}: SavePayload) => {
if (currentNotes.current[id as string]?.readonly || readonly) return;
try {
@@ -286,26 +277,12 @@ export const useEditor = (
);
}, 50);
const saveTimer = setTimeout(() => {
DatabaseLogger.log(`Note save timeout: ${id}...`);
ToastManager.error(
new Error(
"Copy your changes and restart the app to avoid data loss. If the issue persists, please report to us at support@streetwriters.co."
),
"Saving note is taking too long",
"global",
15000
);
}, 30 * 1000);
if (!locked) {
DatabaseLogger.log(`Saving note: ${id}...`);
id = await db.notes?.add({ ...noteData });
saved = true;
DatabaseLogger.log(`Note saved: ${id}...`);
clearTimeout(saveTimer);
if (!note && id) {
editorSessionHistory.newSession(id);
if (id) {
@@ -339,22 +316,9 @@ export const useEditor = (
Notifications.pinNote(id as string);
}
} else {
if (!db.vault.unlocked) {
if (pendingChanges) await sleep(3000);
const unlocked = await unlockVault({
title: "Unlock vault to save note",
paragraph: `This note is locked, unlock to save ${
pendingChanges ? "some pending" : ""
} changes`,
context: "global"
});
if (!unlocked)
throw new Error("Could not save note, vault is locked");
}
noteData.contentId = note?.contentId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.vault?.save(noteData as any);
clearTimeout(saveTimer);
}
if (id && useTabStore.getState().getTabForNote(id) === tabId) {
@@ -377,16 +341,6 @@ export const useEditor = (
}
}
if (
id &&
id === useTabStore.getState().getCurrentNoteId() &&
pendingChanges
) {
postMessage(EditorEvents.title, title || note?.title, tabId);
postMessage(EditorEvents.html, data, tabId);
currentNotes.current[id] = note;
}
saveCount.current++;
return id;
} catch (e) {
@@ -461,11 +415,7 @@ export const useEditor = (
commands.focus(tabId);
});
} else {
if (!event.item) {
overlay(false);
return;
}
console.log("LOADING NOTE", event.item.id);
if (!event.item) return;
const item = event.item;
const currentTab = useTabStore
@@ -493,6 +443,7 @@ export const useEditor = (
useTabStore.getState().focusTab(tabId);
setTimeout(() => {
if (blockIdRef.current) {
console.log("scrolling to block", blockIdRef.current);
commands.scrollIntoViewById(blockIdRef.current);
blockIdRef.current = undefined;
}
@@ -551,7 +502,6 @@ export const useEditor = (
loadingState.current === currentContents.current[item.id]?.data
) {
// If note is already loading, return.
console.log("Note is already loading...");
return;
}
@@ -585,8 +535,11 @@ export const useEditor = (
10000
);
console.log("blockId", blockIdRef.current);
setTimeout(() => {
if (blockIdRef.current) {
console.log("scrolling to block", blockIdRef.current);
commands.scrollIntoViewById(blockIdRef.current);
blockIdRef.current = undefined;
}
@@ -620,131 +573,124 @@ export const useEditor = (
data: Note | ContentItem | TrashItem | DeletedItem,
isLocal?: boolean
) => {
try {
await (async () => {
if (SettingsService.get().disableRealtimeSync && !isLocal) return;
if (!data) return;
if (SettingsService.get().disableRealtimeSync && !isLocal) return;
if (!data) return;
if (isDeleted(data) || isTrashItem(data)) {
const tabId = useTabStore.getState().getTabForNote(data.id);
if (tabId !== undefined) {
console.log("Removing tab");
await commands.clearContent(tabId);
useTabStore.getState().removeTab(tabId);
if (isDeleted(data) || isTrashItem(data)) {
const tabId = useTabStore.getState().getTabForNote(data.id);
if (tabId !== undefined) {
console.log("Removing tab");
await commands.clearContent(tabId);
useTabStore.getState().removeTab(tabId);
}
return;
}
const noteId =
(data as ContentItem).type === "tiptap"
? (data as ContentItem).noteId
: data.id;
if (!useTabStore.getState().hasTabForNote(noteId)) return;
const tabId = useTabStore.getState().getTabForNote(noteId) as number;
const tab = useTabStore.getState().getTab(tabId);
const note = data.type === "note" ? data : await db.notes?.note(noteId);
lock.current = true;
// Handle this case where note was locked on another device and synced.
const locked = await db.vaults.itemExists(
currentNotes.current[noteId] as ItemReference
);
if (note) {
if (!locked && tab?.noteLocked) {
// Note lock removed.
if (tab.locked) {
if (useTabStore.getState().currentTab === tabId) {
eSendEvent(eOnLoadNote, {
item: note,
forced: true
});
} else {
useTabStore.getState().updateTab(tabId, {
locked: false,
noteLocked: false
});
commands.setLoading(true, tabId);
}
}
} else if (!tab?.noteLocked && locked) {
// Note lock added.
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
}
if (currentNotes.current[noteId]?.title !== note.title) {
postMessage(EditorEvents.title, note.title, tabId);
}
commands.setTags(note);
if (currentNotes.current[noteId]?.dateEdited !== note.dateEdited) {
commands.setStatus(
getFormattedDate(note.dateEdited, "date-time"),
"Saved",
tabId as number
);
}
console.log("readonly state changed...", note.readonly);
useTabStore.getState().updateTab(tabId, {
readonly: note.readonly
});
}
if (data.type === "tiptap" && note && !isLocal) {
if (lastContentChangeTime.current[noteId] >= data.dateEdited) {
lock.current = false;
return;
}
if (locked && isEncryptedContent(data)) {
const decryptedContent = await db.vault?.decryptContent(data, noteId);
if (!decryptedContent) {
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
} else {
await postMessage(
EditorEvents.updatehtml,
decryptedContent.data,
tabId
);
currentContents.current[note.id] = decryptedContent;
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current?.data) {
lock.current = false;
return;
}
const noteId =
(data as ContentItem).type === "tiptap"
? (data as ContentItem).noteId
: data.id;
if (!useTabStore.getState().hasTabForNote(noteId)) return;
const tabId = useTabStore.getState().getTabForNote(noteId) as number;
const tab = useTabStore.getState().getTab(tabId);
const note =
data.type === "note" ? data : await db.notes?.note(noteId);
lock.current = true;
// Handle this case where note was locked on another device and synced.
const locked = await db.vaults.itemExists(
currentNotes.current[noteId] as ItemReference
);
if (note) {
if (!locked && tab?.noteLocked) {
// Note lock removed.
if (tab.locked) {
if (useTabStore.getState().currentTab === tabId) {
eSendEvent(eOnLoadNote, {
item: note,
forced: true
});
} else {
useTabStore.getState().updateTab(tabId, {
locked: false,
noteLocked: false
});
commands.setLoading(true, tabId);
}
}
} else if (!tab?.noteLocked && locked) {
// Note lock added.
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
}
if (currentNotes.current[noteId]?.title !== note.title) {
postMessage(EditorEvents.title, note.title, tabId);
}
commands.setTags(note);
if (currentNotes.current[noteId]?.dateEdited !== note.dateEdited) {
commands.setStatus(
getFormattedDate(note.dateEdited, "date-time"),
"Saved",
tabId as number
);
}
console.log("readonly state changed...", note.readonly);
useTabStore.getState().updateTab(tabId, {
readonly: note.readonly
});
lastContentChangeTime.current[note.id] = note.dateEdited;
await postMessage(EditorEvents.updatehtml, _nextContent, tabId);
if (!isEncryptedContent(data)) {
currentContents.current[note.id] = data as UnencryptedContentItem;
}
if (data.type === "tiptap" && note && !isLocal) {
if (lastContentChangeTime.current[noteId] >= data.dateEdited) {
return;
}
if (locked && isEncryptedContent(data)) {
const decryptedContent = await db.vault?.decryptContent(data);
if (!decryptedContent) {
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
} else {
await postMessage(
EditorEvents.updatehtml,
decryptedContent.data,
tabId
);
currentContents.current[note.id] = decryptedContent;
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current?.data) {
return;
}
lastContentChangeTime.current[note.id] = note.dateEdited;
await postMessage(EditorEvents.updatehtml, _nextContent, tabId);
if (!isEncryptedContent(data)) {
currentContents.current[note.id] =
data as UnencryptedContentItem;
}
}
}
})();
} catch (e) {
DatabaseLogger.error(e as Error, "Error when applying sync changes");
} finally {
lock.current = false;
}
}
lock.current = false;
},
[postMessage, commands]
);
@@ -767,8 +713,7 @@ export const useEditor = (
type,
ignoreEdit,
noteId,
tabId,
pendingChanges
tabId
}: {
noteId?: string;
title?: string;
@@ -776,29 +721,18 @@ export const useEditor = (
type: string;
ignoreEdit: boolean;
tabId: number;
pendingChanges?: boolean;
}) => {
DatabaseLogger.log(
`saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}`
);
DatabaseLogger.log(`Saving content...`);
if (
lock.current ||
(currentLoadingNoteId.current &&
currentLoadingNoteId.current === noteId)
) {
DatabaseLogger.log(`Skipped saving content:
DatabaseLogger.log(`Skipped saving conent:
lock.current: ${lock.current}
currentLoadingNoteId.current: ${currentLoadingNoteId.current}
`);
if (lock.current) {
setTimeout(() => {
if (lock.current) {
DatabaseLogger.warn("Editor force removed lock after 5 seconds");
lock.current = false;
}
}, 5000);
}
return;
}
@@ -806,7 +740,7 @@ export const useEditor = (
lastContentChangeTime.current[noteId] = Date.now();
}
if (type === EventTypes.content && noteId) {
if (type === EditorEvents.content && noteId) {
currentContents.current[noteId as string] = {
data: content,
type: "tiptap",
@@ -821,8 +755,7 @@ export const useEditor = (
id: noteId,
ignoreEdit,
sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined,
tabId: tabId,
pendingChanges
tabId: tabId
};
withTimer(
noteId || "newnote",
@@ -834,16 +767,7 @@ export const useEditor = (
onChange(params.data);
return;
}
if (useSettingStore.getState().isAppLoading) {
const sub = useSettingStore.subscribe((state) => {
if (!state.isAppLoading) {
saveNote(params);
sub();
}
});
} else {
saveNote(params);
}
saveNote(params);
},
ignoreEdit ? 0 : 150
);
@@ -886,7 +810,7 @@ export const useEditor = (
useTabStore.getState().currentTab
))
) {
eSendEvent(eEditorReset, "onReady");
eSendEvent("webview_reset", "onReady");
return false;
} else {
syncTabs();
@@ -896,6 +820,7 @@ export const useEditor = (
}, [isDefaultEditor, restoreEditorState]);
const onLoad = useCallback(async () => {
if (currentNotes.current) overlay(true);
setTimeout(() => {
postMessage(EditorEvents.theme, theme);
});
@@ -910,22 +835,12 @@ export const useEditor = (
const noteId = useTabStore.getState().getCurrentNoteId();
if (!noteId) {
overlay(false);
loadNote({ newNote: true });
if (tabBarRef.current?.page() === 1) {
state.current.currentlyEditing = false;
}
} else if (state.current?.initialLoadCalled) {
const note = currentNotes.current[noteId];
if (note) {
loadNote({
item: note
});
}
}
if (!state.current?.initialLoadCalled) {
state.current.initialLoadCalled = true;
}
overlay(false);
}, [
postMessage,
theme,

View File

@@ -46,7 +46,7 @@ export function editorState() {
return editorController.current?.state.current || defaultState;
}
export const EditorEvents = {
export const EditorEvents: { [name: string]: string } = {
html: "native:html",
updatehtml: "native:updatehtml",
title: "native:title",
@@ -55,8 +55,7 @@ export const EditorEvents = {
logger: "native:logger",
status: "native:status",
keyboardShown: "native:keyboardShown",
attachmentData: "native:attachment-data",
resolve: "native:resolve"
attachmentData: "native:attachment-data"
};
export function randId(prefix: string) {

View File

@@ -458,7 +458,7 @@ export const settingsGroups: SettingSection[] = [
id: "background-sync",
name: "Background sync (experimental)",
description:
"Periodically wake up the app in background to run sync.",
"Periodically wake up the app in background to sync your notes from other devices.",
type: "switch",
property: "backgroundSync",
onChange: (value) => {
@@ -470,43 +470,22 @@ export const settingsGroups: SettingSection[] = [
}
},
{
id: "pull-sync",
name: "Force pull changes",
description: `Use this if some changes are not appearing on this device from other devices. This will pull everything from the server and overwrite with whatever is one this device.\n\nThese must only be used for troubleshooting. Using them regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co.`,
modifer: () => {
id: "sync-issues-fix",
name: "Having problems with sync",
description: "Try force sync to resolve issues with syncing",
icon: "sync-alert",
modifer: async () => {
presentDialog({
title: "Force Pull changes",
title: "Force sync",
paragraph:
"This must only be used for troubleshooting. Using this regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co.",
"If your data on two devices is out of sync even after trying to sync normally. You can run force sync to solve such problems. Usually you should never need to run this otherwise. Force sync means that all your data on this device is reuploaded to the server.",
negativeText: "Cancel",
positiveText: "Start",
positivePress: async () => {
eSendEvent(eCloseSheet);
await sleep(300);
Progress.present();
Sync.run("global", true, "fetch", () => {
eSendEvent(eCloseSheet);
});
}
});
}
},
{
id: "push-sync",
name: "Force push changes",
description: `Use this if some changes are not appearing on this device from other devices. This will pull everything from the server and overwrite with whatever is one this device.`,
modifer: () => {
presentDialog({
title: "Force Push changes",
paragraph:
"This must only be used for troubleshooting. Using this regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co.",
negativeText: "Cancel",
positiveText: "Start",
positivePress: async () => {
eSendEvent(eCloseSheet);
await sleep(300);
Progress.present();
Sync.run("global", true, "send", () => {
Sync.run("global", true, true, () => {
eSendEvent(eCloseSheet);
});
}

View File

@@ -162,7 +162,7 @@ export const ToastManager = {
});
},
hide: () => eSendEvent(eHideToast),
error: (e: Error, title?: string, context?: any, duration = 5000) => {
error: (e: Error, title?: string, context?: any) => {
ToastManager.show({
heading: title,
message: e?.message || "",
@@ -176,7 +176,7 @@ export const ToastManager = {
heading: "Logs copied!",
type: "success",
context: "global",
duration: duration
duration: 5000
});
}
});

View File

@@ -17,26 +17,40 @@ 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 { decode, EntityLevel } from "entities";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import RNHTMLtoPDF from "react-native-html-to-pdf-lite";
import * as ScopedStorage from "react-native-scoped-storage";
import { zip } from "react-native-zip-archive";
import { DatabaseLogger } from "../common/database/index";
import { DatabaseLogger, db } from "../common/database/index";
import Storage from "../common/database/storage";
import {
exportNote as _exportNote,
ExportableAttachment,
ExportableNote,
exportNotes
exportNotes,
sanitizeFilename
} from "@notesnook/common";
import { Note } from "@notesnook/core";
import { NoteContent } from "@notesnook/core/dist/collections/session-content";
import { FilteredSelector } from "@notesnook/core/dist/database/sql-collection";
import { basename, dirname, join } from "pathe";
import downloadAttachment from "../common/filesystem/download-attachment";
import { presentDialog } from "../components/dialog/functions";
import { useSettingStore } from "../stores/use-setting-store";
import BiometricService from "./biometrics";
import { ToastManager } from "./event-manager";
import { cacheDir } from "../common/filesystem/utils";
import { unlockVault } from "../utils/unlock-vault";
const MIMETypes = {
txt: "text/plain",
pdf: "application/pdf",
md: "text/markdown",
"md-frontmatter": "text/markdown",
html: "text/html"
};
const FolderNames: { [name: string]: string } = {
txt: "Text",
@@ -45,6 +59,16 @@ const FolderNames: { [name: string]: string } = {
html: "Html"
};
async function releasePermissions(path: string) {
if (Platform.OS === "ios") return;
const uris = await ScopedStorage.getPersistedUriPermissions();
for (const uri of uris) {
if (path.startsWith(uri)) {
await ScopedStorage.releasePersistableUriPermission(uri);
}
}
}
async function getPath(type: string) {
let path =
Platform.OS === "ios" &&
@@ -58,11 +82,153 @@ async function getPath(type: string) {
return path;
}
async function unlockVaultForNoteExport() {
return await unlockVault({
title: "Unlock vault",
paragraph: "Some exported notes are locked, Unlock to export them",
context: "export-notes"
async function save(
path: string,
data: string,
fileName: string,
extension: "txt" | "pdf" | "md" | "html" | "md-frontmatter"
) {
let uri;
if (Platform.OS === "android") {
uri = await ScopedStorage.writeFile(
path,
data,
`${fileName}.${extension}`,
MIMETypes[extension],
extension === "pdf" ? "base64" : "utf8",
false
);
await releasePermissions(path);
} else {
path = path + fileName + `.${extension}`;
await RNFetchBlob.fs.writeFile(path, data, "utf8");
}
return uri || path;
}
async function makeHtml(note: Note, content?: NoteContent<false>) {
let html = await db.notes.export(note.id, {
format: "html",
contentItem: content
});
if (!html) return "";
html = decode(html, {
level: EntityLevel.HTML
});
return html;
}
async function exportAs(
type: string,
note: Note,
bulk?: boolean,
content?: NoteContent<false>
) {
let data;
switch (type) {
case "html":
{
data = await makeHtml(note, content);
}
break;
case "md":
data = await db.notes.export(note.id, {
format: "md",
contentItem: content
});
break;
case "md-frontmatter":
data = await db.notes.export(note.id, {
format: "md-frontmatter",
contentItem: content
});
break;
case "pdf":
{
const html = await makeHtml(note, content);
const fileName = sanitizeFilename(note.title + Date.now(), {
replacement: "_"
});
const options = {
html: html,
fileName:
Platform.OS === "ios" ? "/exported/PDF/" + fileName : fileName,
width: 595,
height: 852,
bgColor: "#FFFFFF",
padding: 30,
base64: bulk || Platform.OS === "android"
} as { [name: string]: any };
if (Platform.OS === "ios") {
options.directory = "Documents";
}
const res = await RNHTMLtoPDF.convert(options);
data = !bulk && Platform.OS === "ios" ? res.filePath : res.base64;
if (bulk && res.filePath) {
RNFetchBlob.fs.unlink(res.filePath);
}
}
break;
case "txt":
{
data = await db.notes.export(note.id, {
format: "txt",
contentItem: content
});
}
break;
}
return data;
}
async function unlockVault() {
const biometry = await BiometricService.isBiometryAvailable();
const fingerprint = await BiometricService.hasInternetCredentials();
if (biometry && fingerprint) {
const credentials = await BiometricService.getCredentials(
"Unlock vault",
"Unlock vault to export locked notes"
);
if (credentials) {
return db.vault.unlock(credentials.password);
}
}
useSettingStore.getState().setSheetKeyboardHandler(false);
return new Promise((resolve) => {
setImmediate(() => {
presentDialog({
context: "export-notes",
input: true,
secureTextEntry: true,
positiveText: "Unlock",
title: "Unlock vault",
paragraph: "Some exported notes are locked, Unlock to export them",
inputPlaceholder: "Enter password",
positivePress: async (value) => {
const unlocked = await db.vault.unlock(value);
if (!unlocked) {
ToastManager.show({
heading: "Invalid password",
message: "Please enter a valid password",
type: "error",
context: "local"
});
return false;
}
resolve(unlocked);
useSettingStore.getState().setSheetKeyboardHandler(true);
return true;
},
onClose: () => {
resolve(false);
useSettingStore.getState().setSheetKeyboardHandler(true);
}
});
});
});
}
@@ -119,7 +285,6 @@ async function createZip(
callback: (progress?: string) => void
) {
const fileName = `nn-export-${totalNotes}-${type}-${Date.now()}.zip`;
const dir = path;
try {
callback("Creating zip");
const zipOutputPath =
@@ -127,7 +292,6 @@ async function createZip(
? join(path, fileName)
: join(RNFetchBlob.fs.dirs.CacheDir, fileName);
await zip(cacheFolder, zipOutputPath);
callback("Saving zip file");
if (Platform.OS === "android") {
const file = await ScopedStorage.createFile(
@@ -139,8 +303,6 @@ async function createZip(
await copyFileAsync("file://" + zipOutputPath, path);
await RNFetchBlob.fs.unlink(zipOutputPath);
callback();
} else {
path = zipOutputPath;
}
RNFetchBlob.fs.unlink(cacheFolder);
} catch (e) {
@@ -149,7 +311,6 @@ async function createZip(
return {
filePath: path,
fileDir: dir,
type: "application/zip",
name: "zip",
fileName: fileName,
@@ -227,7 +388,7 @@ async function bulkExport(
let currentAttachmentProgress = 0;
for await (const item of exportNotes(notes, {
format: type,
unlockVault: unlockVaultForNoteExport as () => Promise<boolean>
unlockVault: unlockVault as () => Promise<boolean>
})) {
if (item instanceof Error) {
DatabaseLogger.error(item);
@@ -271,7 +432,7 @@ async function exportNote(
let currentAttachmentProgress = 0;
for await (const item of _exportNote(note, {
format: type,
unlockVault: unlockVaultForNoteExport as () => Promise<boolean>
unlockVault: unlockVault as () => Promise<boolean>
})) {
if (item instanceof Error) {
DatabaseLogger.error(item);

View File

@@ -191,11 +191,11 @@ function canLockAppInBackground() {
}
let backgroundEnterTime = 0;
function appEnteredBackground() {
backgroundEnterTime = Date.now();
if (canLockAppInBackground()) {
backgroundEnterTime = Date.now();
}
}
const getBackgroundEnterTime = () => backgroundEnterTime;
function shouldLockAppOnEnterForeground() {
if (
useUserStore.getState().disableAppLockRequests ||
@@ -227,8 +227,7 @@ export const SettingsService = {
shouldLockAppOnEnterForeground,
canLockAppInBackground,
appEnteredBackground,
setPrivacyScreen,
getBackgroundEnterTime
setPrivacyScreen
};
init();

View File

@@ -32,24 +32,19 @@ export const ignoredMessages = [
"WebSocket failed to connect",
"Failed to start the HttpConnection before"
];
let pendingSync: any = undefined;
let syncTimer: NodeJS.Timeout;
let pendingSync = undefined;
let syncTimer = 0;
const run = async (
context = "global",
forced = false,
type: "full" | "send" | "fetch" = "full",
onCompleted?: (status?: number) => void,
lastSyncTime?: number
full = true,
onCompleted,
lastSyncTime
) => {
if (useUserStore.getState().syncing) {
DatabaseLogger.info("Sync in progress");
pendingSync = {
forced,
type: type,
context: context,
onCompleted,
lastSyncTime
full: full
};
return;
}
@@ -75,7 +70,7 @@ const run = async (
) {
initAfterSync();
pendingSync = undefined;
return onCompleted?.(SyncStatus.Failed);
return onCompleted?.(false);
}
userstore.setSyncing(true);
@@ -85,8 +80,9 @@ const run = async (
await BackgroundSync.doInBackground(async () => {
try {
await db.sync({
type: type,
force: forced
type: full ? "full" : "send",
force: forced,
lastSyncTime
});
} catch (e) {
error = e;
@@ -99,16 +95,14 @@ const run = async (
} catch (e) {
error = e;
if (
!ignoredMessages.find((message) =>
(e as Error).message?.includes(message)
) &&
!ignoredMessages.find((message) => e.message?.includes(message)) &&
userstore.user &&
status.isConnected &&
status.isInternetReachable
) {
userstore.setSyncing(false, SyncStatus.Failed);
if (status.isConnected && status.isInternetReachable) {
ToastManager.error(e as Error, "Sync failed", context);
ToastManager.error(e, "Sync failed", context);
}
}
@@ -121,14 +115,7 @@ const run = async (
);
onCompleted?.(error ? SyncStatus.Failed : SyncStatus.Passed);
setImmediate(() => {
if (pendingSync)
Sync.run(
pendingSync.context,
pendingSync.forced,
pendingSync.type,
pendingSync.onCompleted,
pendingSync.lastSyncTime
);
if (pendingSync) Sync.run("global", false, pendingSync.full);
});
}
}, 300);

View File

@@ -174,4 +174,3 @@ export const eUnlockWithPassword = "619";
export const eUpdateNoteInEditor = "620";
export const eOnEnterEditor = "621";
export const eOnExitEditor = "622";
export const eEditorReset = "623";

View File

@@ -1,91 +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 { db } from "../common/database";
import { presentDialog } from "../components/dialog/functions";
import BiometricService from "../services/biometrics";
import { ToastManager } from "../services/event-manager";
import { useSettingStore } from "../stores/use-setting-store";
let unlockPromise: Promise<any> | undefined = undefined;
export async function unlockVault({
context,
title,
paragraph
}: {
context?: string;
title: string;
paragraph: string;
}) {
if (unlockPromise) {
console.log("Unlocking.... waiting for unlock promise");
return unlockPromise;
}
unlockPromise = new Promise(async (resolve) => {
const result = await (async () => {
if (db.vault.unlocked) return true;
const biometry = await BiometricService.isBiometryAvailable();
const fingerprint = await BiometricService.hasInternetCredentials();
if (biometry && fingerprint) {
const credentials = await BiometricService.getCredentials(
title,
paragraph
);
if (credentials) {
return db.vault.unlock(credentials.password);
}
}
useSettingStore.getState().setSheetKeyboardHandler(false);
return new Promise((resolve) => {
setImmediate(() => {
presentDialog({
context: context,
input: true,
secureTextEntry: true,
positiveText: "Unlock",
title: title,
paragraph: paragraph,
inputPlaceholder: "Enter password",
positivePress: async (value) => {
const unlocked = await db.vault.unlock(value);
if (!unlocked) {
ToastManager.show({
heading: "Invalid password",
message: "Please enter a valid password",
type: "error",
context: "local"
});
return false;
}
resolve(unlocked);
useSettingStore.getState().setSheetKeyboardHandler(true);
return true;
},
onClose: () => {
resolve(false);
useSettingStore.getState().setSheetKeyboardHandler(true);
}
});
});
});
})();
unlockPromise = undefined;
resolve(result);
});
return unlockPromise;
}

View File

@@ -111,7 +111,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled true
versionCode 3011
versionCode 3010
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

@@ -1,4 +1,3 @@
- Added push/pull changes to troubleshoot sync issues in settings
- Bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -1015,7 +1015,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2100;
CURRENT_PROJECT_VERSION = 2099;
DEVELOPMENT_TEAM = 53CWBG3QUC;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -1089,7 +1089,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.3;
MARKETING_VERSION = 3.0.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -1120,7 +1120,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 2100;
CURRENT_PROJECT_VERSION = 2099;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -1194,7 +1194,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.3;
MARKETING_VERSION = 3.0.2;
ONLY_ACTIVE_ARCH = NO;
OTHER_LDFLAGS = (
"$(inherited)",
@@ -1353,7 +1353,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2100;
CURRENT_PROJECT_VERSION = 2099;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 53CWBG3QUC;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -1365,7 +1365,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.0.3;
MARKETING_VERSION = 3.0.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
@@ -1396,7 +1396,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 2100;
CURRENT_PROJECT_VERSION = 2099;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
@@ -1409,7 +1409,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.0.3;
MARKETING_VERSION = 3.0.2;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1439,7 +1439,7 @@
CODE_SIGN_ENTITLEMENTS = "Make Note/Make Note.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2100;
CURRENT_PROJECT_VERSION = 2099;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 53CWBG3QUC;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -1513,7 +1513,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.0.3;
MARKETING_VERSION = 3.0.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
@@ -1544,7 +1544,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 2100;
CURRENT_PROJECT_VERSION = 2099;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
@@ -1619,7 +1619,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.0.3;
MARKETING_VERSION = 3.0.2;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -350,8 +350,7 @@ PODS:
- react-native-theme-switch-animation (0.6.0):
- RCT-Folly (= 2021.07.22.00)
- React-Core
- react-native-webview (13.10.0):
- RCT-Folly (= 2021.07.22.00)
- react-native-webview (11.26.1):
- React-Core
- React-NativeModulesApple (0.72.0):
- React-callinvoker
@@ -908,7 +907,7 @@ SPEC CHECKSUMS:
react-native-share-extension: faed334b1ddf165f1e576fcabd3dc1c9e748bfa9
react-native-sodium: 955bb0dc3ea05f8ea06d5e96cb89d1be7b5d7681
react-native-theme-switch-animation: 220f883f7be290e79f2ab022093ed1a7a5929e6d
react-native-webview: 90153193e679163ec257011fa457d02765120210
react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1
React-NativeModulesApple: 1d81d927ef1a67a3545a01e14c2e98500bf9b199
React-perflogger: 684a11499a0589cc42135d6d5cc04d0e4e0e261a
React-RCTActionSheet: 00b0a4c382a13b834124fa3f541a7d8d1d56efb9
@@ -959,4 +958,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 2b8b28a341b202bf3ca5f231b75bb05893486ed8
COCOAPODS: 1.12.1
COCOAPODS: 1.14.2

View File

@@ -60,7 +60,7 @@
"react-native-swiper-flatlist": "3.2.2",
"react-native-tooltips": "^1.0.3",
"react-native-vector-icons": "9.2.0",
"react-native-webview": "^13.10.0",
"react-native-webview": "^11.14.1",
"react-native-zip-archive": "6.0.9",
"react-native-quick-sqlite": "^8.0.6",
"react-native-theme-switch-animation": "^0.6.0",

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.0.2",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.0.2",
"version": "3.0.0",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"workspaces": [
@@ -3234,7 +3234,6 @@
"@szhsin/react-menu": "^4.1.0",
"buffer": "^6.0.3",
"framer-motion": "^10.16.8",
"localforage": "^1.10.0",
"mdi-react": "9.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -28496,7 +28495,7 @@
"react-native-tooltips": "^1.0.3",
"react-native-url-polyfill": "^2.0.0",
"react-native-vector-icons": "9.2.0",
"react-native-webview": "^13.10.0",
"react-native-webview": "^11.14.1",
"react-native-zip-archive": "6.0.9"
},
"devDependencies": {
@@ -45373,9 +45372,8 @@
}
},
"node_modules/react-native-webview": {
"version": "13.10.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.10.0.tgz",
"integrity": "sha512-bntBbc3JHBve17NL5fqBWPiOYYDz1VmYUPA0UbsUPHgZj6t7TXUQd4yn2yL//XFKXPDlvMO+MPB9E1uAYpEp/g==",
"version": "11.26.1",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "2.0.0",
"invariant": "2.2.4"

View File

@@ -1,6 +1,6 @@
{
"name": "@notesnook/mobile",
"version": "3.0.3",
"version": "3.0.2",
"private": true,
"license": "GPL-3.0-or-later",
"workspaces": [

View File

@@ -1,12 +0,0 @@
diff --git a/node_modules/react-native-image-crop-picker/android/src/main/java/com/reactnative/ivpusic/imagepicker/PickerModule.java b/node_modules/react-native-image-crop-picker/android/src/main/java/com/reactnative/ivpusic/imagepicker/PickerModule.java
index 5de0845..1b158d8 100644
--- a/node_modules/react-native-image-crop-picker/android/src/main/java/com/reactnative/ivpusic/imagepicker/PickerModule.java
+++ b/node_modules/react-native-image-crop-picker/android/src/main/java/com/reactnative/ivpusic/imagepicker/PickerModule.java
@@ -692,6 +692,7 @@ class PickerModule extends ReactContextBaseJavaModule implements ActivityEventLi
image.putString("mime", options.outMimeType);
image.putInt("size", (int) new File(compressedImagePath).length());
image.putString("modificationDate", String.valueOf(modificationDate));
+ image.putString("sourceURL", path);
if (includeBase64) {
image.putString("data", getBase64StringFromFile(compressedImagePath));

View File

@@ -0,0 +1,169 @@
diff --git a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/Utils.java b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/Utils.java
index 8e59033..c87febe 100644
--- a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/Utils.java
+++ b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/Utils.java
@@ -1,6 +1,7 @@
package com.imagepicker;
import android.Manifest;
+import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ClipData;
import android.content.ContentResolver;
@@ -21,6 +22,7 @@ import android.provider.OpenableColumns;
import android.util.Base64;
import android.webkit.MimeTypeMap;
+import androidx.annotation.NonNull;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
import androidx.exifinterface.media.ExifInterface;
@@ -36,6 +38,7 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -266,7 +269,9 @@ public class Utils {
MediaMetadataRetriever m = new MediaMetadataRetriever();
m.setDataSource(context, uri);
int duration = Math.round(Float.parseFloat(m.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION))) / 1000;
- m.release();
+ try {
+ m.release();
+ } catch(Exception e) {}
return duration;
}
@@ -383,7 +388,7 @@ public class Utils {
return fileUris;
}
- static ReadableMap getImageResponseMap(Uri uri, Options options, Context context) {
+ static WritableMap getImageResponseMap(Uri uri, Options options, Context context) {
String fileName = uri.getLastPathSegment();
int[] dimensions = getImageDimensions(uri, context);
@@ -418,13 +423,16 @@ public class Utils {
for(int i = 0; i < fileUris.size(); ++i) {
Uri uri = fileUris.get(i);
+ String fileName = getNameFromURI(context,uri);
if (isImageType(uri, context)) {
if (uri.getScheme().contains("content")) {
uri = getAppSpecificStorageUri(uri, context);
}
uri = resizeImage(uri, context, options);
- assets.pushMap(getImageResponseMap(uri, options, context));
+ WritableMap resMap = getImageResponseMap(uri, options, context);
+ resMap.putString("originalFileName",fileName);
+ assets.pushMap(resMap);
} else if (isVideoType(uri, context)) {
assets.pushMap(getVideoResponseMap(uri, context));
} else {
@@ -438,6 +446,32 @@ public class Utils {
return response;
}
+ /**
+ * Return file name from Uri given.
+ * @param context the context, cannot be null.
+ * @param uri uri request for file name, cannot be null
+ * @return the corresponding display name for file defined in uri or null if error occurs.
+ */
+ @SuppressLint("Range")
+ static String getNameFromURI(@NonNull Context context, @NonNull Uri uri) {
+ String result = null;
+ Cursor c = null;
+ try {
+ c = context.getContentResolver().query(uri, null, null, null, null);
+ c.moveToFirst();
+ result = c.getString(c.getColumnIndex(OpenableColumns.DISPLAY_NAME));
+ }
+ catch (Exception e){
+ // error occurs
+ }
+ finally {
+ if(c != null){
+ c.close();
+ }
+ }
+ return result;
+ }
+
static ReadableMap getErrorMap(String errCode, String errMsg) {
WritableMap map = Arguments.createMap();
map.putString("errorCode", errCode);
diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.m b/node_modules/react-native-image-picker/ios/ImagePickerManager.m
index b634ffb..18b8950 100644
--- a/node_modules/react-native-image-picker/ios/ImagePickerManager.m
+++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.m
@@ -96,6 +96,39 @@ - (void) showPickerViewController:(UIViewController *)picker
});
}
+-(NSString *)cachesDirectoryName
+{
+ static NSString *cachePath = nil;
+ if(!cachePath) {
+ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
+ cachePath = [paths objectAtIndex:0];
+ }
+
+ return cachePath;
+}
+
+///
+/// Builds paths for identifiers in the cache directory
+///
+-(NSString *)pathForName:(NSString *)name
+{
+ NSString *cachePath = [self cachesDirectoryName];
+ NSString *path = [cachePath stringByAppendingPathComponent:name];
+ return path;
+}
+
+#pragma mark - NSData Cache methods
+
+///
+/// Saves the given data to the cache directory
+///
+-(NSString *)saveToCacheDirectory:(NSData *)data withName:(NSString *)name
+{
+ NSString *path = [self pathForName:name];
+ [data writeToFile:path atomically:YES];
+ return path;
+}
+
#pragma mark - Helpers
-(NSMutableDictionary *)mapImageToAsset:(UIImage *)image data:(NSData *)data {
@@ -121,22 +154,14 @@ -(NSMutableDictionary *)mapImageToAsset:(UIImage *)image data:(NSData *)data {
asset[@"type"] = [@"image/" stringByAppendingString:fileType];
NSString *fileName = [self getImageFileName:fileType];
- NSString *path = [[NSTemporaryDirectory() stringByStandardizingPath] stringByAppendingPathComponent:fileName];
- [data writeToFile:path atomically:YES];
+ NSString *path = [self saveToCacheDirectory:data withName:fileName];
if ([self.options[@"includeBase64"] boolValue]) {
asset[@"base64"] = [data base64EncodedStringWithOptions:0];
}
- NSURL *fileURL = [NSURL fileURLWithPath:path];
- asset[@"uri"] = [fileURL absoluteString];
-
- NSNumber *fileSizeValue = nil;
- NSError *fileSizeError = nil;
- [fileURL getResourceValue:&fileSizeValue forKey:NSURLFileSizeKey error:&fileSizeError];
- if (fileSizeValue){
- asset[@"fileSize"] = fileSizeValue;
- }
+ asset[@"uri"] = path;
+ asset[@"fileSize"] = [NSNumber numberWithInteger:[data length]];
asset[@"fileName"] = fileName;
asset[@"width"] = @(image.size.width);

View File

@@ -1 +1 @@
53ad8e4e40ebebd0f400498dTest note 1 (0) Test note 1 (1) Test note 1 (2) Test note 1 (3) Test note 1 (4) Test note 1 (5) Test note 1 (6) Test note 1 (7) Test note 1 (8) Test note 1 (9)
Test note 1 (9) Test note 1 (8) Test note 1 (7) Test note 1 (6) Test note 1 (5) Test note 1 (4) Test note 1 (3) Test note 1 (2) Test note 1 (1) Test note 1 (0) 53ad8e4e40ebebd0f400498d

View File

@@ -1 +1 @@
f054d19e9a2f46eff7b9bb25Test note 2 (0)Test note 2 (1)Test note 2 (2)Test note 2 (3)Test note 2 (4)Test note 2 (5)Test note 2 (6)Test note 2 (7)Test note 2 (8)Test note 2 (9)
Test note 2 (9)Test note 2 (8)Test note 2 (7)Test note 2 (6)Test note 2 (5)Test note 2 (4)Test note 2 (3)Test note 2 (2)Test note 2 (1)Test note 2 (0)f054d19e9a2f46eff7b9bb25

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -1 +1 @@
This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1An edit I made
An edit I madeThis is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1

View File

@@ -262,7 +262,7 @@ test("using Shift+Click when no notes are selected should not crash the app", as
info.setTimeout(60 * 1000);
const { notes } = await populateList(page, 5);
await notes.focus();
await page.reload();
const note = await notes.findNote({ title: "Test note 3" });

View File

@@ -123,7 +123,7 @@ export class AppModel {
);
}
async waitForSync(state: "completed" | "synced" | "syncing" = "completed") {
async waitForSync(state: "completed" | "synced" = "completed") {
await this.page
.locator(getTestId(`sync-status-${state}`))
.waitFor({ state: "visible" });

View File

@@ -284,9 +284,11 @@ test("change title of a locked note", async ({ page }) => {
await page.waitForTimeout(150);
await page.reload();
await notes.waitForList();
const editedNote = await notes.findNote({ title });
await page.waitForTimeout(500);
const editedNote = await notes.findNote({ title, content: NOTE.content });
await editedNote?.openLockedNote(PASSWORD);
await notes.editor.waitForLoading();
expect(await note?.getTitle()).toContain(title);
expect(await notes.editor.getTitle()).toContain(title);
});

View File

@@ -17,25 +17,12 @@
<link rel="stylesheet" href="https://app.notesnook.com/assets/editor-styles.css?d=0">
<style>
.image-container {
display: block;
img {
max-width: 100% !important;
height: auto !important;
border-radius: 5px;
}
.image-container.align-right {
display: flex;
justify-content: end;
}
.image-container.align-center {
display: flex;
justify-content: center;
}
.image-container.float {
float: left;
}
.image-container.float.align-right {
float: right;
}
body {
background-color: transparent !important;
color: #202124;
@@ -179,6 +166,6 @@
</head>
<body>
<h1>Test 1</h1>
<p data-block-id="xxx" data-spacing="double">This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1</p>
<p data-spacing="double">This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1</p>
</body>
</html>

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { test, Browser, expect } from "@playwright/test";
import { AppModel } from "./models/app.model";
import { USER } from "./utils";
import exp from "constants";
async function createDevice(browser: Browser) {
// Create two isolated browser contexts
@@ -39,9 +40,7 @@ async function actAndSync<T>(
) {
const results = await Promise.all([
...actions.filter((a) => !!a),
...devices.map((d) =>
d.waitForSync("syncing").then(() => d.waitForSync("synced"))
)
...devices.map((d) => d.waitForSync("synced"))
]);
await Promise.all(devices.map((d) => d.page.waitForTimeout(2000)));
@@ -62,7 +61,6 @@ test(`edits in a note opened on 2 devices should sync in real-time`, async ({
createDevice(browser),
createDevice(browser)
]);
const [notesA, notesB] = await Promise.all(
[deviceA, deviceB].map((d) => d.goToNotes())
);
@@ -74,17 +72,14 @@ test(`edits in a note opened on 2 devices should sync in real-time`, async ({
if ((await notesB.editor.getContent("text")) !== "")
await actAndSync([deviceA, deviceB], notesB.editor.clear());
await expect(notesA.editor.content).toBeEmpty();
await expect(notesB.editor.content).toBeEmpty();
await expect(notesA.editor.content).toHaveText("");
await expect(notesB.editor.content).toHaveText("");
await actAndSync([deviceA, deviceB], notesB.editor.setContent(newContent));
expect(noteA).toBeDefined();
expect(noteB).toBeDefined();
await expect(notesA.editor.content).toHaveText(newContent);
await expect(notesB.editor.content).toHaveText(newContent);
await (await deviceA.goToSettings())?.logout();
await (await deviceB.goToSettings())?.logout();
});
function makeid(length: number) {

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/web",
"version": "3.0.3",
"version": "3.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.0.3",
"version": "3.0.2",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.0.3",
"version": "3.0.2",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",

View File

@@ -31,8 +31,6 @@ import {
} from "@notesnook/common";
import Vault from "./vault";
import { ExportStream } from "../utils/streams/export-stream";
import { showToast } from "../utils/toast";
import { confirm } from "./dialog-controller";
export async function exportToPDF(
title: string,
@@ -81,36 +79,20 @@ export async function exportNotes(
format: "pdf" | "md" | "txt" | "html" | "md-frontmatter",
notes: FilteredSelector<Note>
): Promise<boolean> {
const result = await TaskManager.startTask({
return await TaskManager.startTask({
type: "modal",
title: "Exporting notes",
subtitle: "Please wait while your notes are exported.",
action: async (report) => {
const errors: Error[] = [];
const exportStream = new ExportStream(report, (e) => errors.push(e));
await fromAsyncIterator(
_exportNotes(notes, { format, unlockVault: Vault.unlockVault })
)
.pipeThrough(exportStream)
.pipeThrough(new ExportStream(report))
.pipeThrough(createZipStream())
.pipeTo(await createWriteStream("notes.zip"));
return {
errors,
count: exportStream.progress
};
return true;
}
});
confirm({
title: `Exported ${result.count} notes`,
message:
result.errors.length > 0
? `Export completed with ${result.errors.length} errors:
${result.errors.map((e, i) => `${i + 1}. ${e.message}`).join("\n")}`
: "Export completed with 0 errors.",
positiveButtonText: "Okay"
});
return true;
}
const FORMAT_TO_EXT = {
@@ -148,9 +130,7 @@ export async function exportNote(
unlockVault: Vault.unlockVault
})
)
.pipeThrough(
new ExportStream(report, (e) => showToast("error", e.message))
)
.pipeThrough(new ExportStream(report))
.pipeThrough(createZipStream())
.pipeTo(
await createWriteStream(

View File

@@ -75,10 +75,6 @@ export class SharedService<T extends object> extends EventTarget {
{ signal: this.#onClose.signal }
);
window.addEventListener("beforeunload", () => {
this.close();
});
this.proxy = this.#createProxy();
}

View File

@@ -303,7 +303,7 @@ function CalltoAction({ action, variant, sx, dismissAnnouncement }) {
break;
}
case "force-sync": {
await appStore.sync({ type: "full", force: true });
await appStore.sync(true, true);
break;
}
case "backup": {

View File

@@ -73,44 +73,15 @@ import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
const PDFPreview = React.lazy(() => import("../pdf-preview"));
async function saveContent(
noteId: string,
ignoreEdit: boolean,
content: string
) {
function saveContent(noteId: string, ignoreEdit: boolean, content: string) {
logger.debug("saving content", {
noteId,
ignoreEdit,
length: content.length
});
await Promise.race([
useEditorStore.getState().saveSessionContent(noteId, ignoreEdit, {
type: "tiptap",
data: content
}),
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Saving this note is taking too long. Copy your changes and restart the app to prevent data loss. If the problem persists, please report it to us at support@streetwriters.co."
)
),
30 * 1000
)
)
]).catch((e) => {
const { hide } = showToast(
"error",
(e as Error).message,
[
{
text: "Dismiss",
onClick: () => hide()
}
],
0
);
useEditorStore.getState().saveSessionContent(noteId, ignoreEdit, {
type: "tiptap",
data: content
});
}
const deferredSave = debounceWithId(saveContent, 100);
@@ -275,7 +246,7 @@ function EditorView({
if (!item.locked) return editor.updateContent(item.data);
const result = await db.vault
.decryptContent(item)
.decryptContent(item, item.noteId)
.catch(() => EV.publish(EVENTS.vaultLocked));
if (!result) return;
editor.updateContent(result.data);

View File

@@ -212,8 +212,7 @@ import {
mdiCalendarBlank,
mdiFormatListBulleted,
mdiLink,
mdiWindowClose,
mdiFileMusicOutline
mdiWindowClose
} from "@mdi/js";
import { useTheme } from "@emotion/react";
import { Theme } from "@notesnook/theme";
@@ -517,7 +516,6 @@ export const FilePDF = createIcon(
);
export const FileDocument = createIcon(mdiFileDocumentOutline);
export const FileVideo = createIcon(mdiFileVideoOutline);
export const FileAudio = createIcon(mdiFileMusicOutline);
export const FileGeneral = createIcon(mdiFileOutline);
export const FileWebClip = createIcon(mdiWeb);
export const Unlink = createIcon(mdiLinkOff);

View File

@@ -284,8 +284,7 @@ export default React.memo(Note, function (prevProps, nextProps) {
prevProps.reminder?.dateModified === nextProps.reminder?.dateModified &&
prevProps.attachments?.failed === nextProps.attachments?.failed &&
prevProps.attachments?.total === nextProps.attachments?.total &&
prevProps.locked === nextProps.locked &&
prevProps.color?.id === nextProps.color?.id
prevProps.locked === nextProps.locked
);
});

View File

@@ -37,7 +37,6 @@ import {
Close,
DoubleCheckmark,
Download,
FileAudio,
FileDocument,
FileGeneral,
FileImage,
@@ -112,8 +111,7 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
images: 0,
orphaned: 0,
uploads: 0,
videos: 0,
audio: 0
videos: 0
});
const [selected, setSelected] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOptions>({
@@ -377,14 +375,7 @@ function AttachmentRow(
);
}
type Route =
| "all"
| "images"
| "documents"
| "videos"
| "audio"
| "uploads"
| "orphaned";
type Route = "all" | "images" | "documents" | "videos" | "uploads" | "orphaned";
const routes: { id: Route; icon: Icon; title: string }[] = [
{
@@ -407,11 +398,6 @@ const routes: { id: Route; icon: Icon; title: string }[] = [
icon: FileVideo,
title: "Videos"
},
{
id: "audio",
icon: FileAudio,
title: "Audios"
},
{
id: "uploads",
icon: Uploading,
@@ -539,7 +525,6 @@ async function getCounts(): Promise<Record<Route, number>> {
documents: await db.attachments.documents.count(),
images: await db.attachments.images.count(),
videos: await db.attachments.videos.count(),
audio: await db.attachments.audios.count(),
uploads: await db.attachments.pending.count(),
orphaned: await db.attachments.orphaned.count()
};
@@ -556,7 +541,5 @@ function filterAttachments(route: Route) {
? db.attachments.documents
: route === "orphaned"
? db.attachments.orphaned
: route === "audio"
? db.attachments.audios
: db.attachments.pending;
}

View File

@@ -33,7 +33,6 @@ function ProgressDialog(props) {
try {
props.onDone(await props.action(setProgress));
} catch (e) {
console.error(e);
props.onDone(e);
}
})();

View File

@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { SettingsGroup } from "./types";
import { useStore as useAppStore } from "../../stores/app-store";
import { confirm } from "../../common/dialog-controller";
export const SyncSettings: SettingsGroup[] = [
{
@@ -79,56 +78,14 @@ export const SyncSettings: SettingsGroup[] = [
{
key: "force-sync",
title: "Having problems with sync?",
description: `Force push:
Use this if some changes from this device are not appearing on other devices.This will push everything on this device and overwrite whatever is one the server.
Force pull:
Use this if some changes are not appearing on this device from other devices. This will pull everything from the server and overwrite with whatever is one this device.
**These must only be used for troubleshooting. Using them regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co.**`,
description: "Try force sync to resolve issues with syncing.",
keywords: ["force sync", "sync troubleshoot"],
components: [
{
type: "button",
title: "Force push",
title: "Force sync",
variant: "error",
action: () =>
confirm({
title: "Are you sure?",
message:
"This must only be used for troubleshooting. Using them regularly for sync is **not recommended** and will lead to **unexpected data loss** and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co.",
checks: {
accept: { text: "I understand.", default: false }
},
positiveButtonText: "Proceed",
negativeButtonText: "Cancel"
}).then((result) => {
if (!result || !result.accept) return;
return useAppStore
.getState()
.sync({ force: true, type: "send" });
})
},
{
type: "button",
title: "Force pull",
variant: "error",
action: () =>
confirm({
title: "Are you sure?",
message:
"This must only be used for troubleshooting. Using them regularly for sync is **not recommended** and will lead to **unexpected data loss** and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co.",
checks: {
accept: { text: "I understand.", default: false }
},
positiveButtonText: "Proceed",
negativeButtonText: "Cancel"
}).then((result) => {
if (!result || !result.accept) return;
return useAppStore
.getState()
.sync({ force: true, type: "fetch" });
})
action: () => useAppStore.getState().sync(true, true)
}
]
}

View File

@@ -41,7 +41,6 @@ import {
} from "../utils/page-visibility";
import { NetworkCheck } from "../utils/network-check";
import { Color, Notebook, Tag } from "@notesnook/core";
import { SyncOptions } from "@notesnook/core/dist/api/sync";
type SyncState =
| "synced"
@@ -58,7 +57,7 @@ type SyncStatus = {
};
const networkCheck = new NetworkCheck();
let syncStatusTimeout = 0;
let pendingSync: SyncOptions | undefined = undefined;
let pendingSync: { full: boolean } | undefined = undefined;
class AppStore extends BaseStore<AppStore> {
// default state
@@ -70,7 +69,11 @@ class AppStore extends BaseStore<AppStore> {
isSyncEnabled = Config.get("syncEnabled", true);
isRealtimeSyncEnabled = Config.get("isRealtimeSyncEnabled", true);
syncStatus: SyncStatus = {
key: navigator.onLine ? "disabled" : "offline",
key: navigator.onLine
? Config.get("syncEnabled", true)
? "synced"
: "disabled"
: "offline",
progress: null,
type: undefined
};
@@ -112,9 +115,9 @@ class AppStore extends BaseStore<AppStore> {
db.eventManager.subscribe(
EVENTS.databaseSyncRequested,
async (full, force) => {
async (full, force, lastSynced) => {
if (!this.get().isAutoSyncEnabled) return;
await this.get().sync({ type: full ? "full" : "send", force });
await this.get().sync(full, force, lastSynced);
}
);
@@ -265,7 +268,7 @@ class AppStore extends BaseStore<AppStore> {
this.set((state) => (state.lastSynced = lastSynced));
};
sync = async (options: SyncOptions = { type: "full", force: false }) => {
sync = async (full = true, force = false, lastSynced?: number) => {
if (
this.isSyncing() ||
!this.get().isSyncEnabled ||
@@ -273,12 +276,14 @@ class AppStore extends BaseStore<AppStore> {
!(await networkCheck.waitForInternet())
) {
logger.info("Ignoring duplicate sync", {
options,
full,
force,
lastSynced,
syncing: this.isSyncing(),
syncDisabled: !this.get().isSyncEnabled,
offline: !navigator.onLine
});
if (this.isSyncing()) pendingSync = options;
if (this.isSyncing()) pendingSync = { full };
return;
}
@@ -287,7 +292,10 @@ class AppStore extends BaseStore<AppStore> {
this.updateSyncStatus("syncing");
try {
const result = await db.sync(options);
const result = await db.sync({
type: full ? "full" : "send",
force
});
if (!result) return this.updateSyncStatus("failed");
this.updateSyncStatus("completed", true);
@@ -296,9 +304,9 @@ class AppStore extends BaseStore<AppStore> {
if (pendingSync) {
logger.info("Running pending sync", pendingSync);
const syncOptions = { ...pendingSync };
const isFullSync = pendingSync.full;
pendingSync = undefined;
await this.get().sync(syncOptions);
await this.get().sync(isFullSync, false);
}
} catch (err) {
if (!(err instanceof Error)) {
@@ -308,6 +316,9 @@ class AppStore extends BaseStore<AppStore> {
logger.error(err);
if (err.cause === "MERGE_CONFLICT") {
// TODO: reopen conflicted note
// const sessionId = editorstore.get().session.id;
// if (sessionId) await editorstore.openSession(sessionId, true);
await this.refresh();
this.updateSyncStatus("conflicts");
} else {

View File

@@ -47,7 +47,6 @@ import { getFormattedHistorySessionDate } from "@notesnook/common";
import { isCipher } from "@notesnook/core/dist/database/crypto";
import { hashNavigate } from "../navigation";
import { AppEventManager, AppEvents } from "../common/app-events";
import Vault from "../common/vault";
export enum SaveState {
NotSaved = -1,
@@ -223,15 +222,8 @@ class EditorStore extends BaseStore<EditorStore> {
const clearIds: string[] = [];
for (const session of sessions) {
if (session.type === "new") continue;
const noteId = isDeleted(item)
? null
: item.type === "note"
? item.id
: item.type === "tiptap"
? item.noteId
: null;
if (noteId && session.id !== noteId && session.note.id !== noteId)
continue;
if (session.id !== item.id && session.note.id !== item.id) continue;
if (isDeleted(item) || isTrashItem(item)) clearIds.push(session.id);
// if a note is locked, reopen the session
else if (
@@ -240,26 +232,21 @@ class EditorStore extends BaseStore<EditorStore> {
item.type === "tiptap" &&
item.locked
) {
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
openSession(item.id, { force: true, silent: true });
}
// if locked note is unlocked, reopen the session
else if (
(session.type === "locked" ||
(session.type === "default" && session.locked)) &&
item.type === "tiptap" &&
!item.locked
session.type === "locked" ||
(session.type === "default" &&
session.locked &&
item.type === "tiptap" &&
!item.locked)
) {
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
openSession(item.id, { force: true, silent: true });
}
// if a deleted note is restored, reopen the session
else if (session.type === "deleted" && item.type === "note") {
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
openSession(item.id, { force: true, silent: true });
}
// if a readonly note is made editable, reopen the session
else if (
@@ -267,9 +254,7 @@ class EditorStore extends BaseStore<EditorStore> {
item.type === "note" &&
!item.readonly
)
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
openSession(item.id, { force: true, silent: true });
// update the note in all sessions
else if (item.type === "note") {
updateSession(
@@ -343,15 +328,12 @@ class EditorStore extends BaseStore<EditorStore> {
const contentId =
session.type !== "new" && session.note.contentId;
if (!contentId || !event.ids.includes(contentId)) continue;
if (
// if note is locked
(session.type === "default" &&
!session.locked &&
event.item.locked) ||
// if note is unlocked
((session.type === "locked" ||
(session.type === "default" && session.locked)) &&
!event.item.locked)
// when a note is locked or unlocked
session.type !== "locked" &&
!!event.item.locked &&
(session.type !== "default" || !session.locked)
) {
openSession(session.id, { force: true, silent: true });
}
@@ -618,7 +600,7 @@ class EditorStore extends BaseStore<EditorStore> {
: undefined;
if (content?.locked) {
await Vault.lockNote(noteId);
await db.vault.add(noteId);
return this.openSession(note, { ...options, force: true });
}
@@ -981,10 +963,3 @@ function getSessionId(session: DefaultEditorSession | NewEditorSession) {
if (sessionId + SESSION_DURATION < Date.now()) return `${Date.now()}`;
return `${sessionId}`;
}
async function waitForSync() {
if (!appStore.get().isSyncing()) return true;
return new Promise((resolve) => {
db.eventManager.subscribe(EVENTS.syncCompleted, resolve, true);
});
}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ExportableItem } from "@notesnook/common";
import { db } from "../../common/db";
import { showToast } from "../toast";
import { ZipFile } from "./zip-stream";
import { lazify } from "../lazify";
@@ -26,15 +27,12 @@ export class ExportStream extends TransformStream<
ExportableItem | Error,
ZipFile
> {
progress = 0;
constructor(
report: (progress: { text: string; current?: number }) => void,
handleError: (error: Error) => void
) {
constructor(report: (progress: { text: string; current?: number }) => void) {
let progress = 0;
super({
transform: async (item, controller) => {
async transform(item, controller) {
if (item instanceof Error) {
handleError(item);
showToast("error", item.message);
return;
}
if (item.type === "attachment") {
@@ -58,13 +56,13 @@ export class ExportStream extends TransformStream<
if (!stream) return;
controller.enqueue({ ...item, data: stream });
report({
current: this.progress++,
current: progress++,
text: `Saving attachment: ${item.path}`
});
} else {
controller.enqueue(item);
report({
current: this.progress++,
current: progress++,
text: `Exporting note: ${item.path}`
});
}

View File

@@ -1,4 +0,0 @@
- Added push/pull changes to troubleshoot sync issues in settings
- Bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -104,37 +104,31 @@ export async function* exportNotes(
continue;
}
try {
const content = await exportContent(note, {
unlockVault: options.unlockVault,
format,
attachmentsRoot,
pendingAttachments,
resolveInternalLink: (link) => {
const internalLink = parseInternalLink(link);
if (!internalLink) return link;
const paths = notePathMap.get(internalLink.id);
if (!paths) return link;
// if the internal link is linking within the same note
if (paths === notePaths) return `{{NOTE_PATH:}}`;
return `{{NOTE_PATH:${paths[0]}}}`;
}
});
if (!content) continue;
for (const path of notePaths) {
yield <ExportableNote>{
type: "note",
path,
data: resolvePaths(content, path),
mtime: new Date(note.dateEdited),
ctime: new Date(note.dateCreated)
};
const content = await exportContent(note, {
unlockVault: options.unlockVault,
format,
attachmentsRoot,
pendingAttachments,
resolveInternalLink: (link) => {
const internalLink = parseInternalLink(link);
if (!internalLink) return link;
const paths = notePathMap.get(internalLink.id);
if (!paths) return link;
// if the internal link is linking within the same note
if (paths === notePaths) return `{{NOTE_PATH:}}`;
return `{{NOTE_PATH:${paths[0]}}}`;
}
} catch (e) {
yield new Error(
`Failed to export note "${note.title}": ${(e as Error).message}`
);
});
if (!content) continue;
for (const path of notePaths) {
yield <ExportableNote>{
type: "note",
path,
data: resolvePaths(content, path),
mtime: new Date(note.dateEdited),
ctime: new Date(note.dateCreated)
};
}
}
@@ -231,15 +225,10 @@ export async function exportContent(
}
const contentItem = rawContent?.locked
? await database.vault.decryptContent(rawContent)
: // .catch((e) => {
// console.error(e, note);
// return <NoteContent<false>>{
// type: "tiptap",
// data: `This note could not be decrypted: ${e}`
// };
// })
rawContent;
? await database.vault
.decryptContent(rawContent, note.id)
.catch(() => undefined)
: rawContent;
const { data, type } =
format === "pdf"

View File

@@ -109,17 +109,16 @@ test(
});
const id = await deviceA.notes.add({ title: "hello" });
for (let i = 0; i < 5; ++i) {
if (i > 0) await deviceA.notes.add({ id, title: `edit ${i - 1}` });
for (let i = 0; i < 10; ++i) {
await Promise.all([
deviceA.sync({ type: "send" }),
new Promise((resolve) => setTimeout(resolve, 40)).then(() =>
new Promise((resolve) => setTimeout(resolve), 100).then(() =>
deviceA.notes.add({ id, title: `edit ${i}` })
)
]);
expect((await deviceA.notes.note(id))?.title).toBe(`edit ${i}`);
expect((await deviceA.notes.note(id))?.synced).toBe(true);
expect((await deviceA.notes.note(id))?.synced).toBe(false);
await deviceA.sync({ type: "send" });
await deviceB.sync({ type: "fetch" });
expect((await deviceB.notes.note(id))?.title).toBe(`edit ${i}`);
}
@@ -140,17 +139,17 @@ test(
await cleanup(deviceA, deviceB);
});
const id = await deviceA.notes.add({ title: "hello" });
for (let i = 0; i < 5; ++i) {
if (i > 0) await deviceA.notes.add({ id, title: `edit ${i - 1}` });
for (let i = 0; i < 10; ++i) {
await Promise.all([
deviceA.sync({ type: "send" }),
new Promise((resolve) => setTimeout(resolve, 40)).then(() =>
new Promise((resolve) => setTimeout(resolve), 100).then(() =>
deviceA.notes.add({ title: `note ${i}` })
)
]);
expect(await deviceB.notes.all.count()).toBe(i);
await deviceA.sync({ type: "send" });
await deviceB.sync({ type: "fetch" });
expect(await deviceB.notes.all.count()).toBe(i + 2);
expect(await deviceB.notes.all.count()).toBe(i + 1);
}
},
TEST_TIMEOUT * 10

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { expect, test } from "vitest";
import { databaseTest, noteTest } from "./utils";
import { TEST_NOTE, databaseTest, noteTest } from "./utils";
test("updating deleted content should not throw", () =>
databaseTest().then(async (db) => {

View File

@@ -144,7 +144,6 @@ test("save an edited locked note", () =>
expect(content.data.cipher).toBeTypeOf("string");
expect(() => JSON.parse(content.data.cipher)).toThrow();
expect(note.dateEdited).toBeLessThan((await db.notes.note(id)).dateEdited);
expect(note.dateEdited).toBeLessThan(content.dateEdited);
}));
test("change vault password", () =>

View File

@@ -95,7 +95,6 @@ class Database {
isInitialized = false;
eventManager = new EventManager();
sseMutex = new Mutex();
_fs?: FileStorage;
storage: StorageAccessor = () => {
if (!this.options?.storage)
@@ -110,10 +109,7 @@ class Database {
throw new Error(
"Database not initialized. Did you forget to call db.setup()?"
);
return (
this._fs ||
(this._fs = new FileStorage(this.options.fs, this.tokenManager))
);
return new FileStorage(this.options.fs, this.tokenManager);
};
crypto: CryptoAccessor = () => {

View File

@@ -25,7 +25,6 @@ import { AnyColumnWithTable, Kysely, sql } from "kysely";
import { FilteredSelector } from "../database/sql-collection";
import { VirtualizedGrouping } from "../utils/virtualized-grouping";
import { logger } from "../logger";
import { rebuildSearchIndex } from "../database/fts";
type SearchResults<T> = {
sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>;
@@ -246,9 +245,4 @@ export default class Lookup {
if (!ids.length) return [];
return selector.items(ids);
}
async rebuild() {
const db = this.db.sql() as unknown as Kysely<RawDatabaseSchema>;
await rebuildSearchIndex(db);
}
}

View File

@@ -33,15 +33,6 @@ class Collector {
logger = logger.scope("SyncCollector");
constructor(private readonly db: Database) {}
async hasUnsyncedChanges() {
for (const itemType of SYNC_ITEM_TYPES) {
const collectionKey = SYNC_COLLECTIONS_MAP[itemType];
const collection = this.db[collectionKey].collection;
if ((await collection.unsyncedCount()) > 0) return true;
}
return false;
}
async *collect(
chunkSize: number,
isForceSync = false

View File

@@ -160,7 +160,7 @@ class Sync {
)
this.logger.info("New data sent");
await this.stop(options);
await this.stop();
if (!(await checkSyncStatus(SYNC_CHECK_IDS.autoSync))) {
await this.connection.stop();
@@ -265,15 +265,7 @@ class Sync {
return true;
}
async stop(options: SyncOptions) {
if (
(options.type === "send" || options.type === "full") &&
(await this.collector.hasUnsyncedChanges())
) {
this.logger.info("Changes made during last sync. Syncing again...");
await this.start({ type: "send" });
return;
}
async stop() {
// refresh monographs
await this.db.monographs.refresh().catch(this.logger.error);
// update trash cache

View File

@@ -82,7 +82,7 @@ class TokenManager {
async getAccessToken(forceRenew = false) {
return await getSafeToken(async () => {
const token = await this.getToken(true, forceRenew);
if (!token || token.scope.includes("auth:grant_types")) return;
if (!token) return;
return token.access_token;
}, "Error getting access token:");
}

View File

@@ -99,9 +99,8 @@ class UserManager {
async authenticateMultiFactorCode(code: string, method: string) {
if (!code || !method) throw new Error("code & method are required.");
const token = await this.tokenManager.getToken();
if (!token || token.scope !== "auth:grant_types:mfa")
throw new Error("No token found.");
const token = await this.tokenManager.getAccessToken();
if (!token) throw new Error("Unauthorized.");
await this.tokenManager.saveToken(
await http.post(
@@ -112,7 +111,7 @@ class UserManager {
"mfa:code": code,
"mfa:method": method
},
token.access_token
token
)
);
return true;
@@ -127,8 +126,7 @@ class UserManager {
if (!email || !password) throw new Error("email & password are required.");
const token = await this.tokenManager.getToken();
if (!token || token.scope !== "auth:grant_types:mfa_password")
throw new Error("No token found.");
if (!token) throw new Error("No token found.");
email = email.toLowerCase();
if (!hashedPassword) {

View File

@@ -255,7 +255,6 @@ export default class Vault {
noteId,
sessionId,
data: encryptedContent,
dateEdited: Date.now(),
type: content.type
});
}

View File

@@ -22,12 +22,10 @@ import { CURRENT_DATABASE_VERSION } from "../common.js";
import Migrator from "./migrator.js";
import Database from "../api/index.js";
import {
Attachment,
Item,
MaybeDeletedItem,
Note,
Notebook,
Relation,
ValueOf,
isDeleted
} from "../types.js";
@@ -38,7 +36,6 @@ import { DatabaseCollection } from "./index.js";
import { DefaultColors } from "../collections/colors.js";
import { toChunks } from "../utils/array.js";
import { logger } from "../logger.js";
import { clone } from "../utils/clone.js";
type BackupDataItem = MaybeDeletedItem<Item> | string[];
type BackupPlatform = "web" | "mobile" | "node";
@@ -95,44 +92,10 @@ function isEncryptedBackup(
return "encrypted" in backup ? backup.encrypted : isCipher(backup.data);
}
/**
* Due to a bug in v3.0, legacy backups were created with version set to 6.1
* while their actual data was at version 5.9. This caused various issues when
* restoring such a backup.
* This function tries to work around that bug by detecting the version based on
* the actual data.
*/
function isLegacyBackup(data: BackupDataItem[]) {
const note = data.find(
(c): c is Note => !isDeleted(c) && !Array.isArray(c) && c.type === "note"
);
if (note)
return (
"color" in note ||
"notebooks" in note ||
"tags" in note ||
"locked" in note
);
const notebook = data.find(
(c): c is Notebook =>
!isDeleted(c) && !Array.isArray(c) && c.type === "notebook"
);
if (notebook) return "topics" in notebook;
const attachment = data.find(
(c): c is Attachment =>
!isDeleted(c) && !Array.isArray(c) && c.type === "attachment"
);
if (attachment) return "noteIds" in attachment;
const relation = data.find(
(c): c is Relation =>
!isDeleted(c) && !Array.isArray(c) && c.type === "relation"
);
if (relation) return "from" in relation || "to" in relation;
return false;
function isLegacyBackupFile(
backup: LegacyBackupFile | BackupFile
): backup is LegacyBackupFile {
return backup.version <= 5.8;
}
const MAX_CHUNK_SIZE = 10 * 1024 * 1024;
@@ -258,7 +221,7 @@ export default class Backup {
yield {
path: `${chunkIndex++}-${encrypt ? "encrypted" : "plain"}-${hash}`,
data: `{
"version": 5.9,
"version": ${CURRENT_DATABASE_VERSION},
"type": "${type}",
"date": ${Date.now()},
"data": ${itemsJSON},
@@ -434,16 +397,13 @@ export default class Backup {
if (!data) throw new Error("No data found.");
const normalizedData: BackupDataItem[] = Array.isArray(data)
? (data as BackupDataItem[])
: typeof data === "object"
? Object.values(data)
: [];
await this.migrateData(
normalizedData,
backup.version === 6.1 && isLegacyBackup(normalizedData)
? 5.9
: backup.version
Array.isArray(data)
? (data as BackupDataItem[])
: typeof data === "object"
? Object.values(data)
: [],
backup.version
);
}

View File

@@ -30,22 +30,16 @@ import { logger } from "../logger";
export type FileStorageAccessor = () => FileStorage;
export type DownloadableFile = {
filename: string;
// metadata: AttachmentMetadata;
chunkSize: number;
};
export type QueueItem = DownloadableFile & {
cancel?: (reason?: string) => Promise<void>;
operation?: Promise<boolean>;
};
export class FileStorage {
id = Date.now();
downloads = new Map<string, QueueItem>();
uploads = new Map<string, QueueItem>();
groups = {
downloads: new Map<string, Set<string>>(),
uploads: new Map<string, Set<string>>()
};
downloads = new Map<string, QueueItem[]>();
uploads = new Map<string, QueueItem[]>();
constructor(
private readonly fs: IFileStorage,
private readonly tokenManager: TokenManager
@@ -56,26 +50,12 @@ export class FileStorage {
groupId: string,
eventData?: Record<string, unknown>
) {
let current = 0;
const token = await this.tokenManager.getAccessToken();
const total = files.length;
const group = this.groups.downloads.get(groupId) || new Set();
files.forEach((f) => group.add(f.filename));
this.groups.downloads.set(groupId, group);
let current = 0;
this.downloads.set(groupId, files);
for (const file of files as QueueItem[]) {
if (!group.has(file.filename)) continue;
const download = this.downloads.get(file.filename);
if (download && download.operation) {
logger.debug("[queueDownloads] duplicate download", {
filename: file.filename,
groupId
});
await download.operation;
continue;
}
const { filename, chunkSize } = file;
if (await this.exists(filename)) {
current++;
@@ -88,13 +68,6 @@ export class FileStorage {
continue;
}
EV.publish(EVENTS.fileDownload, {
total,
current,
groupId,
filename
});
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.downloadFile(filename, {
url,
@@ -102,15 +75,15 @@ export class FileStorage {
headers: { Authorization: `Bearer ${token}` }
});
file.cancel = cancel;
file.operation = execute()
.catch(() => false)
.finally(() => {
this.downloads.delete(filename);
group.delete(filename);
});
this.downloads.set(filename, file);
const result = await file.operation;
EV.publish(EVENTS.fileDownload, {
total,
current,
groupId,
filename
});
const result = await execute().catch(() => false);
if (eventData)
EV.publish(EVENTS.fileDownloaded, {
success: result,
@@ -121,31 +94,17 @@ export class FileStorage {
eventData
});
}
this.downloads.delete(groupId);
}
async queueUploads(files: DownloadableFile[], groupId: string) {
let current = 0;
const token = await this.tokenManager.getAccessToken();
const total = files.length;
const group = this.groups.uploads.get(groupId) || new Set();
files.forEach((f) => group.add(f.filename));
this.groups.uploads.set(groupId, group);
let current = 0;
this.uploads.set(groupId, files);
for (const file of files as QueueItem[]) {
if (!group.has(file.filename)) continue;
const upload = this.uploads.get(file.filename);
if (upload && upload.operation) {
logger.debug("[queueUploads] duplicate upload", {
filename: file.filename,
groupId
});
await file.operation;
continue;
}
const { filename, chunkSize } = file;
let error = null;
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const { execute, cancel } = this.fs.uploadFile(filename, {
chunkSize,
@@ -153,16 +112,6 @@ export class FileStorage {
headers: { Authorization: `Bearer ${token}` }
});
file.cancel = cancel;
file.operation = execute()
.catch((e) => {
logger.error(e, "failed to upload attachment", { hash: filename });
error = e;
return false;
})
.finally(() => {
this.uploads.delete(filename);
group.delete(filename);
});
EV.publish(EVENTS.fileUpload, {
total,
@@ -171,8 +120,13 @@ export class FileStorage {
filename
});
this.uploads.set(filename, file);
const result = await file.operation;
let error = null;
const result = await execute().catch((e) => {
logger.error(e, "failed to upload attachment", { hash: filename });
error = e;
return false;
});
EV.publish(EVENTS.fileUploaded, {
error,
success: result,
@@ -182,67 +136,44 @@ export class FileStorage {
filename
});
}
this.uploads.delete(groupId);
}
async downloadFile(groupId: string, filename: string, chunkSize: number) {
if (await this.exists(filename)) return true;
const download = this.downloads.get(filename);
if (download && download.operation) {
logger.debug("[downloadFile] duplicate download", { filename, groupId });
return await download.operation;
}
logger.debug("[downloadFile] downloading", { filename, groupId });
const url = `${hosts.API_HOST}/s3?name=${filename}`;
const file: QueueItem = { filename, chunkSize };
const token = await this.tokenManager.getAccessToken();
const group = this.groups.downloads.get(groupId) || new Set();
const { execute, cancel } = this.fs.downloadFile(filename, {
url,
chunkSize,
headers: { Authorization: `Bearer ${token}` }
});
file.cancel = cancel;
file.operation = execute().finally(() => {
this.downloads.delete(filename);
group.delete(filename);
});
this.downloads.set(filename, file);
this.groups.downloads.set(groupId, group.add(filename));
return await file.operation;
this.downloads.set(groupId, [{ cancel, filename, chunkSize }]);
const result = await execute();
this.downloads.delete(groupId);
return result;
}
async cancel(groupId: string) {
const queues = [
{
type: "download",
ids: this.groups.downloads.get(groupId),
files: this.downloads
},
{
type: "upload",
ids: this.groups.uploads.get(groupId),
files: this.uploads
}
].filter((a) => !!a.ids);
{ type: "download", files: this.downloads.get(groupId) },
{ type: "upload", files: this.uploads.get(groupId) }
].filter((a) => !!a.files);
for (const queue of queues) {
if (!queue.ids) continue;
for (const filename of queue.ids) {
const file = queue.files.get(filename);
if (file?.cancel) await file.cancel("Operation canceled.");
queue.ids.delete(filename);
if (!queue.files) continue;
for (let i = 0; i < queue.files.length; ++i) {
const file = queue.files[i];
if (file.cancel) await file.cancel("Operation canceled.");
queue.files.splice(i, 1);
}
if (queue.type === "download") {
this.groups.downloads.delete(groupId);
this.downloads.delete(groupId);
EV.publish(EVENTS.downloadCanceled, { groupId, canceled: true });
} else if (queue.type === "upload") {
this.groups.uploads.delete(groupId);
this.uploads.delete(groupId);
EV.publish(EVENTS.uploadCanceled, { groupId, canceled: true });
}
}

View File

@@ -1,74 +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 { Kysely, sql } from "kysely";
import { RawDatabaseSchema } from ".";
export async function rebuildSearchIndex(db: Kysely<RawDatabaseSchema>) {
await db.transaction().execute(async (tx) => {
for (const query of [
sql`INSERT INTO content_fts(content_fts) VALUES('delete-all')`,
sql`INSERT INTO notes_fts(notes_fts) VALUES('delete-all')`
]) {
await query.execute(tx);
}
await tx
.insertInto("content_fts")
.columns(["rowid", "id", "data", "noteId"])
.expression((eb) =>
eb
.selectFrom("content")
.where((eb) =>
eb.and([
eb("noteId", "is not", null),
eb("data", "is not", null),
eb("deleted", "is not", true)
])
)
.select([
"rowid",
"id",
sql`IIF(locked == 1, '', data)`.as("data"),
"noteId"
])
)
.execute();
await tx
.insertInto("notes_fts")
.columns(["rowid", "id", "title"])
.expression((eb) =>
eb
.selectFrom("notes")
.where((eb) =>
eb.and([eb("title", "is not", null), eb("deleted", "is not", true)])
)
.select(["rowid", "id", "title"])
)
.execute();
for (const query of [
sql`INSERT INTO content_fts(content_fts) VALUES('optimize')`,
sql`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`
]) {
await query.execute(tx);
}
});
}

View File

@@ -167,7 +167,6 @@ export interface DatabaseCollection<T, IsAsync extends boolean> {
delete(ids: string[]): Promise<void>;
exists(id: string): AsyncOrSyncResult<IsAsync, boolean>;
count(): AsyncOrSyncResult<IsAsync, number>;
unsyncedCount(): Promise<number>;
get(id: string): AsyncOrSyncResult<IsAsync, T | undefined>;
put(items: (T | undefined)[]): Promise<SQLiteItem<T>[]>;
update(ids: string[], partial: Partial<T>): Promise<void>;

View File

@@ -24,7 +24,6 @@ import {
MigrationProvider,
sql
} from "kysely";
import { rebuildSearchIndex } from "./fts";
const COLLATE_NOCASE: ColumnBuilderCallback = (col) =>
col.modifyEnd(sql`collate nocase`);
@@ -284,11 +283,6 @@ export class NNMigrationProvider implements MigrationProvider {
.execute();
},
async down(db) {}
},
"2": {
async up(db) {
await rebuildSearchIndex(db);
}
}
};
}

View File

@@ -41,7 +41,6 @@ import {
} from "../types";
import { IndexedCollection } from "./indexed-collection";
import { SQLCollection } from "./sql-collection";
import { logger } from "../logger";
export type RawItem = MaybeDeletedItem<Item>;
type MigratableCollection = {
@@ -137,16 +136,10 @@ class Migrator {
for (let i = 0; i < items.length; ++i) {
const item = items[i];
// can be true due to corrupted data.
if (Array.isArray(item)) {
logger.debug("Skipping item during migration to SQLite", {
table,
version,
item
});
continue;
}
if (Array.isArray(item)) continue;
if (!item) continue;
const itemId = item.id;
let migrated = await migrateItem(
item,
version,
@@ -168,7 +161,14 @@ class Migrator {
);
}
if (migrated !== "skip") toAdd.push(item);
if (migrated === true) {
toAdd.push(item);
// if id changed after migration, we need to delete the old one.
if (item.id !== itemId) {
// await collection.deleteItem(itemId);
}
}
}
if (toAdd.length > 0) {
@@ -217,7 +217,7 @@ class Migrator {
);
}
if (!migrated || migrated === "skip") continue;
if (!migrated) continue;
toAdd.push(item);

View File

@@ -21,7 +21,7 @@ import { MaybeDeletedItem, isDeleted } from "../types";
import EventManager from "../utils/event-manager";
import { DatabaseAccessor, DatabaseCollection, DatabaseSchema } from ".";
import { SQLCollection } from "./sql-collection";
import { Kysely } from "kysely";
import { Kysely, Transaction } from "kysely";
import { Sanitizer } from "./sanitizer";
export class SQLCachedCollection<
@@ -171,10 +171,6 @@ export class SQLCachedCollection<
}
}
async unsyncedCount() {
return this.collection.unsyncedCount();
}
// has(id: string) {
// return this.cache.has(id);
// }

View File

@@ -278,21 +278,6 @@ export class SQLCollection<
return items;
}
async unsyncedCount() {
const { count } =
(await this.db()
.selectFrom<keyof DatabaseSchema>(this.type)
.select((a) => a.fn.count<number>("id").as("count"))
.where(isFalse("synced"))
.$if(this.type === "attachments", (eb) =>
eb.where((eb) =>
eb.or([eb("dateUploaded", ">", 0), eb("deleted", "==", true)])
)
)
.executeTakeFirst()) || {};
return count || 0;
}
async *unsynced(
chunkSize: number,
forceSync?: boolean
@@ -552,11 +537,9 @@ export class FilteredSelector<T extends Item> {
async *[Symbol.asyncIterator]() {
let lastRow: any | null = null;
const fields = this._fields.slice();
if (fields.length > 0) {
if (!fields.find((f) => f.includes(".dateCreated")))
fields.push("dateCreated");
if (!fields.find((f) => f.includes(".id"))) fields.push("id");
}
if (!fields.find((f) => f.includes(".dateCreated")))
fields.push("dateCreated");
if (!fields.find((f) => f.includes(".id"))) fields.push("id");
while (true) {
const rows = await this.filter

View File

@@ -31,16 +31,16 @@ export async function createTriggers(db: Kysely<RawDatabaseSchema>) {
.addEvent("insert")
.when((eb) =>
eb.and([
eb("new.noteId", "is not", null),
eb("new.data", "is not", null),
eb("new.deleted", "is not", true)
eb("new.deleted", "is not", true),
eb("new.locked", "is not", true),
eb("new.data", "is not", null)
])
)
.addQuery((c) =>
c.insertInto("content_fts").values({
rowid: sql`new.rowid`,
id: sql`new.id`,
data: sql`IIF(new.locked == 1, '', new.data)`,
data: sql`new.data`,
noteId: sql`new.noteId`
})
)
@@ -53,13 +53,6 @@ export async function createTriggers(db: Kysely<RawDatabaseSchema>) {
.onTable("content", "main")
.after()
.addEvent("delete")
.when((eb) =>
eb.and([
eb("old.noteId", "is not", null),
eb("old.data", "is not", null),
eb("old.deleted", "is not", true)
])
)
.addQuery((c) =>
c.insertInto("content_fts").values({
content_fts: sql.lit("delete"),
@@ -80,9 +73,9 @@ export async function createTriggers(db: Kysely<RawDatabaseSchema>) {
.addEvent("update")
.when((eb) =>
eb.and([
eb("old.deleted", "is not", true),
eb("old.noteId", "is not", null),
eb("old.data", "is not", null),
eb("old.deleted", "is not", true)
eb("old.data", "is not", null)
])
)
.addQuery((c) =>
@@ -114,8 +107,7 @@ export async function createTriggers(db: Kysely<RawDatabaseSchema>) {
.addEvent("insert")
.when((eb) =>
eb.and([
eb("new.title", "is not", null),
eb("new.deleted", "is not", true)
eb.or([eb("new.deleted", "is", null), eb("new.deleted", "==", false)])
])
)
.addQuery((c) =>
@@ -134,12 +126,6 @@ export async function createTriggers(db: Kysely<RawDatabaseSchema>) {
.onTable("notes", "main")
.after()
.addEvent("delete")
.when((eb) =>
eb.and([
eb("old.title", "is not", null),
eb("old.deleted", "is not", true)
])
)
.addQuery((c) =>
c.insertInto("notes_fts").values({
notes_fts: sql.lit("delete"),

View File

@@ -22,4 +22,3 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
sourcemaps

View File

@@ -17,7 +17,6 @@
"@szhsin/react-menu": "^4.1.0",
"buffer": "^6.0.3",
"framer-motion": "^10.16.8",
"localforage": "^1.10.0",
"mdi-react": "9.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -4376,7 +4375,7 @@
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==",
"devOptional": true
"dev": true
},
"node_modules/@types/q": {
"version": "1.5.8",
@@ -4400,7 +4399,7 @@
"version": "18.2.39",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
"devOptional": true,
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -4435,7 +4434,7 @@
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
"devOptional": true
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.6",
@@ -9727,16 +9726,11 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"devOptional": true,
"dev": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -12805,14 +12799,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -12850,14 +12836,6 @@
"node": ">=8.9.0"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -17822,20 +17800,6 @@
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",

View File

@@ -11,7 +11,6 @@
"@szhsin/react-menu": "^4.1.0",
"buffer": "^6.0.3",
"framer-motion": "^10.16.8",
"localforage": "^1.10.0",
"mdi-react": "9.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@@ -20,6 +20,10 @@
/* color: var(--nn_primary_paragraph) ## TODO: use fixed color */
}
.main-editor > .ProseMirror:first-child {
margin-top: -5px !important;
}
::selection {
color: white;
background-color: var(--nn_primary_accent);

View File

@@ -31,6 +31,7 @@ import { TabContext, useTabStore } from "./hooks/useTabStore";
import { EmotionEditorTheme } from "./theme-factory";
import { getTheme } from "./utils";
import { ReadonlyEditorProvider } from "./components/readonly-editor";
import { error } from "console";
const currentTheme = getTheme();
if (currentTheme) {

View File

@@ -40,19 +40,17 @@ import {
import { useEditorController } from "../hooks/useEditorController";
import { useSettings } from "../hooks/useSettings";
import {
NoteState,
TabItem,
TabStore,
useTabContext,
useTabStore
} from "../hooks/useTabStore";
import { EmotionEditorToolbarTheme } from "../theme-factory";
import { EventTypes, postAsyncWithTimeout, randId, Settings } from "../utils";
import { EventTypes, randId, Settings } from "../utils";
import Header from "./header";
import StatusBar from "./statusbar";
import Tags from "./tags";
import Title from "./title";
import { pendingSaveRequests } from "../utils/pending-saves";
globalThis.toBlobURL = toBlobURL as typeof globalThis.toBlobURL;
@@ -79,37 +77,6 @@ const Tiptap = ({
const isFocusedRef = useRef<boolean>(false);
tabRef.current = tab;
function restoreNoteSelection(state?: NoteState) {
try {
if (!tabRef.current.noteId) return;
const noteState =
state || useTabStore.getState().noteState[tabRef.current.noteId];
if (noteState && (noteState.to || noteState.from)) {
const size = editors[tabRef.current.id]?.state.doc.content.size || 0;
if (
noteState.to > 0 &&
noteState.to <= size &&
noteState.from > 0 &&
noteState.from <= size
) {
editors[tabRef.current.id]?.chain().setTextSelection({
to: noteState.to,
from: noteState.from
});
}
}
containerRef.current?.scrollTo({
left: 0,
top: noteState?.top || 0,
behavior: "auto"
});
} catch (e) {
logger("error", (e as Error).message, (e as Error).stack);
}
}
usePermissionHandler({
claims: {
premium: settings.premium
@@ -146,8 +113,20 @@ const Tiptap = ({
) as Promise<string | undefined>;
},
createInternalLink(attributes) {
return postAsyncWithTimeout(EventTypes.createInternalLink, {
attributes
logger("info", "create internal link");
return new Promise((resolve) => {
const id = randId("createInternalLink");
globalThis.pendingResolvers[id] = (value) => {
delete globalThis.pendingResolvers[id];
resolve(value);
logger("info", "resolved create link request:", id);
};
post("editor-events:create-internal-link", {
attributes: attributes,
resolverId: id
});
});
},
element: getContentDiv(),
@@ -213,7 +192,23 @@ const Tiptap = ({
},
onCreate() {
setTimeout(() => {
restoreNoteSelection();
const noteState = tabRef.current.noteId
? useTabStore.getState().noteState[tabRef.current.noteId]
: undefined;
const top = noteState?.top;
logger("info", tabRef.current.noteId, noteState?.top);
if (noteState?.to || noteState?.from) {
editors[tabRef.current.id]?.chain().setTextSelection({
to: noteState.to,
from: noteState.from
});
}
containerRef.current?.scrollTo({
left: 0,
top: top || 0,
behavior: "auto"
});
}, 32);
},
downloadOptions: {
@@ -239,6 +234,7 @@ const Tiptap = ({
const _editor = useTiptap(tiptapOptions, [tiptapOptions]);
const update = useCallback(() => {
logger("info", "LOADING NOTE...");
editors[tabRef.current.id]?.commands.setTextSelection(0);
setTick((tick) => tick + 1);
globalThis.editorControllers[tabRef.current.id]?.setTitlePlaceholder(
@@ -255,9 +251,6 @@ const Tiptap = ({
return !containerRef.current
? []
: getTableOfContents(containerRef.current);
},
scrollTo: (top) => {
containerRef.current?.scrollTo({ top, behavior: "auto" });
}
});
const controllerRef = useRef(controller);
@@ -275,17 +268,6 @@ const Tiptap = ({
if (!didCallOnLoad) {
didCallOnLoad = true;
post("editor-events:load");
pendingSaveRequests
.getPendingContentIds()
.then(async (result) => {
if (result && result.length) {
dbLogger("log", "Pending save requests found... restoring");
await pendingSaveRequests.postPendingRequests();
}
})
.catch(() => {
logger("info", "Error restoring pending contents...");
});
}
const updateScrollPosition = (state: TabStore) => {
@@ -295,26 +277,18 @@ const Tiptap = ({
const noteState = tabRef.current.noteId
? state.noteState[tabRef.current.noteId]
: undefined;
post(
EventTypes.tabFocused,
!!globalThis.editorControllers[tabRef.current.id]?.content.current &&
!editorControllers[tabRef.current.id]?.loading,
tabRef.current.id,
state.getCurrentNoteId()
);
editorControllers[tabRef.current.id]?.updateTab();
if (noteState) {
if (
containerRef.current &&
containerRef.current?.scrollHeight < noteState.top
) {
console.log("Container too small to scroll.");
return;
containerRef.current?.scrollTo({
left: 0,
top: noteState.top,
behavior: "auto"
});
if (noteState.to || noteState.from) {
editors[tabRef.current.id]?.chain().setTextSelection({
to: noteState.to,
from: noteState.from
});
}
restoreNoteSelection(noteState);
} else {
containerRef.current?.scrollTo({
left: 0,
@@ -329,13 +303,20 @@ const Tiptap = ({
) {
editorControllers[tabRef.current.id]?.setLoading(true);
}
post(
EventTypes.tabFocused,
!!globalThis.editorControllers[tabRef.current.id]?.content.current &&
!editorControllers[tabRef.current.id]?.loading,
tabRef.current.id,
state.getCurrentNoteId()
);
editorControllers[tabRef.current.id]?.updateTab();
} else {
isFocusedRef.current = false;
}
};
updateScrollPosition(useTabStore.getState());
const unsub = useTabStore.subscribe((state, prevState) => {
if (state.currentTab !== tabRef.current.id) {
isFocusedRef.current = false;
@@ -433,105 +414,6 @@ const Tiptap = ({
noHeader={settings.noHeader || false}
/>
<div
id="editor-saving-failed-overlay"
style={{
display: "none",
position: "absolute",
zIndex: 999,
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
rowGap: 10
}}
>
<p
style={{
color: colors.primary.paragraph,
fontSize: 18,
fontWeight: "600",
textAlign: "center",
padding: "0px 20px",
marginBottom: 0,
userSelect: "none"
}}
>
Your changes could not be saved.
</p>
<p
style={{
color: colors.primary.paragraph,
marginTop: 0,
marginBottom: 0,
userSelect: "none",
textAlign: "center",
maxWidth: "90%",
fontSize: "0.9rem"
}}
>
It seems that your changes could not be saved. What to do next:
</p>
<p
style={{
width: "90%",
fontSize: "0.9rem"
}}
>
<ol>
<li>
<p>
Tap on "Dismiss" and copy the contents of your note so they
are not lost.
</p>
</li>
<li>
<p>Restart the app.</p>
</li>
</ol>
</p>
<button
style={{
backgroundColor: colors.primary.accent,
borderRadius: 5,
boxSizing: "border-box",
border: "none",
color: colors.static.white,
width: 250,
fontSize: "1em",
height: 45,
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
onMouseDown={(e) => {
if (globalThis.keyboardShown) {
e.preventDefault();
}
}}
onClick={() => {
const element = document.getElementById(
"editor-saving-failed-overlay"
);
if (element) {
element.style.display = "none";
}
}}
>
<p
style={{
userSelect: "none"
}}
>
Dismiss
</p>
</button>
</div>
<div
onScroll={controller.scroll}
ref={containerRef}

View File

@@ -30,15 +30,8 @@ import {
useRef,
useState
} from "react";
import {
EventTypes,
getRoot,
post,
postAsyncWithTimeout,
saveTheme
} from "../utils";
import { EventTypes, isReactNative, post, randId, saveTheme } from "../utils";
import { injectCss, transform } from "../utils/css";
import { pendingSaveRequests } from "../utils/pending-saves";
import { useTabContext, useTabStore } from "./useTabStore";
type Attachment = {
@@ -61,7 +54,6 @@ type Timers = {
selectionChange: NodeJS.Timeout | null;
change: NodeJS.Timeout | null;
wordCounter: NodeJS.Timeout | null;
scroll: NodeJS.Timeout | null;
};
function isInViewport(element: any) {
@@ -123,12 +115,10 @@ export type EditorController = {
};
export function useEditorController({
update,
getTableOfContents,
scrollTo
getTableOfContents
}: {
update: () => void;
getTableOfContents: () => any[];
scrollTo: (top: number) => void;
}): EditorController {
const passwordInputRef = useRef<HTMLInputElement | null>(null);
const tab = useTabContext();
@@ -145,8 +135,7 @@ export function useEditorController({
const timers = useRef<Timers>({
selectionChange: null,
change: null,
wordCounter: null,
scroll: null
wordCounter: null
});
if (!tabRef.current.noteId && loading) {
@@ -155,49 +144,14 @@ export function useEditorController({
const selectionChange = useCallback((_editor: Editor) => {}, []);
const titleChange = useCallback(async (title: string) => {
const currentSessionId = globalThis.sessionId;
const titleChange = useCallback((title: string) => {
post(
EventTypes.contentchange,
undefined,
tabRef.current.id,
tabRef.current.noteId
);
const params = [
{
title
},
tabRef.current.id,
tabRef.current.noteId,
currentSessionId
];
const pendingTitleIds = await pendingSaveRequests.getPendingTitleIds();
postAsyncWithTimeout(EventTypes.title, ...params, 1000)
.then(() => {
if (pendingTitleIds.length) {
dbLogger(
"log",
`Title saved: ${title}, removing ${pendingTitleIds.length} pending requests `
);
}
pendingSaveRequests.removePendingTitlesById(pendingTitleIds);
})
.catch((e) => {
dbLogger("error", e);
dbLogger(
"log",
`Saving title failed, setting pending request ${pendingTitleIds.length}`
);
if (params[2]) {
pendingSaveRequests.setTitle(params);
}
const element = document.getElementById("editor-saving-failed-overlay");
if (element) {
element.style.display = "flex";
editors[tabRef.current.id]?.commands?.blur();
element.focus();
}
});
post(EventTypes.title, title, tabRef.current.id, tabRef.current.noteId);
}, []);
const countWords = useCallback((ms = 300) => {
@@ -229,10 +183,10 @@ export function useEditorController({
if (typeof timers.current.change === "number") {
clearTimeout(timers.current?.change);
}
timers.current.change = setTimeout(async () => {
timers.current.change = setTimeout(() => {
htmlContentRef.current = editor.getHTML();
const params = [
post(
EventTypes.content,
{
html: htmlContentRef.current,
ignoreEdit: ignoreEdit
@@ -240,40 +194,7 @@ export function useEditorController({
tabRef.current.id,
tabRef.current.noteId,
currentSessionId
];
const pendingContentIds =
await pendingSaveRequests.getPendingContentIds();
postAsyncWithTimeout(EventTypes.content, ...params, 5000)
.then(() => {
if (pendingContentIds.length) {
dbLogger(
"log",
`Content saved, removing ${pendingContentIds.length} pending requests`
);
}
pendingSaveRequests.removePendingContentsById(pendingContentIds);
})
.catch((e) => {
dbLogger("error", e);
dbLogger(
"log",
`Saving content failed, setting pending request ${
pendingContentIds.length + 1
}`
);
if (params[2]) {
pendingSaveRequests.setContent(params);
}
const element = document.getElementById(
"editor-saving-failed-overlay"
);
if (element) {
element.style.display = "flex";
element.focus();
}
});
);
logger(
"info",
"Editor saving content",
@@ -289,18 +210,14 @@ export function useEditorController({
const scroll = useCallback(
(_event: React.UIEvent<HTMLDivElement, UIEvent>) => {
const value = _event.currentTarget.scrollTop;
if (timers.current.scroll !== null) clearTimeout(timers.current.scroll);
timers.current.scroll = setTimeout(() => {
if (
tabRef.current.noteId &&
tabRef.current.noteId === useTabStore.getState().getCurrentNoteId()
) {
useTabStore.getState().setNoteState(tabRef.current.noteId, {
top: value
});
}
}, 16);
const noteId = useTabStore
.getState()
.getNoteIdForTab(useTabStore.getState().currentTab);
if (noteId) {
useTabStore.getState().setNoteState(noteId, {
top: _event.currentTarget.scrollTop
});
}
},
[]
);
@@ -333,29 +250,19 @@ export function useEditorController({
switch (type) {
case "native:updatehtml": {
htmlContentRef.current = value;
logger("info", "UPDATING NOTE HTML");
if (tabRef.current.id !== useTabStore.getState().currentTab) {
updateTabOnFocus.current = true;
} else {
if (!editor) break;
const noteState = tabRef.current?.noteId
? useTabStore.getState().noteState[tabRef.current?.noteId]
: null;
const { from, to } = editor.state.selection;
editor?.commands.setContent(htmlContentRef.current, false, {
preserveWhitespace: true
});
if (noteState) {
editor.commands.setTextSelection({
from: noteState.from,
to: noteState.to
});
}
scrollTo?.(noteState?.top || 0);
editor.commands.setTextSelection({
from,
to
});
countWords(0);
}
@@ -389,8 +296,9 @@ export function useEditorController({
scrollIntoView(editor as any);
}
break;
case "native:resolve":
case "native:attachment-data":
if (pendingResolvers[value.resolverId]) {
logger("info", "resolved data for attachment", value.resolverId);
pendingResolvers[value.resolverId](value.data);
}
break;
@@ -403,9 +311,16 @@ export function useEditorController({
);
useEffect(() => {
getRoot()?.addEventListener("message", onMessage);
if (!isReactNative()) return; // Subscribe only in react native webview.
const isSafari = navigator.vendor.match(/apple/i);
let root: Document | Window = document;
if (isSafari) {
root = window;
}
root.addEventListener("message", onMessage);
return () => {
getRoot()?.removeEventListener("message", onMessage);
root.removeEventListener("message", onMessage);
};
}, [onMessage]);
@@ -439,8 +354,16 @@ export function useEditorController({
};
const getAttachmentData = (attachment: Partial<Attachment>) => {
return postAsyncWithTimeout(EventTypes.getAttachmentData, {
attachment
return new Promise<string>((resolve, reject) => {
const resolverId = randId("get_attachment_data");
pendingResolvers[resolverId] = (data) => {
delete pendingResolvers[resolverId];
resolve(data);
};
post(EventTypes.getAttachmentData, {
attachment,
resolverId: resolverId
});
});
};

View File

@@ -36,7 +36,7 @@ export type TabItem = {
pinned?: boolean;
};
export type NoteState = {
type NoteState = {
top: number;
to: number;
from: number;
@@ -89,8 +89,6 @@ export const useTabStore = create(
currentTab: 0,
scrollPosition: {},
setNoteState: (noteId: string, state: Partial<NoteState>) => {
if (editorControllers[get().currentTab]?.loading) return;
const noteState = {
...get().noteState
};
@@ -98,7 +96,6 @@ export const useTabStore = create(
...get().noteState[noteId],
...state
};
set({
noteState
});

View File

@@ -135,10 +135,7 @@ declare global {
| undefined
>;
var __DEV__: boolean;
function logger(type: "info" | "warn" | "error", ...logs: unknown[]): void;
function dbLogger(type: "log" | "error", ...logs: unknown[]): void;
/**
* Function to post message to react native
* @param type
@@ -161,24 +158,6 @@ declare global {
};
}
}
export function getRoot() {
if (!isReactNative()) return; // Subscribe only in react native webview.
const isSafari = navigator.vendor.match(/apple/i);
let root: Document | Window = document;
if (isSafari) {
root = window;
}
return root;
}
export function getOnMessageListener(callback: () => void) {
getRoot()?.addEventListener("onMessage", callback);
return {
remove: getRoot()?.removeEventListener("onMessage", callback)
};
}
/* eslint-enable no-var */
export const EventTypes = {
@@ -213,8 +192,7 @@ export const EventTypes = {
unlockWithBiometrics: "editor-events:unlock-biometrics",
disableReadonlyMode: "editor-events:disable-readonly-mode",
readonlyEditorLoaded: "readonlyEditorLoaded",
error: "editorError",
dbLogger: "editor-events:dbLogger"
error: "editorError"
} as const;
export function randId(prefix: string) {
@@ -231,8 +209,6 @@ export function logger(
type: "info" | "warn" | "error",
...logs: unknown[]
): void {
if (typeof globalThis.__DEV__ !== "undefined" && !globalThis.__DEV__) return;
const logString = logs
.map((log) => {
return typeof log !== "string" ? JSON.stringify(log) : log;
@@ -242,81 +218,29 @@ export function logger(
post(EventTypes.logger, `[${type}]: ` + logString);
}
export function dbLogger(type: "error" | "log", ...logs: unknown[]): void {
const logString = logs
.map((log) => {
return typeof log !== "string" ? JSON.stringify(log) : log;
})
.join(" ");
post(EventTypes.dbLogger, {
message: `[${type}]: ` + logString,
error: logs[0] instanceof Error ? logs[0] : undefined
});
}
export function post(
type: string,
export function post<T extends keyof typeof EventTypes>(
type: (typeof EventTypes)[T],
value?: unknown,
tabId?: number,
noteId?: string,
sessionId?: string,
hasTimeout?: boolean
): string {
const id = randId(type);
sessionId?: string
): void {
if (isReactNative()) {
setTimeout(() =>
window.ReactNativeWebView.postMessage(
JSON.stringify({
type,
value: value,
sessionId: sessionId || globalThis.sessionId,
tabId,
noteId,
resolverId: id,
hasTimeout: hasTimeout
})
)
window.ReactNativeWebView.postMessage(
JSON.stringify({
type,
value: value,
sessionId: sessionId || globalThis.sessionId,
tabId,
noteId
})
);
} else {
// console.log(type, value);
}
return id;
}
export async function postAsyncWithTimeout<R = any>(
type: string,
value?: unknown,
tabId?: number,
noteId?: string,
sessionId?: string,
waitFor?: number
): Promise<R> {
return new Promise((resolve, reject) => {
const id = post(
type,
value,
tabId,
noteId,
sessionId,
waitFor !== undefined ? true : false
);
globalThis.pendingResolvers[id] = (result) => {
delete globalThis.pendingResolvers[id];
logger("info", `Async post request resolved for ${id}`);
resolve(result);
};
if (waitFor !== undefined) {
setTimeout(() => {
if (globalThis.pendingResolvers[id]) {
delete globalThis.pendingResolvers[id];
reject(new Error(`Async post request timed out for ${id}`));
}
}, waitFor);
}
});
}
globalThis.logger = logger;
globalThis.dbLogger = dbLogger;
globalThis.post = post;
export function saveTheme(theme: ThemeDefinition) {

View File

@@ -1,139 +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 { EventTypes, postAsyncWithTimeout, randId } from ".";
class PendingSaveRequests {
static TITLES = "pendingTitles";
static CONTENT = "pendingContents";
async setTitle(value: any) {
const pendingTitles = JSON.parse(
this.get(PendingSaveRequests.TITLES) || "[]"
);
(pendingTitles as any[]).push({
id: randId("title-pending"),
params: value
});
return localStorage.setItem(
PendingSaveRequests.TITLES,
JSON.stringify(pendingTitles)
);
}
async getPendingTitles() {
const pendingTitles = JSON.parse(
this.get(PendingSaveRequests.TITLES) || "[]"
);
return pendingTitles;
}
async setContent(value: any) {
const pendingContents = JSON.parse(
this.get(PendingSaveRequests.CONTENT) || "[]"
);
(pendingContents as any[]).push({
id: randId("content-pending"),
params: value
});
return localStorage.setItem(
PendingSaveRequests.CONTENT,
JSON.stringify(pendingContents)
);
}
async getPendingContent() {
const pendingContents = this.get(PendingSaveRequests.CONTENT);
return JSON.parse(pendingContents || "[]");
}
get(key: string) {
return localStorage.getItem(key);
}
remove(key: string) {
return localStorage.removeItem(key);
}
clear() {
return localStorage.clear();
}
keys() {
return localStorage.keys();
}
async getPendingTitleIds() {
const pendingTitles = await this.getPendingTitles();
return (pendingTitles as any[]).map((pending) => pending.id);
}
async getPendingContentIds() {
const pendingContents = await this.getPendingContent();
return (pendingContents as any[]).map((pending) => pending.id);
}
async removePendingTitlesById(ids: string[]) {
const pendingTitles = await this.getPendingTitles();
const filtered = (pendingTitles as any[]).filter(
(pending) => !ids.includes(pending.id)
);
return localStorage.setItem(
PendingSaveRequests.TITLES,
JSON.stringify(filtered)
);
}
async removePendingContentsById(ids: string[]) {
const pendingContents = await this.getPendingContent();
const filtered = (pendingContents as any[]).filter(
(pending) => !ids.includes(pending.id)
);
return localStorage.setItem(
PendingSaveRequests.CONTENT,
JSON.stringify(filtered)
);
}
async postPendingRequests() {
const postPendingTitles = async () => {
const pendingTitles = await this.getPendingTitles();
this.remove(PendingSaveRequests.TITLES);
for (const pending of pendingTitles) {
if (pending.params[0]) pending.params[0].pendingChanges = true;
await postAsyncWithTimeout(EventTypes.title, ...pending.params, 5000);
}
};
const postPendingContent = async () => {
const pendingContents = await this.getPendingContent();
this.remove(PendingSaveRequests.CONTENT);
for (const pending of pendingContents) {
if (pending.params[0]) pending.params[0].pendingChanges = true;
await postAsyncWithTimeout(EventTypes.content, ...pending.params, 5000);
}
};
await postPendingTitles();
await postPendingContent();
}
}
export const pendingSaveRequests = new PendingSaveRequests();

View File

@@ -1,24 +0,0 @@
diff --git a/node_modules/prosemirror-model/dist/index.cjs b/node_modules/prosemirror-model/dist/index.cjs
index 1c45f1c..c92ec8d 100644
--- a/node_modules/prosemirror-model/dist/index.cjs
+++ b/node_modules/prosemirror-model/dist/index.cjs
@@ -98,6 +98,7 @@ var Fragment = function () {
var nodeStart = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0;
var parent = arguments.length > 4 ? arguments[4] : undefined;
for (var i = 0, pos = 0; pos < to; i++) {
+ if (i >= this.content.length) break;
var child = this.content[i],
end = pos + child.nodeSize;
if (end > from && f(child, nodeStart + pos, parent || null, i) !== false && child.content.size) {
diff --git a/node_modules/prosemirror-model/dist/index.js b/node_modules/prosemirror-model/dist/index.js
index 9c37a40..a215c60 100644
--- a/node_modules/prosemirror-model/dist/index.js
+++ b/node_modules/prosemirror-model/dist/index.js
@@ -84,6 +84,7 @@ class Fragment {
*/
nodesBetween(from, to, f, nodeStart = 0, parent) {
for (let i = 0, pos = 0; pos < to; i++) {
+ if (i >= this.content.length) break;
let child = this.content[i], end = pos + child.nodeSize;
if (end > from && f(child, nodeStart + pos, parent || null, i) !== false && child.content.size) {
let start = pos + 1;

View File

@@ -70,13 +70,7 @@ export const useEditor = (
EditorState.create({
doc,
plugins: editor.extensionManager.plugins,
selection:
selection.from > 0 &&
selection.from <= doc.content.size &&
selection.to > 0 &&
selection.to <= doc.content.size
? selection
: undefined
selection: selection || undefined
})
);
if (oldIsFocused && !editor.isFocused) editor.commands.focus();