Compare commits

...

57 Commits

Author SHA1 Message Date
Ammar Ahmed
563de1da79 mobile: use sqlite based logger 2024-05-20 15:03:57 +05:00
Abdullah Atta
4024ca763e web: use noop logger before initializing 2024-05-20 14:50:33 +05:00
Abdullah Atta
31e1749e6c logger: minor refactor 2024-05-20 14:50:12 +05:00
Abdullah Atta
bc2029f7e1 core: use chunks and set date only once 2024-05-20 14:49:44 +05:00
Abdullah Atta
e9a0c1640a core: remove attachments initialized log 2024-05-20 13:25:20 +05:00
Abdullah Atta
270f7ebbc7 web: add support for opening multiple sqlite databases 2024-05-20 13:21:35 +05:00
Abdullah Atta
f4ddd89e3e core: add support for deleting logs for a specific date 2024-05-20 13:19:24 +05:00
Abdullah Atta
b89774809f core: use sqlite for storing logs 2024-05-20 13:02:38 +05:00
Abdullah Atta
54a68e4406 themebuilder: bump version to 1.2.0 2024-05-18 13:09:49 +05:00
Abdullah Atta
bd73ae9df4 theme: add titleBar to theme scopes 2024-05-18 13:09:49 +05:00
Abdullah Atta
fe41f7b687 themebuilder: make theme builder compile 2024-05-18 13:09:49 +05:00
Ammar Ahmed
c9adaa2442 mobile: release v3.0.7 2024-05-17 12:41:13 +05:00
Abdullah Atta
10be5771c6 web: bump version to 3.0.6 2024-05-17 12:31:22 +05:00
Abdullah Atta
43f4669360 core: fix notes with no conflict marked as conflicted 2024-05-17 12:29:58 +05:00
Ammar Ahmed
3b04925e54 mobile: update changelog 2024-05-17 09:58:43 +05:00
Ammar Ahmed
ba3959af9e mobile: release v3.0.6 2024-05-17 09:58:08 +05:00
Ammar Ahmed
552d86d9d2 mobile: fix android compilation on java 17 2024-05-17 09:56:51 +05:00
Ammar Ahmed
096602150e mobile: fix encryption key not found error on login 2024-05-17 09:56:51 +05:00
Abdullah Atta
e5795bcbb2 web: fix app asking password on auto backups with encryption off 2024-05-16 16:25:04 +05:00
Ammar Ahmed
9be52a69ad mobile: add changelog 2024-05-16 15:45:38 +05:00
Ammar Ahmed
5809384f62 mobile: release v3.0.5 2024-05-16 15:44:48 +05:00
Ammar Ahmed
fff70dce16 mobile: fix recovery key dialog ui 2024-05-16 15:14:14 +05:00
Ammar Ahmed
258140b1e0 mobile: fix share widget title issues
1. Do not change title of note when choosing append to note
2. Fix color of title in widget
2024-05-16 12:07:29 +05:00
Ammar Ahmed
0a00844e64 mobile: fix locked note title cannot be edited 2024-05-16 12:07:05 +05:00
Abdullah Atta
03cea85efc web: bump version to 3.0.5 2024-05-16 11:57:12 +05:00
Abdullah Atta
8a16ef9b85 web: fix unlock a note for editing test 2024-05-16 11:55:50 +05:00
Abdullah Atta
c82c30b530 editor: fix search replace 2024-05-16 11:34:10 +05:00
Abdullah Atta
46599f66da web: fix search not working in readonly editor 2024-05-16 11:33:53 +05:00
Abdullah Atta
d1b532a4ae editor: show (empty) toolbar in readonly mode 2024-05-16 11:33:53 +05:00
Abdullah Atta
0baf7afc25 web: allow locked note duplication without unlocking 2024-05-16 09:31:46 +05:00
Abdullah Atta
a0595a7d23 core: copy all note relations when duplicating 2024-05-16 09:31:46 +05:00
Abdullah Atta
1e6f6ab477 core: fix Cannot read properties of undefined (reading 'startsWith') error on export 2024-05-16 09:31:18 +05:00
Abdullah Atta
62d677f9ad web: fix web app not working on safari 2024-05-16 09:30:49 +05:00
Abdullah Atta
8de98d98ab core: fix merger tests 2024-05-16 09:30:28 +05:00
Ammar Ahmed
5b57614bfe mobile: fix app lock issues 2024-05-16 09:27:27 +05:00
Ammar Ahmed
ae7c538ad7 mobile: enable privacy screen with applock 2024-05-16 09:27:27 +05:00
Ammar Ahmed
c6394c9e37 mobile: allow removing app lock with account password 2024-05-16 09:27:27 +05:00
Ammar Ahmed
acf3740a68 mobile: fix pricing plans stuck at loading 2024-05-16 09:24:19 +05:00
Ammar Ahmed
8423543904 mobile: show diff in note history 2024-05-16 09:23:45 +05:00
Abdullah Atta
b90cb28637 web: handle corrupted+migrations: error 2024-05-16 08:40:03 +05:00
Abdullah Atta
780e1e9db0 web: reopen editor session if conflict is resolved/created 2024-05-15 12:37:50 +05:00
Abdullah Atta
ca6f8e5e4b web: fix real time sync ignored when resolved content is synced 2024-05-15 12:37:08 +05:00
Abdullah Atta
4c4cda1c39 core: create conflict if local item is unsynced
previously we were only creating a conflict when the local item was
edited after the remote item. This failed in cases where even though
the local item was edited before the remote item, it still contained
important information.

Now we only check if the local item is synced or not. If it isn't synced,
we most probably want to preserve the local changes, or at least want
to be notified.
2024-05-15 12:36:38 +05:00
Abdullah Atta
6fcbde772c core: fix notes marked as conflicted wrongly 2024-05-15 12:34:23 +05:00
Abdullah Atta
eac1750b1c core: add test for merge conflicts during sync 2024-05-14 15:21:00 +05:00
Abdullah Atta
5eea179a58 core: set conflicted property on notes after fetch is done 2024-05-14 15:21:00 +05:00
Abdullah Atta
fe1ea6dc27 core: do not update dateModified when updating content internally 2024-05-14 15:21:00 +05:00
Abdullah Atta
cf1b774255 core: filter out conflicted notes & content during sync 2024-05-14 15:21:00 +05:00
Abdullah Atta
198fa33fc0 core: do not update dateModified when setting synced=true 2024-05-14 15:21:00 +05:00
Abdullah Atta
e722866d62 web: better handling of database disk image is malformed error 2024-05-14 15:21:00 +05:00
Ammar Ahmed
58845e5508 mobile: release v3.0.4 2024-05-11 15:19:12 +05:00
Abdullah Atta
543c6f17ed web: enable sourcemaps for web app 2024-05-11 13:09:59 +05:00
Abdullah Atta
533f95fcff editor: fix editor crash due to TypeError: t.startsWith is not a function 2024-05-11 12:53:30 +05:00
Ammar Ahmed
c02f1cde16 mobile: disable template when pin note to notifications 2024-05-11 12:49:11 +05:00
Ammar Ahmed
043f55ead0 mobile: fix exporting note to text for share/copy 2024-05-11 12:49:11 +05:00
Ammar Ahmed
fcece06a1c mobile: fix update notification not showing on mobile app 2024-05-11 12:49:11 +05:00
Abdullah Atta
da7d79edc6 core: token without a scope is invalid 2024-05-11 12:36:23 +05:00
92 changed files with 1263 additions and 848 deletions

View File

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

View File

@@ -16,15 +16,16 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "react-native-gesture-handler";
import {
THEME_COMPATIBILITY_VERSION,
useThemeEngineStore
} from "@notesnook/theme";
import React, { useEffect } from "react";
import { I18nManager, View } from "react-native";
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { initializeLogger } from "./common/database/logger";
import AppLockedOverlay from "./components/app-lock-overlay";
import { withErrorBoundry } from "./components/exception-handler";
import GlobalSafeAreaProvider from "./components/globalsafearea";
@@ -44,23 +45,28 @@ I18nManager.swapLeftAndRightInRTL(false);
const App = () => {
const init = useAppEvents();
useEffect(() => {
const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
init();
setTimeout(async () => {
SettingsService.onFirstLaunch();
await Notifications.get();
if (SettingsService.get().notifNotes) {
Notifications.pinQuickNote(true);
}
TipManager.init();
}, 100);
initializeLogger()
.catch((e) => {
console.log(e);
})
.finally(() => {
const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
init();
setTimeout(async () => {
SettingsService.onFirstLaunch();
await Notifications.get();
if (SettingsService.get().notifNotes) {
Notifications.pinQuickNote(true);
}
TipManager.init();
}, 100);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -43,9 +43,8 @@ const IOS_KEYCHAIN_ACCESS_GROUP = "group.org.streetwriters.notesnook";
const IOS_KEYCHAIN_SERVICE_NAME = "org.streetwriters.notesnook";
const KEYCHAIN_SERVER_DBKEY = "notesnook:db";
const NOTESNOOK_APPLOCK_KEY_SALT = "3dqclWbOYllfk9kk";
const NOTESNOOK_DB_KEY_SALT = "2rcgSprDmRvZ1AAa";
const NOTESNOOK_USER_KEY_SALT = "7qO4qeoM6PbsAJ0Q";
const NOTESNOOK_APPLOCK_KEY_SALT = "kBwr1Kre86ebOZ8ThLu2OA";
const NOTESNOOK_DB_KEY_SALT = "SNuzOcEK3amoqL0WvPeKqw";
const DB_KEY_CIPHER = "databaseKeyCipher";
const USER_KEY_CIPHER = "userKeyCipher";
@@ -117,9 +116,7 @@ export async function setAppLockVerificationCipher(appLockPassword) {
NOTESNOOK_APPLOCK_KEY_SALT
);
const encrypted = await encrypt(appLockCredentials, generatePassword());
CipherStorage.setMap(APPLOCK_CIPHER, encrypted);
DatabaseLogger.info("setAppLockVerificationCipher");
} catch (e) {
DatabaseLogger.error(e);
@@ -135,10 +132,8 @@ export async function validateAppLockPassword(appLockPassword) {
try {
const appLockCipher = CipherStorage.getMap(APPLOCK_CIPHER);
if (!appLockCipher) return true;
const decrypted = await decrypt(
await Sodium.deriveKey(appLockPassword, NOTESNOOK_APPLOCK_KEY_SALT),
appLockCipher
);
const key = await Sodium.deriveKey(appLockPassword, appLockCipher.salt);
const decrypted = await decrypt(key, appLockCipher);
DatabaseLogger.info(
`validateAppLockPassword: ${typeof decrypted === "string"}`
@@ -213,8 +208,7 @@ export async function getDatabaseKey(appLockPassword) {
if (userKeyCredentials) {
const userKeyCipher = await encrypt(
{
key: DB_KEY,
salt: NOTESNOOK_USER_KEY_SALT
key: DB_KEY
},
userKeyCredentials.password
);
@@ -239,7 +233,7 @@ export async function deriveCryptoKey(data) {
const userKeyCipher = await encrypt(
{
key: await getDatabaseKey(),
salt: NOTESNOOK_USER_KEY_SALT
salt: NOTESNOOK_DB_KEY_SALT
},
credentials.key
);
@@ -264,13 +258,12 @@ export async function getCryptoKey(_name) {
const key = await decrypt(
{
key: await getDatabaseKey()
key: await getDatabaseKey(),
salt: keyCipher.salt
},
keyCipher
);
DatabaseLogger.info("User key decrypted: ", !!key);
return key;
} catch (e) {
console.log("getCryptoKey", e);

View File

@@ -85,4 +85,10 @@ export async function setupDatabase(password) {
}
export const db = database;
export const DatabaseLogger = dbLogger;
let DatabaseLogger = dbLogger.scope(Platform.OS);
const setLogger = () => {
DatabaseLogger = dbLogger.scope(Platform.OS);
};
export { DatabaseLogger, setLogger };

View File

@@ -16,14 +16,27 @@ 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 { MMKVLoader } from "react-native-mmkv-storage";
import { initialize } from "@notesnook/core/dist/logger";
import { KV } from "./storage";
import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
import { Platform } from "react-native";
import { setLogger } from ".";
import { RNSqliteDriver } from "./sqlite.kysely";
const LoggerStorage = new MMKVLoader()
.withInstanceID("notesnook_logs")
.initialize();
const initializeLogger = async () => {
await initialize({
dialect: (name) => ({
createDriver: () => {
return new RNSqliteDriver({ async: true, dbName: name });
},
createAdapter: () => new SqliteAdapter(),
createIntrospector: (db) => new SqliteIntrospector(db),
createQueryCompiler: () => new SqliteQueryCompiler()
}),
tempStore: "memory",
journalMode: Platform.OS === "ios" ? "DELETE" : "WAL"
});
setLogger();
};
initialize(new KV(LoggerStorage));
export { LoggerStorage };
export { initializeLogger };

View File

@@ -34,11 +34,13 @@ import {
import { MMKV } from "../../common/database/mmkv";
import { useAppState } from "../../hooks/use-app-state";
import BiometricService from "../../services/biometrics";
import { ToastManager } from "../../services/event-manager";
import SettingsService from "../../services/settings";
import { useSettingStore } from "../../stores/use-setting-store";
import { useUserStore } from "../../stores/use-user-store";
import { NotesnookModule } from "../../utils/notesnook-module";
import { SIZE } from "../../utils/size";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
@@ -148,6 +150,12 @@ const AppLockedOverlay = () => {
lockApp(false);
enabled(false);
password.current = undefined;
} else {
ToastManager.show({
heading: `Invalid ${keyboardType === "numeric" ? "pin" : "password"}`,
type: "error",
context: "local"
});
}
} catch (e) {
console.error(e);
@@ -188,11 +196,7 @@ const AppLockedOverlay = () => {
enabled(false);
} else {
SettingsService.appEnteredBackground();
if (
SettingsService.get().privacyScreen ||
SettingsService.getProperty("appLockEnabled")
) {
if (SettingsService.get().privacyScreen) {
enabled(true);
}
}
@@ -209,6 +213,7 @@ const AppLockedOverlay = () => {
justifyContent: "center"
}}
>
<Toast context="local" />
<View
style={{
flex: 1,

View File

@@ -112,6 +112,10 @@ export const SessionExpired = () => {
if (!res) throw new Error("no token found");
if (db.tokenManager._isTokenExpired(res))
throw new Error("token expired");
const key = await db.user.getEncryptionKey();
if (!key) throw new Error("No encryption key found.");
Sync.run("global", false, "full", async (complete) => {
if (!complete) {
let user = await db.user.getUser();
@@ -127,7 +131,6 @@ export const SessionExpired = () => {
setVisible(false);
});
} catch (e) {
console.log(e);
let user = await db.user.getUser();
if (!user) return;
email.current = user.email;

View File

@@ -44,6 +44,7 @@ type DialogInfo = {
// eslint-disable-next-line @typescript-eslint/ban-types
context: "global" | "local" | (string & {});
secureTextEntry?: boolean;
keyboardType?: string;
};
export function presentDialog(data: Partial<DialogInfo>): void {

View File

@@ -174,6 +174,7 @@ export const Dialog = ({ context = "global" }) => {
onSubmit={onPressPositive}
returnKeyLabel="Done"
returnKeyType="done"
keyboardType={dialogInfo.keyboardType || "default"}
placeholder={dialogInfo.inputPlaceholder}
/>
</View>

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { db } from "../../../common/database";
import {
clearAppLockVerificationCipher,
setAppLockVerificationCipher,
@@ -44,6 +45,7 @@ import BaseDialog from "../../dialog/base-dialog";
import DialogButtons from "../../dialog/dialog-buttons";
import DialogHeader from "../../dialog/dialog-header";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import Input from "../../ui/input";
import Seperator from "../../ui/seperator";
@@ -66,6 +68,7 @@ export const AppLockPassword = () => {
confirmPassword?: string;
}>({});
const [secureTextEntry, setSecureTextEntry] = useState(true);
const [accountPass, setAccountPass] = useState(false);
useEffect(() => {
const subs = [
@@ -73,6 +76,7 @@ export const AppLockPassword = () => {
eOpenAppLockPasswordDialog,
(mode: "create" | "change" | "remove") => {
setMode(mode);
setAccountPass(false);
setVisible(true);
}
),
@@ -126,7 +130,9 @@ export const AppLockPassword = () => {
mode === "change"
? `Change app lock ${keyboardType}`
: mode === "remove"
? `Remove app lock ${keyboardType}`
? `Enter ${
accountPass ? "account password" : `app lock ${keyboardType}`
} to remove ${keyboardType}`
: `Set up a custom app lock ${keyboardType} to unlock the app`
}
icon="shield"
@@ -169,33 +175,39 @@ export const AppLockPassword = () => {
confirmPasswordInputRef.current?.focus();
}}
defaultValue={values.current.password}
keyboardType={keyboardType === "pin" ? "number-pad" : "default"}
keyboardType={
keyboardType === "pin" && !accountPass ? "number-pad" : "default"
}
autoComplete="password"
returnKeyLabel={mode !== "remove" ? "Next" : "Remove"}
returnKeyType={mode !== "remove" ? "next" : "done"}
secureTextEntry={secureTextEntry}
buttonLeft={
<IconButton
name={keyboardType === "password" ? "numeric" : "keyboard"}
onPress={() => {
setKeyboardType(
keyboardType === "password" ? "pin" : "password"
);
setSecureTextEntry(false);
setImmediate(() => {
setSecureTextEntry(true);
});
}}
style={{
width: 25,
height: 25,
marginRight: 5
}}
size={SIZE.lg}
/>
accountPass ? null : (
<IconButton
name={keyboardType === "password" ? "numeric" : "keyboard"}
onPress={() => {
setKeyboardType(
keyboardType === "password" ? "pin" : "password"
);
setSecureTextEntry(false);
setImmediate(() => {
setSecureTextEntry(true);
});
}}
style={{
width: 25,
height: 25,
marginRight: 5
}}
size={SIZE.lg}
/>
)
}
placeholder={
mode === "change"
accountPass
? "Account password"
: mode === "change"
? `New ${keyboardType}`
: `${keyboardType === "pin" ? "Pin" : "Password"}`
}
@@ -222,6 +234,36 @@ export const AppLockPassword = () => {
placeholder={`Confirm ${keyboardType}`}
/>
) : null}
{mode === "remove" ? (
<>
<Button
icon={
accountPass ? "checkbox-marked" : "checkbox-blank-outline"
}
onPress={() => {
setSecureTextEntry(false);
setAccountPass(!accountPass);
setTimeout(() => {
setSecureTextEntry(true);
});
}}
iconSize={SIZE.lg}
type="plain"
iconColor={
accountPass ? colors.primary.accent : colors.primary.icon
}
title="Use account password instead"
style={{
width: "100%",
alignSelf: "flex-start",
justifyContent: "flex-start",
paddingHorizontal: 0,
height: 30
}}
/>
</>
) : null}
</View>
<DialogButtons
@@ -249,7 +291,8 @@ export const AppLockPassword = () => {
);
return;
}
await setAppLockVerificationCipher(values.current.password);
const password = values.current.password;
setAppLockVerificationCipher(password);
SettingsService.setProperty("appLockHasPasswordSecurity", true);
} else if (mode === "change") {
if (
@@ -277,7 +320,6 @@ export const AppLockPassword = () => {
);
return;
}
const isCurrentPasswordCorrect = await validateAppLockPassword(
values.current.currentPassword
);
@@ -293,8 +335,10 @@ export const AppLockPassword = () => {
return;
}
const password = values.current.password;
await clearAppLockVerificationCipher();
SettingsService.setProperty("appLockHasPasswordSecurity", true);
await setAppLockVerificationCipher(values.current.password);
await setAppLockVerificationCipher(password);
} else if (mode === "remove") {
if (!values.current.password) {
ToastManager.error(
@@ -305,14 +349,18 @@ export const AppLockPassword = () => {
return;
}
const isCurrentPasswordCorrect = await validateAppLockPassword(
values.current.password
);
const isCurrentPasswordCorrect = accountPass
? await db.user.verifyPassword(values.current.password)
: await validateAppLockPassword(values.current.password);
if (!isCurrentPasswordCorrect) {
ToastManager.error(
new Error(
`${keyboardType === "pin" ? "Pin" : "Password"} incorrect`
accountPass
? "Account password incorrect"
: `${
keyboardType === "pin" ? "Pin" : "Password"
} incorrect`
),
undefined,
"local"
@@ -342,7 +390,9 @@ export const AppLockPassword = () => {
close();
}}
positiveTitle="Save"
positiveTitle={
mode === "remove" ? "Remove" : mode === "change" ? "Change" : "Save"
}
negativeTitle="Cancel"
positiveType="transparent"
loading={false}

View File

@@ -586,7 +586,7 @@ export class VaultDialog extends Component {
}
async _copyNote(note) {
Clipboard.setString(await convertNoteToText(note));
Clipboard.setString((await convertNoteToText(note, true)) || "");
ToastManager.show({
heading: "Note copied",
type: "success",
@@ -602,7 +602,7 @@ export class VaultDialog extends Component {
await Share.open({
heading: "Share note",
failOnCancel: false,
message: await convertNoteToText(note)
message: (await convertNoteToText(note)) || ""
});
} catch (e) {
console.error(e);

View File

@@ -21,10 +21,10 @@ import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
import Editor from "../../screens/editor";
import { ReadonlyEditor } from "../../screens/editor/readonly-editor";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { ToastManager, eSendEvent } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useSelectionStore } from "../../stores/use-selection-store";
import { useTrashStore } from "../../stores/use-trash-store";
@@ -34,7 +34,7 @@ import DialogHeader from "../dialog/dialog-header";
import { presentDialog } from "../dialog/functions";
import { Button } from "../ui/button";
import Paragraph from "../ui/typography/paragraph";
import { ReadonlyEditor } from "../../screens/editor/readonly-editor";
import { diff } from "diffblazer";
/**
*
@@ -121,12 +121,21 @@ export default function NotePreview({ session, content, note }) {
<ReadonlyEditor
editorId="historyPreview"
onLoad={async (loadContent) => {
if (content.data) {
const _note = note || (await db.notes.note(session?.noteId));
loadContent({
data: content.data,
id: _note.id
});
try {
if (content.data) {
const _note = note || (await db.notes.note(session?.noteId));
const currentContent = await db.content.get(_note.contentId);
loadContent({
data: diff(currentContent.data, content.data),
id: _note.id
});
}
} catch (e) {
ToastManager.error(
e,
"Failed to load history preview",
"local"
);
}
}}
/>

View File

@@ -600,20 +600,11 @@ export const useActions = ({
});
return;
}
const text = await convertNoteToText(item as Note, false);
if (!text) {
ToastManager.error(
new Error(Errors.export("text")),
undefined,
"local"
);
return;
}
const html = text.replace(/\n/g, "<br />");
const text = await convertNoteToText(item as Note, true);
const html = (text || "").replace(/\n/g, "<br />");
await Notifications.displayNotification({
title: item.title,
message: (item as Note).headline || text,
message: (item as Note).headline || text || "",
subtitle: "",
bigText: html,
ongoing: true,
@@ -684,15 +675,11 @@ export const useActions = ({
} else {
processingId.current = "shareNote";
const convertedText = await convertNoteToText(item);
if (!convertedText) {
ToastManager.error(new Error(Errors.export("text")));
return;
}
processingId.current = undefined;
Share.open({
title: "Share note to",
failOnCancel: false,
message: convertedText
message: convertedText || ""
});
}
}
@@ -772,11 +759,7 @@ export const useActions = ({
} else {
processingId.current = "copyContent";
const text = await convertNoteToText(item as Note, true);
if (!text) {
ToastManager.error(new Error(Errors.export("text")));
return;
}
Clipboard.setString(text);
Clipboard.setString(text || "");
processingId.current = undefined;
ToastManager.show({
heading: "Note copied to clipboard",

View File

@@ -22,20 +22,14 @@ import { Platform } from "react-native";
import { Subscription } from "react-native-iap";
import PremiumService from "../services/premium";
import { db } from "../common/database";
import { Product } from "@notesnook/core/dist/api/pricing";
type PurchaseInfo = {
country: string;
countryCode: string;
sku: string;
discount: number;
};
const skuInfos: { [name: string]: PurchaseInfo | undefined } = {};
const skuInfos: { [name: string]: Product | undefined } = {};
export const usePricing = (period: "monthly" | "yearly") => {
const [current, setCurrent] = useState<{
period: string;
info?: PurchaseInfo;
info?: Product;
product?: Subscription;
}>();
@@ -54,6 +48,7 @@ export const usePricing = (period: "monthly" | "yearly") => {
period
));
skuInfos[period] = skuInfo;
const products = (await PremiumService.getProducts()) as Subscription[];
let product = products.find((p) => p.productId === skuInfo?.sku);
if (!product)

View File

@@ -75,14 +75,16 @@ export function ReadonlyEditor(props: {
if (editorMessage.type === EventTypes.readonlyEditorLoaded) {
console.log("Readonly editor loaded.");
props.onLoad?.((content: { data: string; id: string }) => {
noteId.current = content.id;
editorRef.current?.postMessage(
JSON.stringify({
type: "native:html",
value: content.data
})
);
setLoading(false);
setTimeout(() => {
noteId.current = content.id;
editorRef.current?.postMessage(
JSON.stringify({
type: "native:html",
value: content.data
})
);
setLoading(false);
}, 300);
});
} else if (editorMessage.type === EventTypes.getAttachmentData) {
const attachment = (editorMessage.value as any).attachment as Attachment;

View File

@@ -351,9 +351,18 @@ export const useEditor = (
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);
if (typeof noteData.title === "string") {
await db.notes.add({
title: noteData.title,
id: noteData.id
});
}
noteData.contentId = note?.contentId;
if (data) {
await db.vault?.save(noteData as any);
}
clearTimeout(saveTimer);
}
@@ -696,14 +705,13 @@ export const useEditor = (
);
}
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) {
if (lastContentChangeTime.current[noteId] >= data.dateModified) {
return;
}
@@ -728,7 +736,7 @@ export const useEditor = (
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current?.data) {
if (_nextContent === currentContents.current[note.id]?.data) {
return;
}
lastContentChangeTime.current[note.id] = note.dateEdited;

View File

@@ -122,7 +122,8 @@ export default function DebugLogs() {
colors.primary.paragraph,
colors.error.paragraph,
colors.static.black,
colors.static.orange
colors.static.orange,
colors.primary.border
]
);

View File

@@ -90,13 +90,26 @@ export async function verifyUserWithApplock() {
positiveText: "Verify",
secureTextEntry: true,
negativeText: "Cancel",
keyboardType: keyboardType,
positivePress: async (value) => {
try {
const verified = await validateAppLockPassword(value);
if (!verified) {
ToastManager.show({
heading: `Invalid ${
keyboardType === "numeric" ? "pin" : "password"
}`,
type: "error",
context: "local"
});
return false;
}
resolve(verified);
} catch (e) {
resolve(false);
return false;
}
return true;
}
});
} else {

View File

@@ -871,6 +871,9 @@ export const settingsGroups: SettingSection[] = [
type: "switch",
property: "appLockEnabled",
onChange: () => {
SettingsService.set({
privacyScreen: true
});
SettingsService.setPrivacyScreen(SettingsService.get());
},
onVerify: async () => {
@@ -973,7 +976,7 @@ export const settingsGroups: SettingSection[] = [
SettingsService.getProperty("applockKeyboardType") === "numeric"
? "pin"
: "password"
}, app lock will fallback to using account password to unlock the app`,
}, app lock will be disabled if no other security method is enabled.`,
hidden: () => {
return !SettingsService.getProperty("appLockHasPasswordSecurity");
},

View File

@@ -59,14 +59,14 @@ const recoveryKeyMessage = {
() => {
eSendEvent(eOpenRecoveryKeyDialog);
},
true,
false,
async () => {
SettingsService.set({
recoveryKeySaved: true
});
clearMessage();
},
"I have saved my key already"
"Cancel"
);
},
data: {},

View File

@@ -952,13 +952,13 @@ async function pinNote(id: string) {
const note = await db.notes.note(id as string);
if (!note) return;
let text = await convertNoteToText(note, false);
let text = await convertNoteToText(note, true);
if (!text) text = "";
const html = text.replace(/\n/g, "<br />");
Notifications.displayNotification({
title: note.title,
message: note.headline || text,
subtitle: note.headline || text,
subtitle: "",
bigText: html,
ongoing: true,
actions: ["UNPIN"],

View File

@@ -76,6 +76,17 @@ function migrateAppLock() {
DatabaseLogger.debug("App lock Migrated");
}
function migrateSettings(settings: SettingStore["settings"]) {
const version = settings.settingsVersion;
if (!version) {
settings.settingsVersion = 1;
settings.privacyScreen = settings.appLockEnabled
? true
: settings.privacyScreen;
MMKV.setString("appSettings", JSON.stringify(settings));
}
}
function init() {
scale.fontScale = 1;
const settingsJson = MMKV.getString("appSettings");
@@ -83,14 +94,17 @@ function init() {
if (!settingsJson) {
MMKV.setString("appSettings", JSON.stringify(settings));
} else {
const settingsParsed = JSON.parse(settingsJson);
migrateSettings(settingsParsed);
settings = {
...settings,
...JSON.parse(settingsJson)
...settingsParsed
};
}
if (settings.fontScale) {
scale.fontScale = settings.fontScale;
}
setTimeout(() => setPrivacyScreen(settings), 1);
updateSize();
useSettingStore.getState().setSettings({ ...settings });
@@ -98,7 +112,7 @@ function init() {
}
function setPrivacyScreen(settings: SettingStore["settings"]) {
if (settings.privacyScreen || settings.appLockEnabled) {
if (settings.privacyScreen) {
if (Platform.OS === "android") {
NotesnookModule.setSecureMode(true);
} else {

View File

@@ -81,6 +81,7 @@ export type Settings = {
biometricsAuthEnabled?: boolean;
backgroundSync?: boolean;
applockKeyboardType: "numeric" | "default";
settingsVersion?: number;
};
type DimensionsType = {
@@ -169,7 +170,8 @@ export const defaultSettings: SettingStore["settings"] = {
markdownShortcuts: true,
biometricsAuthEnabled: false,
appLockHasPasswordSecurity: false,
backgroundSync: true
backgroundSync: true,
settingsVersion: 0
};
export const useSettingStore = create<SettingStore>((set, get) => ({

View File

@@ -23,7 +23,7 @@ export const IOS_APPGROUPID = "group.org.streetwriters.notesnook";
export const FILE_SIZE_LIMIT = 500 * 1024 * 1024;
export const IMAGE_SIZE_LIMIT = 50 * 1024 * 1024;
export const BETA = true;
export const BETA = false;
export const STORE_LINK =
Platform.OS === "ios"

View File

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

View File

@@ -37,4 +37,10 @@ allprojects {
maven { url 'https://www.jitpack.io' }
}
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "11"
}
}
}

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 = 2104;
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.7;
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 = 2104;
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.7;
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 = 2104;
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.7;
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 = 2104;
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.7;
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 = 2104;
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.7;
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 = 2104;
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.7;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -959,4 +959,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 2b8b28a341b202bf3ca5f231b75bb05893486ed8
COCOAPODS: 1.12.1
COCOAPODS: 1.15.2

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.0.2",
"version": "3.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.0.2",
"version": "3.0.4",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"workspaces": [

View File

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

View File

@@ -311,8 +311,7 @@ const ShareView = () => {
type: "tiptap"
},
id: note.id,
sessionId: Date.now(),
title: noteTitle.current
sessionId: Date.now()
};
} else {
noteData = { ...note };
@@ -487,25 +486,42 @@ const ShareView = () => {
gap: 10
}}
>
<TextInput
placeholder="Enter note title"
ref={inputRef}
style={{
flexShrink: 1,
flexGrow: 1,
fontFamily: "OpenSans-SemiBold",
fontSize: SIZE.lg,
paddingBottom: 0,
paddingTop: 0
}}
onChangeText={(value) => {
noteTitle.current = value;
}}
blurOnSubmit={false}
onSubmitEditing={() => {
editorRef.current.focus();
}}
/>
{appendNoteId ? (
<Heading
style={{
flexShrink: 1,
flexGrow: 1,
fontFamily: "OpenSans-SemiBold",
fontSize: SIZE.lg,
paddingBottom: 0,
paddingTop: 0
}}
>
Save note
</Heading>
) : (
<TextInput
placeholder="Enter note title"
ref={inputRef}
style={{
flexShrink: 1,
flexGrow: 1,
fontFamily: "OpenSans-SemiBold",
fontSize: SIZE.lg,
paddingBottom: 0,
paddingTop: 0,
color: colors.primary.heading
}}
onChangeText={(value) => {
noteTitle.current = value;
}}
defaultValue={noteTitle.current}
blurOnSubmit={false}
onSubmitEditing={() => {
editorRef.current.focus();
}}
/>
)}
<Button
title="Done"
type="accent"

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/theme-builder",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/theme-builder",
"version": "1.1.1",
"version": "1.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@emotion/react": "11.11.1",
@@ -18,17 +18,18 @@
"@theme-ui/components": "^0.14.7",
"@theme-ui/core": "^0.14.7",
"@trpc/client": "^10.38.3",
"@types/react-dom": "^18.3.0",
"clipboard-polyfill": "^4.0.0",
"file-saver": "^2.0.5",
"katex": "^0.16.2",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.22.5",
"@types/babel__core": "^7.20.1",
"@types/file-saver": "^2.0.5",
"@types/react": "^18.2.17",
"@types/react": "^18.2.39",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"buffer": "^6.0.3",
@@ -978,7 +979,7 @@
},
"../web": {
"name": "@notesnook/web",
"version": "3.0.0",
"version": "3.0.6",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -2026,6 +2027,15 @@
"@trpc/server": "10.38.3"
}
},
"node_modules/@trpc/server": {
"version": "10.38.3",
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-10.38.3.tgz",
"integrity": "sha512-9s8/kwo2IDB5hwB2SKZZrfevRhdb1f9fdXtIYd3lbQuf2jQaC/LyQuHaIQjDQoUx9updBfsHXcFFPiCP1DL6pg==",
"funding": [
"https://trpc.io/sponsor"
],
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.1",
"dev": true,
@@ -2098,25 +2108,30 @@
},
"node_modules/@types/prop-types": {
"version": "15.7.5",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.39",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"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==",
"dev": true
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/@types/styled-system": {
"version": "5.1.16",

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/theme-builder",
"description": "Your private note taking space",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",
@@ -17,18 +17,18 @@
"@theme-ui/components": "^0.14.7",
"@theme-ui/core": "^0.14.7",
"@trpc/client": "^10.38.3",
"@types/react-dom": "^18.3.0",
"clipboard-polyfill": "^4.0.0",
"file-saver": "^2.0.5",
"katex": "^0.16.2",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.22.5",
"@types/babel__core": "^7.20.1",
"@types/file-saver": "^2.0.5",
"@types/react": "^18.2.39",
"@types/react": "^18.2.17",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"buffer": "^6.0.3",

View File

@@ -2,19 +2,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAHUUExURUxpcf////////////////////////////////////////////////////////////////////////////////////39/f39/f////////////////////////////////z8/O3s7P7+/vTz8wAAAPz7++/v7/Py8u/u7vDv7+7t7dfX1/79/ezr66Kiovv6+vf29vX09OTj4w0NDfv7++Ti4vr6+vT09Ovq6vn5+f39/fb19dnX1/b29tvb29/f3xMTE/j4+Pn4+O7s7MnJyerq6uno6M/Pz8jIyN3d3dnZ2d7e3kZGRvj39/38/Pf39+zs7PDw8OPi4ubl5cvLy+rp6czMzPLy8vPz887Ozujo6MrKyuDf3+fn59PT0+bm5uXl5c3NzfX19evr6wsLC+/t7ePj4/Ty8u3r6/Lx8eXk5OPh4dDQ0Nza2tjX19vZ2fHw8Nva2tLR0djW1tbW1tra2tLS0uHh4evp6eLi4tXV1djY2L+/vysrK7q6utHR0aGhoa6urrm5uQICAigoKCEhIZCQkFxcXBgYGFRUVBISEm1tbQ4ODklJSbu7uyAgID09PZqamkVFRXh4eLGxsTg4OOTk5FFRUQEBAQoKCnx8fBcXF7/+rP8AAAAfdFJOUwBOqO2h6KT9Mtyr5+w3Str+6TTZOjXb31M24O8BOd0PapSsAAABxElEQVQ4y2NgYOBgYWWXxwLYBdmEGICAjx9Z1NJJVQfB42QE6hdG0RaoaaCqhKSCl4EF1VxNVAXyTAzcaArUUBXwMLBjKDBBViDAII+uwBZFgTy6AjU1awIKNKz1aa0g1VBfB58CDS08CuIb40AKzLAqsJKXN2tXacChwKS+rTtB3tJHRU9ewxmbFZ6tKiqTrSAKdIO1LDEUaPeoqMzsUwAp0NIt8Uj0ijJXQFVgnDJVZcK0XrACXcXY0sjoHM8AfyUkBbOn9KvMmgRSYFEW4hUVqJagHeaShqTAwX7ijJQusAkW5VXRHu6eruFqaigKkqarqAAVKFbYxWiYq1XGedijuMHBWz6xo7m2Rl7RwtvdJaw6NjzCFl2BVVNdshJQgbqBsqtdpIu7nasjXIFXS2cyRL2CW6i6JTCgFCLitUPs4QrM7cDmKZi7FRYVm6o5QeLbDGICLNEqJAVk59rk2djY5Me4FThawQOKFSzrrxmaZWpqBAamQUGmwRaakNTNzMAm7+ekrKmlqOjsrIgAGcAA1ci0NVPiYhAV8zVURgbp6nBg7isuwsDAKKfkp4AEVOHASklKApS9JZmY5bECaS5ZGQYA3vqpy6NoYh0AAABXelRYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAAeJzj8gwIcVYoKMpPy8xJ5VIAAyMLLmMLEyMTS5MUAxMgRIA0w2QDI7NUIMvY1MjEzMQcxAfLgEigSi4A6hcRdPJCNZUAAAAASUVORK5CYII="
/>
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- <link rel="manifest" href="/site.webmanifest" /> -->
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#01c352" />
<meta name="msapplication-TileColor" content="#01c352" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#01c352" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -49,13 +40,26 @@
/>
<meta name="twitter:card" content="summary_large_image" />
<title>Notesnook Theme Builder</title>
<style id="theme-colors">
#splash {
display: none !important;
}
</style>
<script type="module">
import { themeToCSS } from "@notesnook/theme";
<script nonce="7WIq8hRwApoXhctoGZZthMLYQLRNiprTwcPi6Azdf">
const colorScheme = JSON.parse(
window.localStorage.getItem("colorScheme") || '"light"'
);
const root = document.querySelector("html");
if (root) root.setAttribute("data-theme", colorScheme);
const theme = window.localStorage.getItem(`theme:${colorScheme}`);
if (theme) {
const css = themeToCSS(JSON.parse(theme));
const stylesheet = document.getElementById("theme-colors");
if (stylesheet) stylesheet.innerHTML = css;
}
</script>
<script type="module" src="/index.tsx"></script>
<style>
@@ -63,21 +67,6 @@
overscroll-behavior: none;
}
html[data-theme="dark"] {
--bg: #0f0f0f;
--fg: #fff;
--three-bars-bg: #494949;
--gradient-stop-color: #111111;
--n-letter-color: #e1e1e1;
}
html[data-theme="light"] {
--bg: #fff;
--three-bars-bg: #bebebe;
--gradient-stop-color: #fff9f9;
--n-letter-color: #000;
}
html[data-theme="light"] .react-loading-skeleton {
--base-color: var(--background-secondary);
--highlight-color: #var(--background-secondary);
@@ -88,6 +77,19 @@
--highlight-color: var(--background-secondary);
}
#splash {
background-color: var(--background);
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#splash svg {
transform: scale(1);
animation: pulse 2s infinite;
@@ -95,7 +97,7 @@
@keyframes pulse {
0% {
transform: scale(0.95);
transform: scale(0.9);
}
70% {
@@ -103,7 +105,7 @@
}
100% {
transform: scale(0.95);
transform: scale(0.9);
}
}
@@ -128,20 +130,9 @@
overflow: hidden;
}
@keyframes fadeUp {
0% {
transform: translateY(500px);
opacity: 0;
}
80% {
transform: translateY(0px);
opacity: 0.7;
}
100% {
opacity: 1;
}
#root {
display: flex;
flex-direction: column;
}
/* svg {
@@ -171,85 +162,40 @@
</style>
</head>
<body>
<body class="theme-scope-base">
<noscript>You need to enable JavaScript to run this app.</noscript>
<!-- <script src="https://cdn.jsdelivr.net/npm/highlightjs@9.16.2/highlight.pack.min.js"></script>
-->
<div id="root"></div>
<div
id="splash"
style="
background-color: var(--bg);
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
"
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 0; width: 0; z-index: -1; position: absolute"
>
<svg viewBox="0 0 339 339" style="width: 150px">
<defs />
<defs>
<linearGradient
xlink:href="#a"
id="b"
x1="188.61227"
x2="193.54405"
y1="165.2058"
y2="216.81519"
gradientTransform="rotate(5 4448 -4204) scale(2.93671)"
gradientUnits="userSpaceOnUse"
/>
<linearGradient id="a">
<stop offset="0" />
<stop
offset="1"
stop-color="var(--gradient-stop-color)"
stop-opacity="0"
/>
</linearGradient>
<linearGradient
id="c"
x1="167.8"
x2="270.6"
y1="76.9"
y2="64.2"
gradientTransform="rotate(5 465 -2050) scale(1.50082)"
gradientUnits="userSpaceOnUse"
xlink:href="#a"
/>
</defs>
<g transform="translate(0 42)">
<path fill="url(#b)" d="M160 205l154 42-141 44-155-42z" />
<path fill="url(#c)" d="M160-35v240l154 42 1-253z" />
<symbol id="full-logo" viewBox="0 0 1000 1000">
<rect
width="1000"
height="1000"
rx="30%"
ry="30%"
style="fill: var(--paragraph)"
/>
<g transform="translate(275,176.95254)">
<path
fill="none"
stroke-width="1.2"
d="M160 205V-35m0 240L18 249m142-44l154 41"
/>
<path
fill="var(--n-letter-color)"
d="M84 109l35 54V98l21-7v91l-27 9-35-54v65l-21 6v-91z"
/>
<rect
width="86.1"
height="12.6"
x="185"
y="97"
fill="var(--three-bars-bg)"
ry="2.3"
transform="skewY(15) scale(.9669 1)"
/>
<path
fill="var(--three-bars-bg)"
d="M181 169l99 26 2 3v8c0 1-1 2-2 1l-99-26-2-3v-7c0-2 1-2 2-2zm0-47l99 27 2 2v8l-2 2-99-27c-1 0-2-1-2-3v-7l2-2z"
style="fill: var(--background)"
d="M437.985 493.919c-17.255 50.411-51.835 93.065-97.588 120.372-45.752 27.307-99.71 37.496-152.269 28.753-52.56-8.743-100.313999-35.85-134.763-76.498C18.916 525.898.006 474.347 0 421.064v-110.25l79.121001 33.053v77.152c-.007 20.779 4.434 41.318 13.024 60.237C100.735 500.176 113.276 517.037 128.925 530.707c2.97 2.587 6.053 5.107 9.236 7.459 23.357 17.388 51.402 27.352 80.494 28.597.99 0 1.946.079 2.925.101.979.023 2.25 0 3.375 0h3.375c1.125 0 1.935 0 2.925-.101 29.081-1.248 57.116-11.203 80.471-28.575 3.173-2.351 6.255-4.86 9.237-7.447 20.556-17.98 35.656-41.366 43.582-67.5zM450 225v196.065c0 2.531 0 5.074-.158 7.605l-78.963-33.019V225c-.013-37.684-14.607-73.901-40.725-101.066-26.119-27.164003-61.735-43.168003-99.389-44.660003-37.655-1.492-74.426 11.644-102.611 36.657003-28.185999 25.013-45.599999 59.962-48.593999 97.526-.281 3.803-.439 7.662-.439 11.543v48.712L0 240.637V0h225c59.674 0 116.903 23.705 159.099 65.901C426.295 108.097 450 165.326 450 225Z"
/>
</g>
</symbol>
<symbol id="themed-logo" viewBox="0 0 450 646.09491">
<path
style="fill: var(--paragraph)"
d="M437.985 493.919c-17.255 50.411-51.835 93.065-97.588 120.372-45.752 27.307-99.71 37.496-152.269 28.753-52.56-8.743-100.313999-35.85-134.763-76.498C18.916 525.898.006 474.347 0 421.064v-110.25l79.121001 33.053v77.152c-.007 20.779 4.434 41.318 13.024 60.237C100.735 500.176 113.276 517.037 128.925 530.707c2.97 2.587 6.053 5.107 9.236 7.459 23.357 17.388 51.402 27.352 80.494 28.597.99 0 1.946.079 2.925.101.979.023 2.25 0 3.375 0h3.375c1.125 0 1.935 0 2.925-.101 29.081-1.248 57.116-11.203 80.471-28.575 3.173-2.351 6.255-4.86 9.237-7.447 20.556-17.98 35.656-41.366 43.582-67.5zM450 225v196.065c0 2.531 0 5.074-.158 7.605l-78.963-33.019V225c-.013-37.684-14.607-73.901-40.725-101.066-26.119-27.164003-61.735-43.168003-99.389-44.660003-37.655-1.492-74.426 11.644-102.611 36.657003-28.185999 25.013-45.599999 59.962-48.593999 97.526-.281 3.803-.439 7.662-.439 11.543v48.712L0 240.637V0h225c59.674 0 116.903 23.705 159.099 65.901C426.295 108.097 450 165.326 450 225Z"
/>
</symbol>
</svg>
<div id="splash" class="hidden">
<svg style="height: 120px">
<use href="#themed-logo" />
</svg>
</div>
<div id="dialogContainer"></div>

View File

@@ -18,31 +18,59 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { init } from "@notesnook/web/src/bootstrap";
import { render } from "react-dom";
import { createRoot } from "react-dom/client";
import {
ErrorBoundary,
ErrorComponent
} from "@notesnook/web/src/components/error-boundary";
import { BaseThemeProvider } from "@notesnook/web/src/components/theme-provider";
import { App } from "./app";
renderApp();
async function renderApp() {
const { component, props } = await init();
const rootElement = document.getElementById("root");
if (!rootElement) return;
const root = createRoot(rootElement);
try {
const { component, props } = await init();
const { default: Component } = await component();
render(
<BaseThemeProvider
sx={{
display: "flex",
"#app": { flex: 1, height: "unset" },
"& > :first-child:not(#menu-wrapper)": { flex: 1 },
height: "100%"
}}
>
<Component route={props?.route || "login:email"} />
<App />
</BaseThemeProvider>,
document.getElementById("root"),
() => {
document.getElementById("splash")?.remove();
}
);
const { useKeyStore } = await import(
"@notesnook/web/src/interfaces/key-store"
);
await useKeyStore.getState().init();
const { default: Component } = await component();
const { default: AppLock } = await import(
"@notesnook/web/src/views/app-lock"
);
root.render(
<ErrorBoundary>
<BaseThemeProvider
onRender={() => document.getElementById("splash")?.remove()}
sx={{
display: "flex",
"#app": { flex: 1, height: "unset" },
"& > :first-child:not(#menu-wrapper)": { flex: 1 },
height: "100%"
}}
>
<AppLock>
<Component route={props?.route || "login:email"} />
</AppLock>
<App />
</BaseThemeProvider>
</ErrorBoundary>
);
} catch (e) {
root.render(
<>
<ErrorComponent
error={e}
resetErrorBoundary={() => window.location.reload()}
/>
</>
);
}
}

View File

@@ -259,14 +259,15 @@ test("unlock a note for editing", async ({ page }) => {
await page.waitForTimeout(150);
await page.reload();
await page.waitForTimeout(500);
await notes.waitForList();
const newContent = `${NOTE.content}${content}`;
const editedNote = await notes.findNote({
title: NOTE.title,
content: newContent
});
await editedNote?.openLockedNote(PASSWORD);
if (!editedNote) throw new Error("Could not find note.");
await editedNote.openLockedNote(PASSWORD);
await notes.editor.waitForLoading();
expect(await notes.editor.getContent("text")).toContain(newContent);
});

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/web",
"version": "3.0.4",
"version": "3.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.0.4",
"version": "3.0.6",
"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.4",
"version": "3.0.6",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",

View File

@@ -58,7 +58,7 @@ async function initializeDatabase(persistence: DatabasePersistence) {
database.setup({
sqliteOptions: {
dialect: createDialect,
dialect: (name, init) => createDialect(name, true, init),
...(IS_DESKTOP_APP || isFeatureSupported("opfs")
? { journalMode: "WAL", lockingMode: "exclusive" }
: {

View File

@@ -77,12 +77,19 @@ export async function introduceFeatures() {
export const DEFAULT_CONTEXT = { colors: [], tags: [], notebook: {} };
export async function createBackup(rescueMode = false) {
export async function createBackup(
options: {
rescueMode?: boolean;
noVerify?: boolean;
} = {}
) {
const { rescueMode, noVerify } = options;
const { isLoggedIn } = useUserStore.getState();
const { encryptBackups, toggleEncryptBackups } = useSettingStore.getState();
if (!isLoggedIn && encryptBackups) toggleEncryptBackups();
const verified = rescueMode || encryptBackups || (await verifyAccount());
const verified =
rescueMode || encryptBackups || noVerify || (await verifyAccount());
if (!verified) {
showToast("error", "Could not create a backup: user verification failed.");
return false;

View File

@@ -18,9 +18,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import hotkeys from "hotkeys-js";
import { navigate } from "../navigation";
// import { store as themestore } from "../stores/theme-store";
import { GlobalKeyboard } from "../utils/keyboard";
import { useEditorStore } from "../stores/editor-store";
import { useStore as useSearchStore } from "../stores/search-store";
import { useEditorManager } from "../components/editor/manager";
const KEYMAP = [
// {
@@ -57,11 +58,24 @@ const KEYMAP = [
keys: ["command+f", "ctrl+f"],
description: "Search all notes",
global: false,
action: (e) => {
if (e.target?.classList.contains("ProseMirror")) return;
action: (e: KeyboardEvent) => {
const isInEditor =
e.target instanceof HTMLElement &&
!!e.target?.closest(".editor-container");
if (isInEditor) {
const activeSession = useEditorStore.getState().getActiveSession();
if (activeSession?.type === "readonly") {
e.preventDefault();
const editor = useEditorManager
.getState()
.getEditor(activeSession.id);
editor?.editor?.startSearch();
}
return;
}
e.preventDefault();
navigate("/search/notes");
useSearchStore.setState({ isSearching: true, searchType: "notes" });
}
}
// {
@@ -137,7 +151,11 @@ export function registerKeyMap() {
KEYMAP.forEach((key) => {
hotkeys(
key.keys.join(","),
{ element: key.global ? GlobalKeyboard : window },
{
element: key.global
? (GlobalKeyboard as unknown as HTMLElement)
: document.body
},
key.action
);
});

View File

@@ -177,7 +177,7 @@ function isIgnored(key: keyof typeof NoticesData) {
let openedToast: { hide: () => void } | null = null;
async function saveBackup() {
if (IS_DESKTOP_APP) {
await createBackup();
await createBackup({ noVerify: true });
} else if (isUserPremium() && !IS_TESTING) {
if (openedToast !== null) return;
openedToast = showToast(

View File

@@ -40,7 +40,7 @@ class SqliteDriver extends KSqliteDriver {
}
}
export const createDialect = (name: string): Dialect => {
export const createDialect = (name: string, _encrypted: boolean): Dialect => {
return {
createDriver: () =>
new SqliteDriver({

View File

@@ -34,6 +34,7 @@ declare module "kysely" {
export const createDialect = (
name: string,
encrypted: boolean,
init?: () => Promise<void>
): Dialect => {
return {
@@ -41,6 +42,7 @@ export const createDialect = (
new WaSqliteWorkerDriver({
async: !isFeatureSupported("opfs"),
dbName: name,
encrypted,
init
}),
createAdapter: () => new SqliteAdapter(),

View File

@@ -83,7 +83,7 @@ export class SharedService<T extends object> extends EventTarget {
}
activate(
portProviderFunc: () => MessagePort | Promise<MessagePort>,
portProviderFunc: () => Promise<{ port: MessagePort; onclose: () => void }>,
onClientConnected: () => Promise<void>
) {
if (this.#onDeactivate) return;
@@ -97,7 +97,7 @@ export class SharedService<T extends object> extends EventTarget {
navigator.locks
.request(LOCK_NAME, { signal: this.#onDeactivate.signal }, async () => {
// Get the port to request client ports.
const port = await portProviderFunc();
const { port, onclose } = await portProviderFunc();
port.start();
// Listen for client requests. A separate BroadcastChannel
@@ -159,6 +159,7 @@ export class SharedService<T extends object> extends EventTarget {
// Release the lock only on user abort or context destruction.
return new Promise((_, reject) => {
this.#onDeactivate?.signal.addEventListener("abort", () => {
onclose();
broadcastChannel.close();
reject(this.#onDeactivate?.signal.reason);
});

View File

@@ -32,219 +32,230 @@ type PreparedStatement = {
columns: string[];
};
let sqlite: SQLiteAPI;
let db: number | undefined = undefined;
let vfs: IDBBatchAtomicVFS | AccessHandlePoolVFS | null = null;
let initialized = false;
const preparedStatements: Map<string, PreparedStatement> = new Map();
const retryCounter: Record<string, number> = {};
console.log("new sqlite worker");
async function open(dbName: string, async: boolean, url?: string) {
if (db) {
console.error("Database is already initialized", db);
return;
}
const option = url ? { locateFile: () => url } : {};
const sqliteModule = async
? await import("./wa-sqlite-async").then(
({ default: SQLiteAsyncESMFactory }) => SQLiteAsyncESMFactory(option)
)
: await import("./wa-sqlite").then(({ default: SQLiteSyncESMFactory }) =>
SQLiteSyncESMFactory(option)
);
sqlite = Factory(sqliteModule);
vfs = await getVFS(dbName, async);
sqlite.vfs_register(vfs, false);
db = await sqlite.open_v2(dbName, undefined, `multipleciphers-${vfs.name}`);
}
/**
* Wrapper function for preparing SQL statements with caching
* to avoid unnecessary computations.
*/
async function prepare(sql: string) {
if (!db) throw new Error("Database is not initialized.");
try {
const cached = preparedStatements.get(sql);
if (cached !== undefined) return cached;
const str = sqlite.str_new(db, sql);
const prepared = await sqlite.prepare_v2(db, sqlite.str_value(str));
if (!prepared) return;
const statement: PreparedStatement = {
stmt: prepared.stmt,
columns: sqlite.column_names(prepared.stmt)
};
preparedStatements.set(sql, statement);
sqlite.str_finish(str);
// reset retry count on success
retryCounter[sql] = 0;
return statement;
} catch (ex) {
console.error(ex);
// statement prepare process can be flaky so retry at least 5 times
// before giving up.
if (retryCounter[sql] < 5) {
retryCounter[sql] = (retryCounter[sql] || 0) + 1;
console.warn("Failed to prepare statement. Retrying:", sql);
return prepare(sql);
} else retryCounter[sql] = 0;
if (ex instanceof Error || ex instanceof SQLiteError)
ex.message += ` (query: ${sql})`;
throw ex;
}
}
async function run(
sql: string,
mode: RunMode,
parameters?: SQLiteCompatibleType[]
) {
const prepared = await prepare(sql);
if (!prepared) return [];
try {
if (parameters) sqlite.bind_collection(prepared.stmt, parameters);
// fast path for exec statements
if (mode === "exec") {
while ((await sqlite.step(prepared.stmt)) === SQLITE_ROW);
return [];
}
const rows: Record<string, SQLiteCompatibleType>[] = [];
while ((await sqlite.step(prepared.stmt)) === SQLITE_ROW) {
const row = sqlite.row(prepared.stmt);
const acc: Record<string, SQLiteCompatibleType> = {};
row.forEach((v, i) => (acc[prepared.columns[i]] = v));
rows.push(acc);
}
return rows;
} catch (e) {
if (e instanceof Error || e instanceof SQLiteError)
e.message += ` (query: ${sql})`;
throw e;
} finally {
await sqlite
.reset(prepared.stmt)
// we must clear/destruct the prepared statement if it can't be reset
.catch(() =>
sqlite
.finalize(prepared.stmt)
// ignore error (we will just prepare a new statement)
.catch(console.error)
.finally(() => preparedStatements.delete(sql))
);
}
}
async function exec<R>(
mode: RunMode,
sql: string,
parameters?: SQLiteCompatibleType[]
): Promise<QueryResult<R>> {
if (!sql.startsWith("PRAGMA key")) {
await waitForDatabase();
}
if (!db) throw new Error("No database is not opened.");
const rows = (await run(sql, mode, parameters)) as R[];
if (mode === "query") return { rows };
// initialize the database after it has been successfully decrypted.
// all queries prior to that must wait otherwise we get the
// "file is not a database" error
if (sql.startsWith("PRAGMA key")) await initialize();
return {
insertId: BigInt(sqlite.last_insert_rowid(db)),
numAffectedRows: BigInt(sqlite.changes(db)),
rows: mode === "raw" ? rows : []
};
}
async function close() {
if (!db) return;
for (const [_, prepared] of preparedStatements) {
await sqlite.finalize(prepared.stmt);
}
preparedStatements.clear();
await sqlite.close(db);
await vfs?.close();
db = undefined;
class _SQLiteWorker {
sqlite!: SQLiteAPI;
db: number | undefined = undefined;
vfs: IDBBatchAtomicVFS | AccessHandlePoolVFS | null = null;
initialized = false;
}
preparedStatements: Map<string, PreparedStatement> = new Map();
retryCounter: Record<string, number> = {};
constructor(
private readonly dbName: string,
private readonly encrypted: boolean
) {
console.log("new sqlite worker", dbName, encrypted);
}
async function exportDatabase(dbName: string, async: boolean) {
const vfs = await getVFS(dbName, async);
const stream = new ReadableStream(new DatabaseSource(vfs, dbName));
return transfer(stream, [stream]);
}
async open(async: boolean, url?: string) {
if (this.db) {
console.error("Database is already initialized", this.db);
return;
}
async function deleteDatabase(dbName: string, async: boolean) {
await close();
if (vfs) await vfs.delete();
else await (await getVFS(dbName, async)).delete();
}
const option = url ? { locateFile: () => url } : {};
const sqliteModule = async
? await import("./wa-sqlite-async").then(
({ default: SQLiteAsyncESMFactory }) => SQLiteAsyncESMFactory(option)
)
: await import("./wa-sqlite").then(({ default: SQLiteSyncESMFactory }) =>
SQLiteSyncESMFactory(option)
);
this.sqlite = Factory(sqliteModule);
this.vfs = await this.getVFS(this.dbName, async);
async function getVFS(dbName: string, async: boolean) {
const vfs = async
? await import("./IDBBatchAtomicVFS").then(
({ IDBBatchAtomicVFS }) =>
new IDBBatchAtomicVFS(dbName, { durability: "strict" })
)
: await import("./AccessHandlePoolVFS").then(
({ AccessHandlePoolVFS }) => new AccessHandlePoolVFS(dbName)
this.sqlite.vfs_register(this.vfs, false);
this.db = await this.sqlite.open_v2(
this.dbName,
undefined,
`multipleciphers-${this.vfs.name}`
);
}
/**
* Wrapper function for preparing SQL statements with caching
* to avoid unnecessary computations.
*/
async prepare(sql: string): Promise<PreparedStatement | undefined> {
if (!this.db) throw new Error("Database is not initialized.");
try {
const cached = this.preparedStatements.get(sql);
if (cached !== undefined) return cached;
const str = this.sqlite.str_new(this.db, sql);
const prepared = await this.sqlite.prepare_v2(
this.db,
this.sqlite.str_value(str)
);
if ("isReady" in vfs) await vfs.isReady;
return vfs;
if (!prepared) return;
const statement: PreparedStatement = {
stmt: prepared.stmt,
columns: this.sqlite.column_names(prepared.stmt)
};
this.preparedStatements.set(sql, statement);
this.sqlite.str_finish(str);
// reset retry count on success
this.retryCounter[sql] = 0;
return statement;
} catch (ex) {
console.error(ex);
// statement prepare process can be flaky so retry at least 5 times
// before giving up.
if (this.retryCounter[sql] < 5) {
this.retryCounter[sql] = (this.retryCounter[sql] || 0) + 1;
console.warn("Failed to prepare statement. Retrying:", sql);
return this.prepare(sql);
} else this.retryCounter[sql] = 0;
if (ex instanceof Error || ex instanceof SQLiteError)
ex.message += ` (query: ${sql})`;
throw ex;
}
}
async exec(sql: string, mode: RunMode, parameters?: SQLiteCompatibleType[]) {
const prepared = await this.prepare(sql);
if (!prepared) return [];
try {
if (parameters) this.sqlite.bind_collection(prepared.stmt, parameters);
// fast path for exec statements
if (mode === "exec") {
while ((await this.sqlite.step(prepared.stmt)) === SQLITE_ROW);
return [];
}
const rows: Record<string, SQLiteCompatibleType>[] = [];
while ((await this.sqlite.step(prepared.stmt)) === SQLITE_ROW) {
const row = this.sqlite.row(prepared.stmt);
const acc: Record<string, SQLiteCompatibleType> = {};
row.forEach((v, i) => (acc[prepared.columns[i]] = v));
rows.push(acc);
}
return rows;
} catch (e) {
if (e instanceof Error || e instanceof SQLiteError)
e.message += ` (query: ${sql})`;
throw e;
} finally {
await this.sqlite
.reset(prepared.stmt)
// we must clear/destruct the prepared statement if it can't be reset
.catch(() =>
this.sqlite
.finalize(prepared.stmt)
// ignore error (we will just prepare a new statement)
.catch(console.error)
.finally(() => this.preparedStatements.delete(sql))
);
}
}
async run<R>(
mode: RunMode,
sql: string,
parameters?: SQLiteCompatibleType[]
): Promise<QueryResult<R>> {
if (this.encrypted && !sql.startsWith("PRAGMA key")) {
await this.waitForDatabase();
}
if (!this.db) throw new Error("No database is not opened.");
const rows = (await this.exec(sql, mode, parameters)) as R[];
if (mode === "query") return { rows };
// initialize the database after it has been successfully decrypted.
// all queries prior to that must wait otherwise we get the
// "file is not a database" error
if (this.encrypted && sql.startsWith("PRAGMA key")) await this.initialize();
return {
insertId: BigInt(this.sqlite.last_insert_rowid(this.db)),
numAffectedRows: BigInt(this.sqlite.changes(this.db)),
rows: mode === "raw" ? rows : []
};
}
async close() {
if (!this.db) return;
for (const [_, prepared] of this.preparedStatements) {
await this.sqlite.finalize(prepared.stmt);
}
this.preparedStatements.clear();
await this.sqlite.close(this.db);
await this.vfs?.close();
this.db = undefined;
this.initialized = false;
}
async export(dbName: string, async: boolean) {
const vfs = await this.getVFS(dbName, async);
const stream = new ReadableStream(new DatabaseSource(vfs, dbName));
return transfer(stream, [stream]);
}
async delete(dbName: string, async: boolean) {
await this.close();
if (this.vfs) await this.vfs.delete();
else await (await this.getVFS(dbName, async)).delete();
}
async getVFS(dbName: string, async: boolean) {
const vfs = async
? await import("./IDBBatchAtomicVFS").then(
({ IDBBatchAtomicVFS }) =>
new IDBBatchAtomicVFS(dbName, { durability: "strict" })
)
: await import("./AccessHandlePoolVFS").then(
({ AccessHandlePoolVFS }) => new AccessHandlePoolVFS(dbName)
);
if ("isReady" in vfs) await vfs.isReady;
return vfs;
}
async initialize() {
self.dispatchEvent(
new MessageEvent("message", {
data: { type: "databaseInitialized", dbName: this.dbName }
})
);
console.log("Database initialized", this.db);
this.initialized = true;
}
async waitForDatabase() {
// if the database hasn't yet been initialized.
if (!this.initialized) {
console.log("Waiting for database to be initialized...", this.db);
return await new Promise<boolean>((resolve) =>
self.addEventListener("message", (ev) => {
if (
ev.data.type === "databaseInitialized" &&
ev.data.dbName === this.dbName
)
resolve(true);
})
);
}
return true;
}
}
async function initialize() {
self.dispatchEvent(
new MessageEvent("message", { data: { type: "databaseInitialized" } })
);
console.log("Database initialized", db);
initialized = true;
}
const worker = {
close,
open,
run: exec,
export: exportDatabase,
delete: deleteDatabase
};
export type SQLiteWorker = typeof worker;
export type SQLiteWorker = typeof _SQLiteWorker.prototype;
addEventListener("message", async (event) => {
if (!event.data.type) {
await worker.open(event.data.dbName, event.data.async, event.data.uri);
const worker = new _SQLiteWorker(event.data.dbName, event.data.encrypted);
await worker.open(event.data.async, event.data.uri);
const providerPort = createSharedServicePort(worker);
postMessage(null, [providerPort]);
self.addEventListener("beforeunload", () => worker.close());
}
});
async function waitForDatabase() {
// if the database hasn't yet been initialized.
if (!initialized) {
console.log("Waiting for database to be initialized...", db);
return await new Promise<boolean>((resolve) =>
self.addEventListener("message", (ev) => {
if (ev.data.type === "databaseInitialized") resolve(true);
})
);
}
return true;
}

View File

@@ -26,7 +26,12 @@ import SQLiteAsyncURI from "./wa-sqlite-async.wasm?url";
import { Mutex } from "async-mutex";
import { SharedService } from "./shared-service";
type Config = { dbName: string; async: boolean; init?: () => Promise<void> };
type Config = {
dbName: string;
async: boolean;
encrypted: boolean;
init?: () => Promise<void>;
};
const servicePool = new Map<
string,
@@ -55,7 +60,6 @@ export class WaSqliteWorkerDriver implements Driver {
if (closed) {
console.log("Already activated. Reinitializing...");
await service.proxy.open(
this.config.dbName,
this.config.async,
this.config.async ? SQLiteAsyncURI : SQLiteSyncURI
);
@@ -72,19 +76,24 @@ export class WaSqliteWorkerDriver implements Driver {
service.activate(
() =>
new Promise<MessagePort>((resolve) => {
new Promise<{ port: MessagePort; onclose: () => void }>((resolve) => {
console.log("initializing worker");
this.needsInitialization = true;
const worker = new Worker();
worker.addEventListener(
"message",
(event) => resolve(event.ports[0]),
(event) =>
resolve({
port: event.ports[0],
onclose: () => worker.terminate()
}),
{ once: true }
);
worker.postMessage({
dbName: this.config.dbName,
async: this.config.async,
encrypted: this.config.encrypted,
uri: this.config.async ? SQLiteAsyncURI : SQLiteSyncURI
});
}),

View File

@@ -307,7 +307,7 @@ function CalltoAction({ action, variant, sx, dismissAnnouncement }) {
break;
}
case "backup": {
await createBackup(true);
await createBackup();
break;
}
default: {

View File

@@ -331,12 +331,6 @@ async function resolveConflict({
toKeepDateEdited: number;
dateResolved?: number;
}) {
await db.notes.add({
id: note.id,
dateEdited: toKeepDateEdited,
conflicted: false
});
await db.content.add({
id: note.contentId,
data: toKeep,
@@ -345,12 +339,17 @@ async function resolveConflict({
sessionId: `${Date.now()}`
});
await db.notes.add({
id: note.id,
dateEdited: toKeepDateEdited,
conflicted: false
});
if (toCopy) {
await createCopy(note, toCopy);
}
await notesStore.refresh();
useEditorStore.getState().openSession(note.id, { force: true });
}
async function createCopy(note: Note, content: ContentItem) {

View File

@@ -152,7 +152,6 @@ export function EditorActionBar() {
activeSession &&
activeSession.type !== "new" &&
activeSession.type !== "locked" &&
activeSession.type !== "readonly" &&
activeSession.type !== "diff" &&
activeSession.type !== "conflicted",
onClick: editor?.startSearch

View File

@@ -271,7 +271,7 @@ function EditorView({
const isContent =
item.type === "tiptap" && item.noteId === session.note.id;
const isNote = item.type === "note" && item.id === session.note.id;
if (isContent && lastChangedTime.current < item.dateEdited) {
if (isContent && lastChangedTime.current < item.dateModified) {
if (!item.locked) return editor.updateContent(item.data);
const result = await db.vault

View File

@@ -344,7 +344,6 @@ function TipTap(props: TipTapProps) {
};
}, []);
if (readonly) return null;
return (
<>
<ScopedThemeProvider
@@ -418,7 +417,7 @@ function TiptapWrapper(
editorContainer={() => {
if (editorContainerRef.current) return editorContainerRef.current;
const editorContainer = document.createElement("div");
editorContainer.classList.add("selectable");
editorContainer.classList.add("selectable", "editor-container");
editorContainer.style.flex = "1";
editorContainer.style.cursor = "text";
editorContainer.style.color =
@@ -427,6 +426,7 @@ function TiptapWrapper(
editorContainer.style.fontSize = `${editorConfig.fontSize}px`;
editorContainer.style.fontFamily =
getFontById(editorConfig.fontFamily)?.font || "sans-serif";
editorContainer.tabIndex = -1;
editorContainerRef.current = editorContainer;
return editorContainer;
}}

View File

@@ -162,9 +162,10 @@ function getErrorHelp(props: FallbackProps) {
if (
errorText.includes("file is not a database") ||
errorText.includes("unsupported file format") ||
errorText.includes("database disk image is malformed") ||
errorText.includes("null function or function signature mismatch") ||
errorText.includes("malformed database schema")
errorText.includes("malformed database schema") ||
/table ".+?" already exists/.test(errorText) ||
errorText.includes("corrupted migrations:")
) {
return {
explanation: `This error usually means the database file is either corrupt or it could not be decrypted.`,
@@ -195,6 +196,17 @@ function getErrorHelp(props: FallbackProps) {
resetErrorBoundary();
}
};
} else if (errorText.includes("database disk image is malformed")) {
return {
explanation: `This error usually means the search index is corrupted.`,
action:
"This error can be fixed by rebuilding the search index. This action won't result in any kind of data loss.",
fix: async () => {
const { db } = await import("../../common/db");
await db.lookup.rebuild();
resetErrorBoundary();
}
};
}
}

View File

@@ -524,8 +524,6 @@ const menuItems: (
type: "button",
key: "duplicate",
title: "Duplicate",
//!isSynced ||
isDisabled: context?.locked,
icon: Duplicate.path,
onClick: () => store.get().duplicate(...ids),
multiSelect: true

View File

@@ -89,32 +89,7 @@ const features: Record<FeatureKeys, Feature> = {
)
}
]
: [
{
title: "Bi-directional note linking",
subtitle: "You can now link any 2 notes for easier referencing."
},
{
title: "Tabs",
subtitle:
"Open multiple notes side by side for faster multi-tasking."
},
{
title: "Nested notebooks",
subtitle:
"Create subnotebooks inside notebooks upto unlimited depth."
},
{
title: "At rest encryption",
subtitle:
"All your data will now be stored encrypted on your device to keep you safe even if your device gets compromised."
},
{
title: "App lock",
subtitle:
"App lock is now here on desktop/web apps! Put a lock on the app, and keep it safe from intruders."
}
],
: [],
cta: {
title: "Got it",
icon: Checkmark,

View File

@@ -233,6 +233,15 @@ class EditorStore extends BaseStore<EditorStore> {
if (noteId && session.id !== noteId && session.note.id !== noteId)
continue;
if (isDeleted(item) || isTrashItem(item)) clearIds.push(session.id);
// if a note becomes conflicted, reopen the session
else if (
session.type !== "conflicted" &&
item.type === "tiptap" &&
item.conflicted
)
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
// if a note is locked, reopen the session
else if (
session.type === "default" &&
@@ -257,9 +266,7 @@ class EditorStore extends BaseStore<EditorStore> {
}
// 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(session.note.id, { force: true, silent: true });
}
// if a readonly note is made editable, reopen the session
else if (
@@ -267,9 +274,7 @@ class EditorStore extends BaseStore<EditorStore> {
item.type === "note" &&
!item.readonly
)
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
openSession(session.note.id, { force: true, silent: true });
// update the note in all sessions
else if (item.type === "note") {
updateSession(
@@ -344,6 +349,10 @@ class EditorStore extends BaseStore<EditorStore> {
session.type !== "new" && session.note.contentId;
if (!contentId || !event.ids.includes(contentId)) continue;
if (
// if note's conflict is resolved
(session.type === "conflicted" && !event.item.conflicted) ||
// if note becomes conflicted
(session.type !== "conflicted" && event.item.conflicted) ||
// if note is locked
(session.type === "default" &&
!session.locked &&
@@ -548,12 +557,14 @@ class EditorStore extends BaseStore<EditorStore> {
const noteId = typeof noteOrId === "string" ? noteOrId : noteOrId.id;
const session = getSession(noteId);
if (session && !options.force && !session.needsHydration) {
return this.activateSession(noteId, options.activeBlockId);
}
if (session && !options.force) {
if (!session.needsHydration) {
return this.activateSession(noteId, options.activeBlockId);
}
if (session && (session.type === "diff" || session.type === "conflicted")) {
return openDiffSession(session.note.id, session.id);
if (session.type === "diff" || session.type === "conflicted") {
return openDiffSession(session.note.id, session.id);
}
}
if (session && session.id) await db.fs().cancel(session.id);
@@ -566,19 +577,7 @@ class EditorStore extends BaseStore<EditorStore> {
const isPreview = session ? session.preview : !options?.newSession;
const isLocked = await db.vaults.itemExists(note);
if (isLocked && note.type !== "trash") {
this.addSession(
{
type: "locked",
id: note.id,
pinned: session?.pinned,
note,
preview: isPreview,
activeBlockId: options.activeBlockId
},
!options.silent
);
} else if (note.conflicted) {
if (note.conflicted) {
const content = note.contentId
? await db.content.get(note.contentId)
: undefined;
@@ -612,6 +611,18 @@ class EditorStore extends BaseStore<EditorStore> {
},
!options.silent
);
} else if (isLocked && note.type !== "trash") {
this.addSession(
{
type: "locked",
id: note.id,
pinned: session?.pinned,
note,
preview: isPreview,
activeBlockId: options.activeBlockId
},
!options.silent
);
} else {
const content = note.contentId
? await db.content.get(note.contentId)

View File

@@ -22,15 +22,32 @@ import {
logger as _logger,
logManager
} from "@notesnook/core/dist/logger";
import { LogMessage } from "@notesnook/logger";
import { DatabasePersistence, NNStorage } from "../interfaces/storage";
import { LogMessage, NoopLogger, format } from "@notesnook/logger";
import { ZipFile, createZipStream } from "./streams/zip-stream";
import { createWriteStream } from "./stream-saver";
import { sanitizeFilename } from "@notesnook/common";
import { createDialect } from "../common/sqlite";
import { isFeatureSupported } from "./feature-check";
let logger: typeof _logger;
async function initializeLogger(persistence: DatabasePersistence = "db") {
initialize(new NNStorage("Logs", () => null, persistence), false);
let logger: typeof _logger = new NoopLogger();
async function initializeLogger() {
await initialize(
{
dialect: (name, init) => createDialect(name, false, init),
...(IS_DESKTOP_APP || isFeatureSupported("opfs")
? { journalMode: "WAL", lockingMode: "exclusive" }
: {
journalMode: "MEMORY",
lockingMode: "exclusive"
}),
tempStore: "memory",
synchronous: "normal",
pageSize: 8192,
cacheSize: -32000,
skipInitialization: !IS_DESKTOP_APP
},
false
);
logger = _logger.scope("notesnook-web");
}
@@ -47,11 +64,9 @@ async function downloadLogs() {
return;
}
controller.enqueue({
path: sanitizeFilename(log.key, { replacement: "-" }),
path: sanitizeFilename(log.key, { replacement: "-" }) + ".log",
data: textEncoder.encode(
(log.logs as LogMessage[])
.map((line) => JSON.stringify(line))
.join("\n")
(log.logs as LogMessage[]).map((line) => format(line)).join("\n")
)
});
}

View File

@@ -458,7 +458,7 @@ function BackupData(props: BaseRecoveryComponentProps<"backup">) {
"Please wait while we create a backup file for you to download."
}}
onSubmit={async () => {
await createBackup(true);
await createBackup({ rescueMode: true });
navigate("new");
}}
>

View File

@@ -56,6 +56,7 @@ export default defineConfig({
minify: "esbuild",
cssMinify: true,
emptyOutDir: true,
sourcemap: !isDesktop,
rollupOptions: {
output: {
plugins: [emitEditorStyles()],

View File

@@ -0,0 +1,3 @@
- Bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -0,0 +1,3 @@
- Bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -0,0 +1,3 @@
- Bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -0,0 +1,3 @@
- Bug fixes and performance improvements
Thank you for using Notesnook!

View File

@@ -28,7 +28,7 @@ import { login } from "./utils";
import { SqliteDialect } from "kysely";
import BetterSQLite3 from "better-sqlite3-multiple-ciphers";
const TEST_TIMEOUT = 30 * 1000;
const TEST_TIMEOUT = 60 * 1000;
test(
"case 1: device A & B should only download the changes from device C (no uploading)",
@@ -156,6 +156,111 @@ test(
TEST_TIMEOUT * 10
);
test(
"issue: syncing should not affect the items' dateModified",
async (t) => {
const [deviceA] = await Promise.all([initializeDevice("deviceA")]);
t.onTestFinished(async (r) => {
console.log(`${t.task.name} log out`);
await cleanup(deviceA);
});
const noteId = await deviceA.notes.add({
title: "Test note from device A",
content: { data: "<p>Hello</p>", type: "tiptap" }
});
const noteDateBefore = (await deviceA.notes.note(noteId)).dateModified;
const contentDateBefore = (await deviceA.content.findByNoteId(noteId))
.dateModified;
await deviceA.sync({ type: "full" });
const noteDateAfter = (await deviceA.notes.note(noteId)).dateModified;
const contentDateAfter = (await deviceA.content.findByNoteId(noteId))
.dateModified;
expect(noteDateBefore).toBe(noteDateAfter);
expect(contentDateBefore).toBe(contentDateAfter);
},
TEST_TIMEOUT
);
test(
"case 4: local content changed after remote content should create a conflict",
async (t) => {
const [deviceA, deviceB] = await Promise.all([
initializeDevice("deviceA"),
initializeDevice("deviceB")
]);
t.onTestFinished(async (r) => {
console.log(`${t.task.name} log out`);
await cleanup(deviceA, deviceB);
});
const noteId = await deviceA.notes.add({
title: "Test note from device A",
content: { data: "<p>Hello</p>", type: "tiptap" }
});
await deviceA.sync({ type: "full" });
await deviceB.sync({ type: "full" });
await deviceB.notes.add({
id: noteId,
content: { data: "<p>Hello (I am from device B)</p>", type: "tiptap" }
});
await deviceB.sync({ type: "full" });
await new Promise((resolve) => setTimeout(resolve, 10000));
await deviceA.notes.add({
id: noteId,
content: { data: "<p>Hello (I am from device A)</p>", type: "tiptap" }
});
await deviceA.sync({ type: "full" });
expect(await deviceA.notes.conflicted.count()).toBeGreaterThan(0);
},
TEST_TIMEOUT * 10
);
test(
"case 5: remote content changed after local content should create a conflict",
async (t) => {
const [deviceA, deviceB] = await Promise.all([
initializeDevice("deviceA"),
initializeDevice("deviceB")
]);
t.onTestFinished(async (r) => {
console.log(`${t.task.name} log out`);
await cleanup(deviceA, deviceB);
});
const noteId = await deviceA.notes.add({
title: "Test note from device A",
content: { data: "<p>Hello</p>", type: "tiptap" }
});
await deviceA.sync({ type: "full" });
await deviceB.sync({ type: "full" });
await deviceA.notes.add({
id: noteId,
content: { data: "<p>Hello (I am from device B)</p>", type: "tiptap" }
});
await deviceA.sync({ type: "full" });
await new Promise((resolve) => setTimeout(resolve, 10000));
await deviceB.notes.add({
id: noteId,
content: { data: "<p>Hello (I am from device A)</p>", type: "tiptap" }
});
await deviceB.sync({ type: "full" });
expect(await deviceB.notes.conflicted.count()).toBeGreaterThan(0);
},
TEST_TIMEOUT * 10
);
// test(
// "case 4: Device A's sync is interrupted halfway and Device B makes some changes afterwards and syncs.",
// async () => {
@@ -378,6 +483,8 @@ test(
* @returns {Promise<Database>}
*/
async function initializeDevice(id, capabilities = []) {
// initialize(new NodeStorageInterface(), false);
console.time(`Init ${id}`);
EV.subscribe(EVENTS.userCheckStatus, async (type) => {
return {

View File

@@ -63,6 +63,7 @@ import { Settings } from "../collections/settings";
import {
DatabaseAccessor,
DatabaseSchema,
RawDatabaseSchema,
SQLiteOptions,
changeDatabasePassword,
createDatabase,
@@ -74,7 +75,8 @@ import { Vaults } from "../collections/vaults";
import { KVStorage } from "../database/kv";
import { QueueValue } from "../utils/queue-value";
import { Sanitizer } from "../database/sanitizer";
import { dropTriggers } from "../database/triggers";
import { createTriggers, dropTriggers } from "../database/triggers";
import { NNMigrationProvider } from "../database/migrations";
type EventSourceConstructor = new (
uri: string,
@@ -179,7 +181,7 @@ class Database {
subscriptions = new Subscriptions(this.tokenManager);
offers = new Offers();
debug = new Debug();
pricing = new Pricing();
pricing = Pricing;
user = new UserManager(this);
syncer = new Sync(this);
@@ -244,7 +246,10 @@ class Database {
await sql.raw(statement).execute(this.sql());
}
await initializeDatabase(this.sql().withTables());
await initializeDatabase(
this.sql().withTables(),
new NNMigrationProvider()
);
await this.initCollections();
return true;
}
@@ -274,10 +279,11 @@ class Database {
this.disconnectSSE();
});
this._sql = (await createDatabase(
"notesnook",
this.options.sqliteOptions
)) as unknown as Kysely<DatabaseSchema>;
this._sql = (await createDatabase<RawDatabaseSchema>("notesnook", {
...this.options.sqliteOptions,
migrationProvider: new NNMigrationProvider(),
onInit: (db) => createTriggers(db)
})) as unknown as Kysely<DatabaseSchema>;
await this.sanitizer.init();

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "../utils/http";
type Product = {
export type Product = {
country: string;
countryCode: string;
sku?: string;

View File

@@ -123,7 +123,7 @@ describe.concurrent("merge content", (test) => {
await loginFakeUser(db);
const merger = new Merger(db);
const merged = await merger.mergeContent(
const merged = merger.mergeContent(
{
type: "tiptap",
data: "Remote",
@@ -132,6 +132,7 @@ describe.concurrent("merge content", (test) => {
{
type: "tiptap",
data: "Local",
synced: true,
dateEdited: Date.now() - 1000
}
);
@@ -140,13 +141,13 @@ describe.concurrent("merge content", (test) => {
expect(merged.data).toBe("Remote");
}));
test("trigger conflict if local item dateEdited is newer", () =>
test("trigger conflict if local item is unsynced", () =>
databaseTest().then(async (db) => {
await loginFakeUser(db);
const merger = new Merger(db);
const noteId = await db.notes.add(TEST_NOTE);
const merged = await merger.mergeContent(
const merged = merger.mergeContent(
{
type: "tiptap",
data: "Remote",
@@ -157,7 +158,7 @@ describe.concurrent("merge content", (test) => {
type: "tiptap",
data: "Local",
noteId,
synced: false,
dateEdited: Date.now()
}
);
@@ -166,7 +167,6 @@ describe.concurrent("merge content", (test) => {
expect(merged.data).toBe("Local");
expect(merged.conflicted).toBeDefined();
expect(merged.conflicted.data).toBe("Remote");
expect(await db.notes.conflicted.has(noteId)).toBe(true);
}));
test("merge conflicts if local item is already conflicted", () =>

View File

@@ -57,37 +57,34 @@ class Collector {
const collection = this.db[collectionKey].collection;
let pushTimestamp = Date.now();
for await (const chunk of collection.unsynced(chunkSize, isForceSync)) {
const items = await this.prepareChunk(chunk, key);
const { ids, items: syncableItems } = filterSyncableItems(chunk);
if (!ids.length) continue;
const ciphers = await this.db
.storage()
.encryptMulti(key, syncableItems);
const items = toPushItem(ids, ciphers);
if (!items) continue;
yield { items, type: itemType };
await collection.update(
chunk.map((i) => i.id),
{ synced: true },
{
sendEvent: false,
// EDGE CASE:
// Sometimes an item can get updated while it's being pushed.
// The result is that its `synced` property becomes true even
// though it's modification wasn't yet synced.
// In order to prevent that, we only set the `synced` property
// to true for items that haven't been modified since we last ran
// the push. Everything else will be collected again in the next
// push.
condition: (eb) => eb("dateModified", "<=", pushTimestamp)
}
);
await this.db
.sql()
.updateTable(collection.type)
.where("id", "in", ids)
// EDGE CASE:
// Sometimes an item can get updated while it's being pushed.
// The result is that its `synced` property becomes true even
// though it's modification wasn't yet synced.
// In order to prevent that, we only set the `synced` property
// to true for items that haven't been modified since we last ran
// the push. Everything else will be collected again in the next
// push.
.where("dateModified", "<=", pushTimestamp)
.set({ synced: true })
.execute();
pushTimestamp = Date.now();
}
}
}
async prepareChunk(chunk: MaybeDeletedItem<Item>[], key: SerializedKey) {
const { ids, items } = filterSyncableItems(chunk);
if (!ids.length) return;
const ciphers = await this.db.storage().encryptMulti(key, items);
return toPushItem(ids, ciphers);
}
}
export default Collector;
@@ -113,9 +110,6 @@ function filterSyncableItems(items: MaybeDeletedItem<Item>[]): {
const ids = [];
const syncableItems = [];
for (const item of items) {
// do not sync conflicted note or content
if ("conflicted" in item && item.conflicted) continue;
delete item.synced;
ids.push(item.id);

View File

@@ -242,6 +242,20 @@ class Sync {
this.connection?.off("SendItems");
this.connection?.off("SendVaultKey");
await this.db
.sql()
.updateTable("notes")
.where("id", "in", (eb) =>
eb
.selectFrom("content")
.select("noteId as id")
.where("conflicted", "is not", null)
.where("conflicted", "!=", false)
.$castTo<string | null>()
)
.set({ conflicted: true })
.execute();
}
async send(deviceId: string, isForceSync?: boolean) {
@@ -334,10 +348,8 @@ class Sync {
const localItems = await collection.records(chunk.items.map((i) => i.id));
let items: (MaybeDeletedItem<Item> | undefined)[] = [];
if (itemType === "content") {
items = await Promise.all(
deserialized.map((item) =>
this.merger.mergeContent(item, localItems[item.id])
)
items = deserialized.map((item) =>
this.merger.mergeContent(item, localItems[item.id])
);
} else {
items =

View File

@@ -46,7 +46,7 @@ class Merger {
}
}
async mergeContent(
mergeContent(
remoteItem: MaybeDeletedItem<Item>,
localItem: MaybeDeletedItem<Item> | undefined
) {
@@ -75,10 +75,7 @@ class Merger {
else if (!conflicted) return;
// otherwise we trigger the conflicts
await this.db.notes.add({
id: localItem.noteId,
conflicted: true
});
this.logger.info("conflict marked", { id: localItem.noteId });
localItem.conflicted = remoteItem;
return localItem;
}
@@ -124,9 +121,8 @@ export function isContentConflicted(
remoteItem.dateModified &&
localItem.dateResolved === remoteItem.dateModified;
const isEdited =
// the local item is edited if it was changed/edited after the remote
// note and it also wasn't synced yet.
localItem.dateEdited > remoteItem.dateEdited && !localItem.synced;
// the local item is edited if it wasn't synced yet.
!localItem.synced;
if (isEdited && !isResolved) {
// If time difference between local item's edits & remote item's edits
// is less than threshold, we shouldn't trigger a merge conflict; instead

View File

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

View File

@@ -100,9 +100,6 @@ export class Attachments implements ICollection {
async init() {
await this.collection.init();
logger.debug("attachments initialized", {
total: await this.collection.count()
});
}
async add(

View File

@@ -175,10 +175,7 @@ export class Content implements ICollection {
const content = await this.collection.get(id);
if (!content || isDeleted(content)) return;
if (!content.locked && this.preProcess(content)) {
await this.db.content.add({
...content,
sessionId: `${Date.now()}`
});
await this.collection.update([content.id], content, { modify: false });
}
return content;
}
@@ -246,10 +243,7 @@ export class Content implements ICollection {
.executeTakeFirst()) as ContentItem;
if (!content || isDeleted(content)) return;
if (!content.locked && this.preProcess(content)) {
await this.db.content.add({
...content,
sessionId: `${Date.now()}`
});
await this.collection.update([content.id], content, { modify: false });
}
return content;
}

View File

@@ -318,17 +318,9 @@ export class Notes implements ICollection {
const content = note.contentId
? await this.db.content.get(note.contentId)
: undefined;
if (content && (isDeleted(content) || content.locked))
throw new Error("Cannot duplicate a locked or deleted note.");
const duplicateId = await this.db.notes.add({
...clone(note),
id: undefined,
content: content
? {
type: content.type,
data: content.data
}
: undefined,
readonly: false,
favorite: false,
pinned: false,
@@ -338,19 +330,38 @@ export class Notes implements ICollection {
dateCreated: undefined,
dateModified: undefined
});
if (!duplicateId) continue;
for (const relation of await this.db.relations
.to(note, "notebook")
.get()) {
const contentId = await this.db.content.add({
...clone(content),
id: undefined,
noteId: duplicateId,
dateResolved: undefined,
dateEdited: undefined,
dateCreated: undefined,
dateModified: undefined
});
await this.db.notes.add({ id: duplicateId, contentId });
for (const relation of await this.db.relations.to(note).get()) {
await this.db.relations.add(
{ type: "notebook", id: relation.fromId },
{ type: relation.fromType, id: relation.fromId },
{
id: duplicateId,
type: "note"
}
);
}
for (const relation of await this.db.relations.from(note).get()) {
await this.db.relations.add(
{
id: duplicateId,
type: "note"
},
{ type: relation.toType, id: relation.toId }
);
}
}
}

View File

@@ -63,6 +63,9 @@ export class Relations implements ICollection {
});
}
from(
reference: ItemReference | ItemReferences
): RelationsArray<keyof RelatableTable>;
from(
reference: ItemReference | ItemReferences,
types: (keyof RelatableTable)[]
@@ -73,16 +76,19 @@ export class Relations implements ICollection {
): RelationsArray<TType>;
from<TType extends keyof RelatableTable = keyof RelatableTable>(
reference: ItemReference | ItemReferences,
type: TType | keyof RelatableTable[]
type?: TType | keyof RelatableTable[]
) {
return new RelationsArray(
this.db,
reference,
Array.isArray(type) ? type : [type],
type ? (Array.isArray(type) ? type : ([type] as TType[])) : undefined,
"from"
);
}
to(
reference: ItemReference | ItemReferences
): RelationsArray<keyof RelatableTable>;
to(
reference: ItemReference | ItemReferences,
types: (keyof RelatableTable)[]
@@ -93,12 +99,12 @@ export class Relations implements ICollection {
): RelationsArray<TType>;
to<TType extends keyof RelatableTable = keyof RelatableTable>(
reference: ItemReference | ItemReferences,
type: TType | keyof RelatableTable[]
type?: TType | keyof RelatableTable[]
) {
return new RelationsArray(
this.db,
reference,
Array.isArray(type) ? type : [type],
type ? (Array.isArray(type) ? type : ([type] as TType[])) : undefined,
"to"
);
}
@@ -224,21 +230,26 @@ const TABLE_MAP = {
type RelatableTable = typeof TABLE_MAP;
class RelationsArray<TType extends keyof RelatableTable> {
private table: ValueOf<RelatableTable> = TABLE_MAP[this.types[0]];
constructor(
private readonly db: Database,
private readonly reference: ItemReference | ItemReferences,
private readonly types: TType[],
private readonly types: TType[] | undefined,
private readonly direction: "from" | "to"
) {}
get selector() {
if (!this.types)
throw new Error("Cannot use selector when no tables are specified.");
if (this.types.length > 1)
throw new Error(
"Cannot use selector when more than 1 tables are specified."
);
const table: ValueOf<RelatableTable> = TABLE_MAP[this.types[0]];
return new FilteredSelector<ItemMap[TType]>(
this.table,
table,
this.db
.sql()
.selectFrom<keyof DatabaseSchema>(this.table)
.selectFrom<keyof DatabaseSchema>(table)
.where("id", "in", (b) =>
b
.selectFrom("relations")
@@ -356,10 +367,12 @@ class RelationsArray<TType extends keyof RelatableTable> {
) => {
if (this.direction === "to") {
return builder
.where(
"fromType",
this.types.length > 1 ? "in" : "==",
this.types.length > 1 ? this.types : this.types[0]
.$if(!!this.types, (eb) =>
eb.where(
"fromType",
this.types!.length > 1 ? "in" : "==",
this.types!.length > 1 ? this.types : this.types![0]
)
)
.where("toType", "==", this.reference.type)
.where(
@@ -370,12 +383,12 @@ class RelationsArray<TType extends keyof RelatableTable> {
: this.reference.id
)
.$if(
this.types.includes("note" as TType) &&
!!this.types?.includes("note" as TType) &&
this.db.trash.cache.notes.length > 0,
(b) => b.where("fromId", "not in", this.db.trash.cache.notes)
)
.$if(
this.types.includes("notebook" as TType) &&
!!this.types?.includes("notebook" as TType) &&
this.db.trash.cache.notebooks.length > 0,
(b) => b.where("fromId", "not in", this.db.trash.cache.notebooks)
)
@@ -383,10 +396,12 @@ class RelationsArray<TType extends keyof RelatableTable> {
.$narrowType<{ id: string }>();
} else {
return builder
.where(
"toType",
this.types.length > 1 ? "in" : "==",
this.types.length > 1 ? this.types : this.types[0]
.$if(!!this.types, (eb) =>
eb.where(
"toType",
this.types!.length > 1 ? "in" : "==",
this.types!.length > 1 ? this.types : this.types![0]
)
)
.where("fromType", "==", this.reference.type)
.where(
@@ -397,12 +412,12 @@ class RelationsArray<TType extends keyof RelatableTable> {
: this.reference.id
)
.$if(
this.types.includes("note" as TType) &&
!!this.types?.includes("note" as TType) &&
this.db.trash.cache.notes.length > 0,
(b) => b.where("toId", "not in", this.db.trash.cache.notes)
)
.$if(
this.types.includes("notebook" as TType) &&
!!this.types?.includes("notebook" as TType) &&
this.db.trash.cache.notebooks.length > 0,
(b) => b.where("toId", "not in", this.db.trash.cache.notebooks)
)

View File

@@ -184,7 +184,7 @@ export class Tiptap {
this.data = new HTMLRewriter({
ontag: (name, attr) => {
const href = attr[ATTRIBUTES.href];
if (name === "a" && href.startsWith("nn://")) {
if (name === "a" && href && href.startsWith("nn://")) {
const link = resolve(href);
if (!link) return;
attr[ATTRIBUTES.href] = link;
@@ -244,7 +244,10 @@ export class Tiptap {
if (types.includes("internalLinks")) {
result.internalLinks.push(
...findAll(
(e) => e.tagName === "a" && e.attribs.href.startsWith("nn://"),
(e) =>
e.tagName === "a" &&
!!e.attribs.href &&
e.attribs.href.startsWith("nn://"),
document.childNodes
)
.map((e) => parseInternalLink(e.attribs.href))

View File

@@ -34,7 +34,8 @@ import {
ColumnType,
ExpressionBuilder,
ReferenceExpression,
Dialect
Dialect,
MigrationProvider
} from "kysely";
import {
Attachment,
@@ -58,8 +59,6 @@ import {
Vault,
isDeleted
} from "../types";
import { NNMigrationProvider } from "./migrations";
import { createTriggers } from "./triggers";
import { logger } from "../logger";
// type FilteredKeys<T, U> = {
@@ -160,6 +159,7 @@ type AsyncOrSyncResult<Async extends boolean, Response> = Async extends true
: Response;
export interface DatabaseCollection<T, IsAsync extends boolean> {
type: keyof DatabaseSchema;
clear(): Promise<void>;
init(): Promise<void>;
upsert(item: T): Promise<void>;
@@ -257,8 +257,8 @@ const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
}
};
async function setupDatabase(
db: Kysely<RawDatabaseSchema>,
async function setupDatabase<Schema>(
db: Kysely<Schema>,
options: SQLiteOptions
) {
if (options.password)
@@ -294,11 +294,14 @@ async function setupDatabase(
);
}
export async function initializeDatabase(db: Kysely<RawDatabaseSchema>) {
export async function initializeDatabase<Schema>(
db: Kysely<Schema>,
migrationProvider: MigrationProvider
) {
try {
const migrator = new Migrator({
db,
provider: new NNMigrationProvider()
provider: migrationProvider
});
const { error, results } = await migrator.migrateToLatest();
@@ -313,8 +316,6 @@ export async function initializeDatabase(db: Kysely<RawDatabaseSchema>) {
.join(", ")}`
);
await createTriggers(db);
return db;
} catch (e) {
logger.error(e, "Failed to initialized database.");
@@ -335,8 +336,14 @@ export type SQLiteOptions = {
skipInitialization?: boolean;
};
export async function createDatabase(name: string, options: SQLiteOptions) {
const db = new Kysely<RawDatabaseSchema>({
export async function createDatabase<Schema>(
name: string,
options: SQLiteOptions & {
migrationProvider: MigrationProvider;
onInit?: (db: Kysely<Schema>) => Promise<void>;
}
) {
const db = new Kysely<Schema>({
// log: (event) => {
// if (event.queryDurationMillis > 5)
// console.warn(event.query.sql, event.queryDurationMillis);
@@ -344,7 +351,8 @@ export async function createDatabase(name: string, options: SQLiteOptions) {
dialect: options.dialect(name, async () => {
await db.connection().execute(async (db) => {
await setupDatabase(db, options);
await initializeDatabase(db);
await initializeDatabase(db, options.migrationProvider);
if (options.onInit) await options.onInit(db);
});
}),
plugins: [new SqliteBooleanPlugin()]
@@ -352,7 +360,8 @@ export async function createDatabase(name: string, options: SQLiteOptions) {
if (!options.skipInitialization)
await db.connection().execute(async (db) => {
await setupDatabase(db, options);
await initializeDatabase(db);
await initializeDatabase(db, options.migrationProvider);
if (options.onInit) await options.onInit(db);
});
return db;

View File

@@ -289,6 +289,26 @@ export class NNMigrationProvider implements MigrationProvider {
async up(db) {
await rebuildSearchIndex(db);
}
},
"3": {
async up(db) {
await db
.updateTable("notes")
.where("id", "in", (eb) =>
eb
.selectFrom("content")
.select("noteId as id")
.where((eb) =>
eb.or([
eb("conflicted", "is", null),
eb("conflicted", "==", false)
])
)
.$castTo<string | null>()
)
.set({ conflicted: false })
.execute();
}
}
};
}

View File

@@ -38,7 +38,7 @@ export class SQLCachedCollection<
startTransaction: (
executor: (tr: Kysely<DatabaseSchema>) => Promise<void>
) => Promise<void>,
type: TCollectionType,
public type: TCollectionType,
eventManager: EventManager,
sanitizer: Sanitizer
) {

View File

@@ -75,7 +75,7 @@ export class SQLCollection<
_startTransaction: (
executor: (tr: Kysely<DatabaseSchema>) => Promise<void>
) => Promise<void>,
private readonly type: TCollectionType,
public readonly type: TCollectionType,
private readonly eventManager: EventManager,
private readonly sanitizer: Sanitizer
) {}
@@ -227,14 +227,16 @@ export class SQLCollection<
ids: string[],
partial: Partial<SQLiteItem<T>>,
options: {
sendEvent: boolean;
sendEvent?: boolean;
modify?: boolean;
condition?: ExpressionOrFactory<
DatabaseSchema,
keyof DatabaseSchema,
SqlBool
>;
} = { sendEvent: true }
} = {}
) {
const { sendEvent = true, modify = true, condition } = options;
if (!this.sanitizer.sanitize(this.type, partial)) return;
await this.db()
@@ -244,16 +246,16 @@ export class SQLCollection<
await tx
.updateTable<keyof DatabaseSchema>(this.type)
.where("id", "in", chunk)
.$if(!!options.condition, (eb) => eb.where(options.condition!))
.$if(!!condition, (eb) => eb.where(condition!))
.set({
...partial,
dateModified: Date.now(),
dateModified: modify ? Date.now() : undefined,
synced: partial.synced || false
})
.execute();
}
});
if (options.sendEvent) {
if (sendEvent) {
this.eventManager.publish(EVENTS.databaseUpdated, <UpdateEvent>{
type: "update",
collection: this.type,
@@ -284,6 +286,12 @@ export class SQLCollection<
.selectFrom<keyof DatabaseSchema>(this.type)
.select((a) => a.fn.count<number>("id").as("count"))
.where(isFalse("synced"))
.$if(this.type === "content", (eb) =>
eb.where("conflicted", "is", null)
)
.$if(this.type === "notes", (eb) =>
eb.where("conflicted", "is not", true)
)
.$if(this.type === "attachments", (eb) =>
eb.where((eb) =>
eb.or([eb("dateUploaded", ">", 0), eb("deleted", "==", true)])
@@ -304,6 +312,12 @@ export class SQLCollection<
.selectAll()
.$if(lastRowId != null, (qb) => qb.where("id", ">", lastRowId!))
.$if(!forceSync, (eb) => eb.where(isFalse("synced")))
.$if(this.type === "content", (eb) =>
eb.where("conflicted", "is", null)
)
.$if(this.type === "notes", (eb) =>
eb.where("conflicted", "is not", true)
)
.$if(this.type === "attachments", (eb) =>
eb.where((eb) =>
eb.or([eb("dateUploaded", ">", 0), eb("deleted", "==", true)])

View File

@@ -28,7 +28,9 @@ import {
format,
ILogger
} from "@notesnook/logger";
import { IStorage } from "./interfaces";
import { Kysely, Migration, MigrationProvider } from "kysely";
import { SQLiteOptions, createDatabase } from "./database";
import { toChunks } from "./utils/array";
const WEEK = 86400000 * 7;
@@ -38,10 +40,46 @@ const WEEK = 86400000 * 7;
// 3. Keep 7 days of logs
// 4. Implement functions for log retrieval & filtering
type SQLiteItem<T> = {
[P in keyof T]?: T[P] | null;
};
type LogMessageWithDate = LogMessage & { date: string };
export type LogDatabaseSchema = {
logs: SQLiteItem<LogMessageWithDate>;
};
class NNLogsMigrationProvider implements MigrationProvider {
async getMigrations(): Promise<Record<string, Migration>> {
return {
"1": {
async up(db) {
await db.schema
.createTable("logs")
.addColumn("timestamp", "integer", (c) => c.notNull())
.addColumn("message", "text", (c) => c.notNull())
.addColumn("level", "integer", (c) => c.notNull())
.addColumn("date", "text")
.addColumn("scope", "text")
.addColumn("extras", "text")
.addColumn("elapsed", "integer")
.execute();
await db.schema
.createIndex("log_timestamp_index")
.on("logs")
.column("timestamp")
.execute();
}
}
};
}
}
class DatabaseLogReporter {
writer: DatabaseLogWriter;
constructor(storage: IStorage) {
this.writer = new DatabaseLogWriter(storage);
constructor(db: Kysely<LogDatabaseSchema>) {
this.writer = new DatabaseLogWriter(db);
}
write(log: LogMessage) {
@@ -50,10 +88,10 @@ class DatabaseLogReporter {
}
class DatabaseLogWriter {
private queue: Map<string, LogMessage> = new Map();
private queue: LogMessageWithDate[] = [];
private hasCleared = false;
constructor(private readonly storage: IStorage) {
constructor(private readonly db: Kysely<LogDatabaseSchema>) {
setInterval(() => {
setTimeout(() => {
if (!this.hasCleared) {
@@ -66,86 +104,86 @@ class DatabaseLogWriter {
}
push(message: LogMessage) {
const key = new Date(message.timestamp).toLocaleDateString();
this.queue.set(`${key}:${message.timestamp}`, message);
const date = new Date(message.timestamp);
(message as LogMessageWithDate).date = `${date.getFullYear()}-${
date.getMonth() + 1
}-${date.getDate()}`;
this.queue.push(message as LogMessageWithDate);
}
async flush() {
if (this.queue.size === 0) return;
const queueCopy = Array.from(this.queue.entries());
this.queue = new Map();
await this.storage.writeMulti(queueCopy);
if (this.queue.length === 0) return;
const queueCopy = this.queue.slice();
this.queue = [];
for (const chunk of toChunks(queueCopy, 1000)) {
await this.db.insertInto("logs").values(chunk).execute();
}
}
async rotate() {
const logKeys = (await this.storage.getAllKeys()).sort();
const keysToRemove = [];
for (const key of logKeys) {
const keyParts = key.split(":");
if (keyParts.length === 1 || parseInt(keyParts[1]) < Date.now() - WEEK) {
keysToRemove.push(key);
}
}
if (keysToRemove.length) await this.storage.removeMulti(keysToRemove);
const range = Date.now() - WEEK;
await this.db.deleteFrom("logs").where("timestamp", "<", range).execute();
}
}
class DatabaseLogManager {
constructor(private readonly storage: IStorage) {}
constructor(private readonly db: Kysely<LogDatabaseSchema>) {}
async get() {
const logKeys = await this.storage.getAllKeys();
const logEntries = await this.storage.readMulti<LogMessage>(logKeys);
const logs: Record<string, LogMessage[]> = {};
const logs = await this.db
.selectFrom("logs")
.select([
"timestamp",
"message",
"level",
"scope",
"extras",
"elapsed",
"date"
])
.execute();
const groupedLogs: Record<string, LogMessage[]> = {};
for (const [logKey, log] of logEntries) {
const keyParts = logKey.split(":");
if (keyParts.length === 1) continue;
const key = keyParts[0];
if (!logs[key]) logs[key] = [];
logs[key].push(log);
for (const log of logs) {
const key = log.date!;
if (!groupedLogs[key]) groupedLogs[key] = [];
groupedLogs[key].push(log as LogMessage);
}
return Object.keys(logs)
return Object.keys(groupedLogs)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.map((key) => ({
key,
logs: logs[key]?.sort((a, b) => a.timestamp - b.timestamp)
logs: groupedLogs[key]?.sort((a, b) => a.timestamp - b.timestamp)
}));
}
async clear() {
const logKeys = await this.storage.getAllKeys();
await this.storage.removeMulti(logKeys);
await this.db.deleteFrom("logs").execute();
}
async delete(key: string) {
const logKeys = await this.storage.getAllKeys();
const keysToRemove = [];
for (const logKey of logKeys) {
const keyParts = logKey.split(":");
if (keyParts.length === 1) continue;
const currKey = keyParts[0];
if (currKey === key) keysToRemove.push(logKey);
}
if (keysToRemove.length) await this.storage.removeMulti(keysToRemove);
await this.db.deleteFrom("logs").where("date", "==", key).execute();
}
}
function initialize(storage: IStorage, disableConsoleLogs?: boolean) {
if (storage) {
const reporters: ILogReporter[] = [new DatabaseLogReporter(storage)];
if (process.env.NODE_ENV !== "production" && !disableConsoleLogs)
reporters.push(consoleReporter);
logger = new Logger({
reporter: combineReporters(reporters),
lastTime: Date.now()
});
logManager = new DatabaseLogManager(storage);
}
async function initialize(
options: SQLiteOptions,
disableConsoleLogs?: boolean
) {
const db = await createDatabase<LogDatabaseSchema>("notesnook-logs", {
...options,
migrationProvider: new NNLogsMigrationProvider()
});
const reporters: ILogReporter[] = [new DatabaseLogReporter(db)];
if (process.env.NODE_ENV !== "production" && !disableConsoleLogs)
reporters.push(consoleReporter);
logger = new Logger({
reporter: combineReporters(reporters),
lastTime: Date.now()
});
logManager = new DatabaseLogManager(db);
}
let logger: ILogger = new NoopLogger();

View File

@@ -66,7 +66,7 @@ export function parseInternalLink(link: string): InternalLink | undefined {
}
export function isInternalLink(link: string) {
return link.startsWith("nn://");
return link && link.startsWith("nn://");
}
function isValidInternalType(type: string): type is InternalLinkType {

View File

@@ -4376,7 +4376,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 +4400,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 +4435,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",
@@ -9736,7 +9736,7 @@
"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"
@@ -17822,20 +17822,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

@@ -19,7 +19,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Extension } from "@tiptap/core";
import { Decoration, DecorationSet } from "prosemirror-view";
import { EditorState, Plugin, PluginKey, Transaction } from "prosemirror-state";
import {
EditorState,
Plugin,
PluginKey,
Transaction,
TextSelection
} from "prosemirror-state";
import { SearchSettings } from "../../toolbar/stores/search-store";
type DispatchFn = (tr: Transaction) => void;
@@ -274,44 +280,58 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
},
moveToNextResult:
() =>
({ state, dispatch, commands }) => {
({ state, dispatch }) => {
const { selectedIndex, results } = this.storage;
if (!results || results.length <= 0) return false;
let nextIndex = selectedIndex + 1;
if (isNaN(nextIndex) || nextIndex >= results.length) nextIndex = 0;
const { tr } = state;
const { from, to } = results[nextIndex];
commands.setTextSelection({ from, to });
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(from),
tr.mapping.map(to)
)
);
scrollIntoView();
this.storage.selectedIndex = nextIndex;
state.tr.setMeta("selectedIndex", nextIndex);
tr.setMeta("selectedIndex", nextIndex);
if (dispatch) updateView(state, dispatch);
return true;
},
moveToPreviousResult:
() =>
({ state, dispatch, commands }) => {
({ state, dispatch }) => {
const { selectedIndex, results } = this.storage;
if (!results || results.length <= 0) return false;
let prevIndex = selectedIndex - 1;
if (isNaN(prevIndex) || prevIndex < 0) prevIndex = results.length - 1;
const { tr } = state;
const { from, to } = results[prevIndex];
commands.setTextSelection({ from, to });
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(from),
tr.mapping.map(to)
)
);
scrollIntoView();
this.storage.selectedIndex = prevIndex;
state.tr.setMeta("selectedIndex", prevIndex);
tr.setMeta("selectedIndex", prevIndex);
if (dispatch) updateView(state, dispatch);
return true;
},
replace:
(term) =>
({ commands, tr, dispatch }) => {
({ chain, tr, dispatch }) => {
const { selectedIndex, results } = this.storage;
if (!dispatch || !results || results.length <= 0) return false;
@@ -320,9 +340,7 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
const { from, to } = results[index];
tr.insertText(term, from, to);
dispatch(tr);
commands.moveToNextResult();
return true;
return chain().moveToNextResult().run();
},
replaceAll:
(term) =>

View File

@@ -21,7 +21,7 @@ import { useLayoutEffect } from "react";
import { FloatingMenuProps } from "./types";
import { SearchReplacePopup } from "../popups/search-replace";
import { ResponsivePresenter } from "../../components/responsive";
import { getEditorContainer, getToolbarElement } from "../utils/dom";
import { getToolbarElement } from "../utils/dom";
import { useEditorSearchStore } from "../stores/search-store";
export function SearchReplaceFloatingMenu(props: FloatingMenuProps) {
@@ -41,11 +41,11 @@ export function SearchReplaceFloatingMenu(props: FloatingMenuProps) {
isOpen={isSearching}
onClose={() => editor.commands.endSearch()}
position={{
target: editor.isEditable ? getToolbarElement() : getEditorContainer(),
target: getToolbarElement(),
isTargetAbsolute: true,
location: editor.isEditable ? "below" : "top",
location: "below",
align: "end",
yOffset: editor.isEditable ? 5 : -50
yOffset: 5
}}
blocking={false}
focusOnRender={false}

View File

@@ -61,7 +61,9 @@ export function Toolbar(props: ToolbarProps) {
? editor.isEditable
? [...MOBILE_STATIC_TOOLBAR_GROUPS, ...tools]
: READONLY_MOBILE_STATIC_TOOLBAR_GROUPS
: [...STATIC_TOOLBAR_GROUPS, ...tools],
: editor.isEditable
? [...STATIC_TOOLBAR_GROUPS, ...tools]
: [],
[tools, editor.isEditable, isMobile]
);

View File

@@ -301,5 +301,5 @@ function LinkTool(props: LinkToolProps) {
}
function isInternalLink(href?: string | null) {
return !!href?.startsWith("nn://");
return typeof href === "string" ? href.startsWith("nn://") : false;
}

View File

@@ -282,9 +282,10 @@ img.ProseMirror-separator {
background-color: var(--paragraph, var(--nn_primary_paragraph)) !important;
}
.search-result.selected {
.search-result.selected,
.search-result.selected::selection {
background-color: var(--accent-secondary) !important;
color: var(--accentForeground-secondary);
color: var(--accentForeground-secondary) !important;
}
.search-result {

View File

@@ -118,7 +118,9 @@ function errorLogLevelFactory(level: LogLevel, config: LoggerConfig) {
extras:
error instanceof Error
? { ...extras, fallbackMessage }
: { ...extras, error },
: error
? { ...extras, error }
: extras,
scope: config.scope,
elapsed: now - config.lastTime
});

View File

@@ -294,6 +294,7 @@ export const THEME_SCOPES: readonly (keyof ThemeScopes)[] = [
"editor",
"editorToolbar",
"editorSidebar",
"titleBar",
"dialog",
"navigationMenu",
"contextMenu",