Compare commits

..

1 Commits

Author SHA1 Message Date
Ammar Ahmed
dd94d9685c mobile: fix cannot read property 'groupBy' of undefined 2024-05-11 11:09:30 +05:00
100 changed files with 880 additions and 1395 deletions

View File

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

View File

@@ -16,16 +16,15 @@ 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";
@@ -45,28 +44,23 @@ I18nManager.swapLeftAndRightInRTL(false);
const App = () => {
const init = useAppEvents();
useEffect(() => {
initializeLogger()
.catch((e) => {
console.log(e);
})
.finally(() => {
const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
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);
//@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

@@ -25,7 +25,6 @@ import { MMKVLoader, ProcessingModes } from "react-native-mmkv-storage";
import { generateSecureRandom } from "react-native-securerandom";
import { DatabaseLogger } from ".";
import { MMKV } from "./mmkv";
import { ToastManager } from "../../services/event-manager";
// Database key cipher is persisted across different user sessions hence it has
// it's independent storage which we will never clear. This is only used when application has
@@ -44,8 +43,9 @@ 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 = "kBwr1Kre86ebOZ8ThLu2OA";
const NOTESNOOK_DB_KEY_SALT = "SNuzOcEK3amoqL0WvPeKqw";
const NOTESNOOK_APPLOCK_KEY_SALT = "3dqclWbOYllfk9kk";
const NOTESNOOK_DB_KEY_SALT = "2rcgSprDmRvZ1AAa";
const NOTESNOOK_USER_KEY_SALT = "7qO4qeoM6PbsAJ0Q";
const DB_KEY_CIPHER = "databaseKeyCipher";
const USER_KEY_CIPHER = "userKeyCipher";
@@ -117,7 +117,9 @@ 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);
@@ -133,8 +135,10 @@ export async function validateAppLockPassword(appLockPassword) {
try {
const appLockCipher = CipherStorage.getMap(APPLOCK_CIPHER);
if (!appLockCipher) return true;
const key = await Sodium.deriveKey(appLockPassword, appLockCipher.salt);
const decrypted = await decrypt(key, appLockCipher);
const decrypted = await decrypt(
await Sodium.deriveKey(appLockPassword, NOTESNOOK_APPLOCK_KEY_SALT),
appLockCipher
);
DatabaseLogger.info(
`validateAppLockPassword: ${typeof decrypted === "string"}`
@@ -210,7 +214,7 @@ export async function getDatabaseKey(appLockPassword) {
const userKeyCipher = await encrypt(
{
key: DB_KEY,
salt: NOTESNOOK_DB_KEY_SALT
salt: NOTESNOOK_USER_KEY_SALT
},
userKeyCredentials.password
);
@@ -223,7 +227,6 @@ export async function getDatabaseKey(appLockPassword) {
return DB_KEY;
} catch (e) {
ToastManager.error(e, "Error getting database key");
console.log(e, "error");
DatabaseLogger.error(e);
return null;
@@ -236,7 +239,7 @@ export async function deriveCryptoKey(data) {
const userKeyCipher = await encrypt(
{
key: await getDatabaseKey(),
salt: NOTESNOOK_DB_KEY_SALT
salt: NOTESNOOK_USER_KEY_SALT
},
credentials.key
);
@@ -261,12 +264,13 @@ export async function getCryptoKey(_name) {
const key = await decrypt(
{
key: await getDatabaseKey(),
salt: keyCipher.salt
key: await getDatabaseKey()
},
keyCipher
);
DatabaseLogger.info("User key decrypted: ", !!key);
return key;
} catch (e) {
console.log("getCryptoKey", e);

View File

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

View File

@@ -16,27 +16,14 @@ 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 { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
import { Platform } from "react-native";
import { setLogger } from ".";
import { RNSqliteDriver } from "./sqlite.kysely";
import { KV } from "./storage";
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();
};
const LoggerStorage = new MMKVLoader()
.withInstanceID("notesnook_logs")
.initialize();
export { initializeLogger };
initialize(new KV(LoggerStorage));
export { LoggerStorage };

View File

@@ -34,13 +34,11 @@ 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";
@@ -150,12 +148,6 @@ 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);
@@ -196,7 +188,11 @@ const AppLockedOverlay = () => {
enabled(false);
} else {
SettingsService.appEnteredBackground();
if (SettingsService.get().privacyScreen) {
if (
SettingsService.get().privacyScreen ||
SettingsService.getProperty("appLockEnabled")
) {
enabled(true);
}
}
@@ -213,7 +209,6 @@ const AppLockedOverlay = () => {
justifyContent: "center"
}}
>
<Toast context="local" />
<View
style={{
flex: 1,

View File

@@ -112,10 +112,6 @@ 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();
@@ -131,6 +127,7 @@ 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,7 +44,6 @@ 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,7 +174,6 @@ export const Dialog = ({ context = "global" }) => {
onSubmit={onPressPositive}
returnKeyLabel="Done"
returnKeyType="done"
keyboardType={dialogInfo.keyboardType || "default"}
placeholder={dialogInfo.inputPlaceholder}
/>
</View>

View File

@@ -19,7 +19,6 @@ 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,
@@ -45,7 +44,6 @@ 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";
@@ -68,7 +66,6 @@ export const AppLockPassword = () => {
confirmPassword?: string;
}>({});
const [secureTextEntry, setSecureTextEntry] = useState(true);
const [accountPass, setAccountPass] = useState(false);
useEffect(() => {
const subs = [
@@ -76,7 +73,6 @@ export const AppLockPassword = () => {
eOpenAppLockPasswordDialog,
(mode: "create" | "change" | "remove") => {
setMode(mode);
setAccountPass(false);
setVisible(true);
}
),
@@ -130,9 +126,7 @@ export const AppLockPassword = () => {
mode === "change"
? `Change app lock ${keyboardType}`
: mode === "remove"
? `Enter ${
accountPass ? "account password" : `app lock ${keyboardType}`
} to remove ${keyboardType}`
? `Remove app lock ${keyboardType}`
: `Set up a custom app lock ${keyboardType} to unlock the app`
}
icon="shield"
@@ -175,39 +169,33 @@ export const AppLockPassword = () => {
confirmPasswordInputRef.current?.focus();
}}
defaultValue={values.current.password}
keyboardType={
keyboardType === "pin" && !accountPass ? "number-pad" : "default"
}
keyboardType={keyboardType === "pin" ? "number-pad" : "default"}
autoComplete="password"
returnKeyLabel={mode !== "remove" ? "Next" : "Remove"}
returnKeyType={mode !== "remove" ? "next" : "done"}
secureTextEntry={secureTextEntry}
buttonLeft={
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}
/>
)
<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={
accountPass
? "Account password"
: mode === "change"
mode === "change"
? `New ${keyboardType}`
: `${keyboardType === "pin" ? "Pin" : "Password"}`
}
@@ -234,36 +222,6 @@ 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
@@ -291,8 +249,7 @@ export const AppLockPassword = () => {
);
return;
}
const password = values.current.password;
setAppLockVerificationCipher(password);
await setAppLockVerificationCipher(values.current.password);
SettingsService.setProperty("appLockHasPasswordSecurity", true);
} else if (mode === "change") {
if (
@@ -320,6 +277,7 @@ export const AppLockPassword = () => {
);
return;
}
const isCurrentPasswordCorrect = await validateAppLockPassword(
values.current.currentPassword
);
@@ -335,10 +293,8 @@ export const AppLockPassword = () => {
return;
}
const password = values.current.password;
await clearAppLockVerificationCipher();
SettingsService.setProperty("appLockHasPasswordSecurity", true);
await setAppLockVerificationCipher(password);
await setAppLockVerificationCipher(values.current.password);
} else if (mode === "remove") {
if (!values.current.password) {
ToastManager.error(
@@ -349,18 +305,14 @@ export const AppLockPassword = () => {
return;
}
const isCurrentPasswordCorrect = accountPass
? await db.user.verifyPassword(values.current.password)
: await validateAppLockPassword(values.current.password);
const isCurrentPasswordCorrect = await validateAppLockPassword(
values.current.password
);
if (!isCurrentPasswordCorrect) {
ToastManager.error(
new Error(
accountPass
? "Account password incorrect"
: `${
keyboardType === "pin" ? "Pin" : "Password"
} incorrect`
`${keyboardType === "pin" ? "Pin" : "Password"} incorrect`
),
undefined,
"local"
@@ -390,9 +342,7 @@ export const AppLockPassword = () => {
close();
}}
positiveTitle={
mode === "remove" ? "Remove" : mode === "change" ? "Change" : "Save"
}
positiveTitle="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, true)) || "");
Clipboard.setString(await convertNoteToText(note));
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 { ReadonlyEditor } from "../../screens/editor/readonly-editor";
import Editor from "../../screens/editor";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import { ToastManager, eSendEvent } from "../../services/event-manager";
import { eSendEvent, ToastManager } 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 { diff } from "diffblazer";
import { ReadonlyEditor } from "../../screens/editor/readonly-editor";
/**
*
@@ -121,21 +121,12 @@ export default function NotePreview({ session, content, note }) {
<ReadonlyEditor
editorId="historyPreview"
onLoad={async (loadContent) => {
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"
);
if (content.data) {
const _note = note || (await db.notes.note(session?.noteId));
loadContent({
data: content.data,
id: _note.id
});
}
}}
/>

View File

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

View File

@@ -22,14 +22,20 @@ 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";
const skuInfos: { [name: string]: Product | undefined } = {};
type PurchaseInfo = {
country: string;
countryCode: string;
sku: string;
discount: number;
};
const skuInfos: { [name: string]: PurchaseInfo | undefined } = {};
export const usePricing = (period: "monthly" | "yearly") => {
const [current, setCurrent] = useState<{
period: string;
info?: Product;
info?: PurchaseInfo;
product?: Subscription;
}>();
@@ -48,7 +54,6 @@ 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,16 +75,14 @@ export function ReadonlyEditor(props: {
if (editorMessage.type === EventTypes.readonlyEditorLoaded) {
console.log("Readonly editor loaded.");
props.onLoad?.((content: { data: string; id: string }) => {
setTimeout(() => {
noteId.current = content.id;
editorRef.current?.postMessage(
JSON.stringify({
type: "native:html",
value: content.data
})
);
setLoading(false);
}, 300);
noteId.current = content.id;
editorRef.current?.postMessage(
JSON.stringify({
type: "native:html",
value: content.data
})
);
setLoading(false);
});
} else if (editorMessage.type === EventTypes.getAttachmentData) {
const attachment = (editorMessage.value as any).attachment as Attachment;

View File

@@ -185,8 +185,7 @@ const camera = async (options: PickerOptions) => {
cropping: false,
multiple: true,
maxFiles: 10,
writeTempFile: true,
compressImageQuality: 1
writeTempFile: true
})
.then((response) => {
handleImageResponse(
@@ -216,8 +215,7 @@ const gallery = async (options: PickerOptions) => {
mediaType: "photo",
maxFiles: 10,
cropping: false,
multiple: true,
compressImageQuality: 1
multiple: true
})
.then((response) =>
handleImageResponse(
@@ -268,6 +266,7 @@ const handleImageResponse = async (
response: Image[],
options: PickerOptions
) => {
console.log(response, "result-file-picker");
const result = await AttachImage.present(response, options.context);
if (!result) return;
const compress = result.compress;
@@ -303,15 +302,10 @@ const handleImageResponse = async (
type: "url"
});
let fileName = image.sourceURL
const fileName = image.sourceURL
? basename(image.sourceURL)
: image.filename || "image";
fileName =
image.mime === "image/jpeg"
? fileName.replace(/HEIC|HEIF/, "jpeg")
: fileName;
console.log("attaching image...", fileName);
console.log("attaching file...");

View File

@@ -351,18 +351,9 @@ export const useEditor = (
if (!unlocked)
throw new Error("Could not save note, vault is locked");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-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);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.vault?.save(noteData as any);
clearTimeout(saveTimer);
}
@@ -705,13 +696,14 @@ 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.dateModified) {
if (lastContentChangeTime.current[noteId] >= data.dateEdited) {
return;
}
@@ -736,7 +728,7 @@ export const useEditor = (
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current[note.id]?.data) {
if (_nextContent === currentContents.current?.data) {
return;
}
lastContentChangeTime.current[note.id] = note.dateEdited;

View File

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

View File

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

View File

@@ -59,14 +59,14 @@ const recoveryKeyMessage = {
() => {
eSendEvent(eOpenRecoveryKeyDialog);
},
false,
true,
async () => {
SettingsService.set({
recoveryKeySaved: true
});
clearMessage();
},
"Cancel"
"I have saved my key already"
);
},
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, true);
let text = await convertNoteToText(note, false);
if (!text) text = "";
const html = text.replace(/\n/g, "<br />");
Notifications.displayNotification({
title: note.title,
message: note.headline || text,
subtitle: "",
subtitle: note.headline || text,
bigText: html,
ongoing: true,
actions: ["UNPIN"],

View File

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

View File

@@ -81,7 +81,6 @@ export type Settings = {
biometricsAuthEnabled?: boolean;
backgroundSync?: boolean;
applockKeyboardType: "numeric" | "default";
settingsVersion?: number;
};
type DimensionsType = {
@@ -170,8 +169,7 @@ export const defaultSettings: SettingStore["settings"] = {
markdownShortcuts: true,
biometricsAuthEnabled: false,
appLockHasPasswordSecurity: false,
backgroundSync: true,
settingsVersion: 0
backgroundSync: true
};
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 = false;
export const BETA = true;
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 3016
versionCode 3011
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

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

View File

@@ -1,3 +1,4 @@
- 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 = 2105;
CURRENT_PROJECT_VERSION = 2100;
DEVELOPMENT_TEAM = 53CWBG3QUC;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -1089,7 +1089,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.8;
MARKETING_VERSION = 3.0.3;
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 = 2105;
CURRENT_PROJECT_VERSION = 2100;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@@ -1194,7 +1194,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.8;
MARKETING_VERSION = 3.0.3;
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 = 2105;
CURRENT_PROJECT_VERSION = 2100;
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.8;
MARKETING_VERSION = 3.0.3;
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 = 2105;
CURRENT_PROJECT_VERSION = 2100;
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.8;
MARKETING_VERSION = 3.0.3;
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 = 2105;
CURRENT_PROJECT_VERSION = 2100;
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.8;
MARKETING_VERSION = 3.0.3;
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 = 2105;
CURRENT_PROJECT_VERSION = 2100;
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.8;
MARKETING_VERSION = 3.0.3;
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.15.2
COCOAPODS: 1.12.1

View File

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

View File

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

View File

@@ -10,19 +10,3 @@ index 5de0845..1b158d8 100644
if (includeBase64) {
image.putString("data", getBase64StringFromFile(compressedImagePath));
diff --git a/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m b/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m
index 9f20973..5e14da8 100644
--- a/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m
+++ b/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m
@@ -595,8 +595,10 @@ - (void)qb_imagePickerController:
NSString *mimeType = [self determineMimeTypeFromImageData:imageData];
Boolean isKnownMimeType = [mimeType length] > 0;
+ Boolean isHeicOrHeif = [mimeType isEqualToString:@"image/heic"] || [mimeType isEqualToString:@"image/heif"];
+
ImageResult *imageResult = [[ImageResult alloc] init];
- if (isLossless && useOriginalWidth && useOriginalHeight && isKnownMimeType && !forceJpg) {
+ if (isLossless && useOriginalWidth && useOriginalHeight && isKnownMimeType && !forceJpg && !isHeicOrHeif) {
// Use original, unmodified image
imageResult.data = imageData;
imageResult.width = @(imgT.size.width);

View File

@@ -311,7 +311,8 @@ const ShareView = () => {
type: "tiptap"
},
id: note.id,
sessionId: Date.now()
sessionId: Date.now(),
title: noteTitle.current
};
} else {
noteData = { ...note };
@@ -486,42 +487,25 @@ const ShareView = () => {
gap: 10
}}
>
{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();
}}
/>
)}
<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();
}}
/>
<Button
title="Done"
type="accent"

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/theme-builder",
"version": "1.2.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/theme-builder",
"version": "1.2.0",
"version": "1.1.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@emotion/react": "11.11.1",
@@ -18,18 +18,17 @@
"@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",
@@ -979,7 +978,7 @@
},
"../web": {
"name": "@notesnook/web",
"version": "3.0.6",
"version": "3.0.0",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -2027,15 +2026,6 @@
"@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,
@@ -2108,30 +2098,25 @@
},
"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=="
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
"dev": true
},
"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.2.0",
"version": "1.1.1",
"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,10 +2,19 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<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" />
<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" />
<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
@@ -40,26 +49,13 @@
/>
<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>
@@ -67,6 +63,21 @@
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);
@@ -77,19 +88,6 @@
--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;
@@ -97,7 +95,7 @@
@keyframes pulse {
0% {
transform: scale(0.9);
transform: scale(0.95);
}
70% {
@@ -105,7 +103,7 @@
}
100% {
transform: scale(0.9);
transform: scale(0.95);
}
}
@@ -130,9 +128,20 @@
overflow: hidden;
}
#root {
display: flex;
flex-direction: column;
@keyframes fadeUp {
0% {
transform: translateY(500px);
opacity: 0;
}
80% {
transform: translateY(0px);
opacity: 0.7;
}
100% {
opacity: 1;
}
}
/* svg {
@@ -162,40 +171,85 @@
</style>
</head>
<body class="theme-scope-base">
<body>
<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>
<svg
xmlns="http://www.w3.org/2000/svg"
style="height: 0; width: 0; z-index: -1; position: absolute"
<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;
"
>
<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)">
<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" />
<path
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"
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"
/>
</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,59 +18,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { init } from "@notesnook/web/src/bootstrap";
import { createRoot } from "react-dom/client";
import {
ErrorBoundary,
ErrorComponent
} from "@notesnook/web/src/components/error-boundary";
import { render } from "react-dom";
import { BaseThemeProvider } from "@notesnook/web/src/components/theme-provider";
import { App } from "./app";
renderApp();
async function renderApp() {
const rootElement = document.getElementById("root");
if (!rootElement) return;
const root = createRoot(rootElement);
try {
const { component, props } = await init();
const { component, props } = await init();
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()}
/>
</>
);
}
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();
}
);
}

View File

@@ -259,15 +259,14 @@ test("unlock a note for editing", async ({ page }) => {
await page.waitForTimeout(150);
await page.reload();
await notes.waitForList();
await page.waitForTimeout(500);
const newContent = `${NOTE.content}${content}`;
const editedNote = await notes.findNote({
title: NOTE.title,
content: newContent
});
if (!editedNote) throw new Error("Could not find note.");
await editedNote.openLockedNote(PASSWORD);
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.6",
"version": "3.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.0.6",
"version": "3.0.3",
"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.6",
"version": "3.0.3",
"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: (name, init) => createDialect(name, true, init),
dialect: createDialect,
...(IS_DESKTOP_APP || isFeatureSupported("opfs")
? { journalMode: "WAL", lockingMode: "exclusive" }
: {

View File

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

View File

@@ -18,10 +18,9 @@ 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 = [
// {
@@ -58,24 +57,11 @@ const KEYMAP = [
keys: ["command+f", "ctrl+f"],
description: "Search all notes",
global: false,
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;
}
action: (e) => {
if (e.target?.classList.contains("ProseMirror")) return;
e.preventDefault();
useSearchStore.setState({ isSearching: true, searchType: "notes" });
navigate("/search/notes");
}
}
// {
@@ -151,11 +137,7 @@ export function registerKeyMap() {
KEYMAP.forEach((key) => {
hotkeys(
key.keys.join(","),
{
element: key.global
? (GlobalKeyboard as unknown as HTMLElement)
: document.body
},
{ element: key.global ? GlobalKeyboard : window },
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({ noVerify: true });
await createBackup();
} 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, _encrypted: boolean): Dialect => {
export const createDialect = (name: string): Dialect => {
return {
createDriver: () =>
new SqliteDriver({

View File

@@ -34,7 +34,6 @@ declare module "kysely" {
export const createDialect = (
name: string,
encrypted: boolean,
init?: () => Promise<void>
): Dialect => {
return {
@@ -42,7 +41,6 @@ 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: () => Promise<{ port: MessagePort; onclose: () => void }>,
portProviderFunc: () => MessagePort | Promise<MessagePort>,
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, onclose } = await portProviderFunc();
const port = await portProviderFunc();
port.start();
// Listen for client requests. A separate BroadcastChannel
@@ -159,7 +159,6 @@ 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,230 +32,219 @@ type PreparedStatement = {
columns: string[];
};
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);
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;
}
async open(async: boolean, url?: string) {
if (this.db) {
console.error("Database is already initialized", this.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)
);
this.sqlite = Factory(sqliteModule);
this.vfs = await this.getVFS(this.dbName, async);
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)
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)
);
if (!prepared) return;
sqlite = Factory(sqliteModule);
vfs = await getVFS(dbName, async);
const statement: PreparedStatement = {
stmt: prepared.stmt,
columns: this.sqlite.column_names(prepared.stmt)
};
this.preparedStatements.set(sql, statement);
sqlite.vfs_register(vfs, false);
db = await sqlite.open_v2(dbName, undefined, `multipleciphers-${vfs.name}`);
}
this.sqlite.str_finish(str);
/**
* 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;
// reset retry count on success
this.retryCounter[sql] = 0;
return statement;
} catch (ex) {
console.error(ex);
const str = sqlite.str_new(db, sql);
const prepared = await sqlite.prepare_v2(db, sqlite.str_value(str));
if (!prepared) return;
// 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 : []
const statement: PreparedStatement = {
stmt: prepared.stmt,
columns: sqlite.column_names(prepared.stmt)
};
}
preparedStatements.set(sql, statement);
async close() {
if (!this.db) return;
sqlite.str_finish(str);
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();
// reset retry count on success
retryCounter[sql] = 0;
return statement;
} catch (ex) {
console.error(ex);
this.db = undefined;
this.initialized = false;
}
// 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;
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;
if (ex instanceof Error || ex instanceof SQLiteError)
ex.message += ` (query: ${sql})`;
throw ex;
}
}
export type SQLiteWorker = typeof _SQLiteWorker.prototype;
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;
initialized = false;
}
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 function deleteDatabase(dbName: string, async: boolean) {
await close();
if (vfs) await vfs.delete();
else await (await getVFS(dbName, async)).delete();
}
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)
);
if ("isReady" in vfs) await vfs.isReady;
return vfs;
}
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;
addEventListener("message", async (event) => {
if (!event.data.type) {
const worker = new _SQLiteWorker(event.data.dbName, event.data.encrypted);
await worker.open(event.data.async, event.data.uri);
await worker.open(event.data.dbName, 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,12 +26,7 @@ import SQLiteAsyncURI from "./wa-sqlite-async.wasm?url";
import { Mutex } from "async-mutex";
import { SharedService } from "./shared-service";
type Config = {
dbName: string;
async: boolean;
encrypted: boolean;
init?: () => Promise<void>;
};
type Config = { dbName: string; async: boolean; init?: () => Promise<void> };
const servicePool = new Map<
string,
@@ -60,6 +55,7 @@ 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
);
@@ -76,24 +72,19 @@ export class WaSqliteWorkerDriver implements Driver {
service.activate(
() =>
new Promise<{ port: MessagePort; onclose: () => void }>((resolve) => {
new Promise<MessagePort>((resolve) => {
console.log("initializing worker");
this.needsInitialization = true;
const worker = new Worker();
worker.addEventListener(
"message",
(event) =>
resolve({
port: event.ports[0],
onclose: () => worker.terminate()
}),
(event) => resolve(event.ports[0]),
{ 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();
await createBackup(true);
break;
}
default: {

View File

@@ -331,6 +331,12 @@ 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,
@@ -339,17 +345,12 @@ 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,6 +152,7 @@ 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.dateModified) {
if (isContent && lastChangedTime.current < item.dateEdited) {
if (!item.locked) return editor.updateContent(item.data);
const result = await db.vault

View File

@@ -344,6 +344,7 @@ function TipTap(props: TipTapProps) {
};
}, []);
if (readonly) return null;
return (
<>
<ScopedThemeProvider
@@ -417,7 +418,7 @@ function TiptapWrapper(
editorContainer={() => {
if (editorContainerRef.current) return editorContainerRef.current;
const editorContainer = document.createElement("div");
editorContainer.classList.add("selectable", "editor-container");
editorContainer.classList.add("selectable");
editorContainer.style.flex = "1";
editorContainer.style.cursor = "text";
editorContainer.style.color =
@@ -426,7 +427,6 @@ 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,10 +162,9 @@ 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") ||
/table ".+?" already exists/.test(errorText) ||
errorText.includes("corrupted migrations:")
errorText.includes("malformed database schema")
) {
return {
explanation: `This error usually means the database file is either corrupt or it could not be decrypted.`,
@@ -196,17 +195,6 @@ 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,6 +524,8 @@ 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,7 +89,32 @@ 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,15 +233,6 @@ 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" &&
@@ -266,7 +257,9 @@ class EditorStore extends BaseStore<EditorStore> {
}
// if a deleted note is restored, reopen the session
else if (session.type === "deleted" && item.type === "note") {
openSession(session.note.id, { force: true, silent: true });
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
}
// if a readonly note is made editable, reopen the session
else if (
@@ -274,7 +267,9 @@ class EditorStore extends BaseStore<EditorStore> {
item.type === "note" &&
!item.readonly
)
openSession(session.note.id, { force: true, silent: true });
waitForSync().then(() =>
openSession(session.note.id, { force: true, silent: true })
);
// update the note in all sessions
else if (item.type === "note") {
updateSession(
@@ -349,10 +344,6 @@ 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 &&
@@ -557,14 +548,12 @@ class EditorStore extends BaseStore<EditorStore> {
const noteId = typeof noteOrId === "string" ? noteOrId : noteOrId.id;
const session = getSession(noteId);
if (session && !options.force) {
if (!session.needsHydration) {
return this.activateSession(noteId, options.activeBlockId);
}
if (session && !options.force && !session.needsHydration) {
return this.activateSession(noteId, options.activeBlockId);
}
if (session.type === "diff" || session.type === "conflicted") {
return openDiffSession(session.note.id, session.id);
}
if (session && (session.type === "diff" || session.type === "conflicted")) {
return openDiffSession(session.note.id, session.id);
}
if (session && session.id) await db.fs().cancel(session.id);
@@ -577,7 +566,19 @@ class EditorStore extends BaseStore<EditorStore> {
const isPreview = session ? session.preview : !options?.newSession;
const isLocked = await db.vaults.itemExists(note);
if (note.conflicted) {
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) {
const content = note.contentId
? await db.content.get(note.contentId)
: undefined;
@@ -611,18 +612,6 @@ 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,32 +22,15 @@ import {
logger as _logger,
logManager
} from "@notesnook/core/dist/logger";
import { LogMessage, NoopLogger, format } from "@notesnook/logger";
import { LogMessage } from "@notesnook/logger";
import { DatabasePersistence, NNStorage } from "../interfaces/storage";
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 = 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
);
let logger: typeof _logger;
async function initializeLogger(persistence: DatabasePersistence = "db") {
initialize(new NNStorage("Logs", () => null, persistence), false);
logger = _logger.scope("notesnook-web");
}
@@ -64,9 +47,11 @@ async function downloadLogs() {
return;
}
controller.enqueue({
path: sanitizeFilename(log.key, { replacement: "-" }) + ".log",
path: sanitizeFilename(log.key, { replacement: "-" }),
data: textEncoder.encode(
(log.logs as LogMessage[]).map((line) => format(line)).join("\n")
(log.logs as LogMessage[])
.map((line) => JSON.stringify(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({ rescueMode: true });
await createBackup(true);
navigate("new");
}}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
- 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 = 60 * 1000;
const TEST_TIMEOUT = 30 * 1000;
test(
"case 1: device A & B should only download the changes from device C (no uploading)",
@@ -156,111 +156,6 @@ 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 () => {
@@ -483,8 +378,6 @@ 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,7 +63,6 @@ import { Settings } from "../collections/settings";
import {
DatabaseAccessor,
DatabaseSchema,
RawDatabaseSchema,
SQLiteOptions,
changeDatabasePassword,
createDatabase,
@@ -75,8 +74,7 @@ import { Vaults } from "../collections/vaults";
import { KVStorage } from "../database/kv";
import { QueueValue } from "../utils/queue-value";
import { Sanitizer } from "../database/sanitizer";
import { createTriggers, dropTriggers } from "../database/triggers";
import { NNMigrationProvider } from "../database/migrations";
import { dropTriggers } from "../database/triggers";
type EventSourceConstructor = new (
uri: string,
@@ -181,7 +179,7 @@ class Database {
subscriptions = new Subscriptions(this.tokenManager);
offers = new Offers();
debug = new Debug();
pricing = Pricing;
pricing = new Pricing();
user = new UserManager(this);
syncer = new Sync(this);
@@ -246,10 +244,7 @@ class Database {
await sql.raw(statement).execute(this.sql());
}
await initializeDatabase(
this.sql().withTables(),
new NNMigrationProvider()
);
await initializeDatabase(this.sql().withTables());
await this.initCollections();
return true;
}
@@ -279,11 +274,10 @@ class Database {
this.disconnectSSE();
});
this._sql = (await createDatabase<RawDatabaseSchema>("notesnook", {
...this.options.sqliteOptions,
migrationProvider: new NNMigrationProvider(),
onInit: (db) => createTriggers(db)
})) as unknown as Kysely<DatabaseSchema>;
this._sql = (await createDatabase(
"notesnook",
this.options.sqliteOptions
)) as unknown as Kysely<DatabaseSchema>;
await this.sanitizer.init();

View File

@@ -92,10 +92,7 @@ class MFAManager {
}
async sendCode(method: "sms" | "email") {
const token = await this.tokenManager.getAccessToken([
"IdentityServerApi",
"auth:grant_types:mfa"
]);
const token = await this.tokenManager.getAccessToken();
if (!token) throw new Error("Unauthorized.");
return await http.post(

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "../utils/http";
export type Product = {
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 = merger.mergeContent(
const merged = await merger.mergeContent(
{
type: "tiptap",
data: "Remote",
@@ -132,7 +132,6 @@ describe.concurrent("merge content", (test) => {
{
type: "tiptap",
data: "Local",
synced: true,
dateEdited: Date.now() - 1000
}
);
@@ -141,13 +140,13 @@ describe.concurrent("merge content", (test) => {
expect(merged.data).toBe("Remote");
}));
test("trigger conflict if local item is unsynced", () =>
test("trigger conflict if local item dateEdited is newer", () =>
databaseTest().then(async (db) => {
await loginFakeUser(db);
const merger = new Merger(db);
const noteId = await db.notes.add(TEST_NOTE);
const merged = merger.mergeContent(
const merged = await merger.mergeContent(
{
type: "tiptap",
data: "Remote",
@@ -158,7 +157,7 @@ describe.concurrent("merge content", (test) => {
type: "tiptap",
data: "Local",
noteId,
synced: false,
dateEdited: Date.now()
}
);
@@ -167,6 +166,7 @@ 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,34 +57,37 @@ class Collector {
const collection = this.db[collectionKey].collection;
let pushTimestamp = Date.now();
for await (const chunk of collection.unsynced(chunkSize, isForceSync)) {
const { ids, items: syncableItems } = filterSyncableItems(chunk);
if (!ids.length) continue;
const ciphers = await this.db
.storage()
.encryptMulti(key, syncableItems);
const items = toPushItem(ids, ciphers);
const items = await this.prepareChunk(chunk, key);
if (!items) continue;
yield { items, type: itemType };
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();
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)
}
);
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;
@@ -110,6 +113,9 @@ 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,20 +242,6 @@ 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) {
@@ -348,8 +334,10 @@ class Sync {
const localItems = await collection.records(chunk.items.map((i) => i.id));
let items: (MaybeDeletedItem<Item> | undefined)[] = [];
if (itemType === "content") {
items = deserialized.map((item) =>
this.merger.mergeContent(item, localItems[item.id])
items = await Promise.all(
deserialized.map((item) =>
this.merger.mergeContent(item, localItems[item.id])
)
);
} else {
items =

View File

@@ -46,7 +46,7 @@ class Merger {
}
}
mergeContent(
async mergeContent(
remoteItem: MaybeDeletedItem<Item>,
localItem: MaybeDeletedItem<Item> | undefined
) {
@@ -75,7 +75,10 @@ class Merger {
else if (!conflicted) return;
// otherwise we trigger the conflicts
this.logger.info("conflict marked", { id: localItem.noteId });
await this.db.notes.add({
id: localItem.noteId,
conflicted: true
});
localItem.conflicted = remoteItem;
return localItem;
}
@@ -121,8 +124,9 @@ export function isContentConflicted(
remoteItem.dateModified &&
localItem.dateResolved === remoteItem.dateModified;
const isEdited =
// the local item is edited if it wasn't synced yet.
!localItem.synced;
// 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;
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

@@ -32,15 +32,6 @@ export type Token = {
refresh_token: string;
};
type Scope = (typeof SCOPES)[number];
const SCOPES = [
"notesnook.sync",
"offline_access",
"IdentityServerApi",
"auth:grant_types:mfa",
"auth:grant_types:mfa_password"
] as const;
const ENDPOINTS = {
token: "/connect/token",
revoke: "/connect/revocation",
@@ -88,14 +79,10 @@ class TokenManager {
return scopes.includes("offline_access") && Boolean(refresh_token);
}
async getAccessToken(
scopes: Scope[] = ["notesnook.sync", "IdentityServerApi"],
forceRenew = false
) {
async getAccessToken(forceRenew = false) {
return await getSafeToken(async () => {
const token = await this.getToken(true, forceRenew);
if (!token || !token.scope) return;
if (!scopes.some((s) => token.scope.includes(s))) return;
if (!token || token.scope.includes("auth:grant_types")) return;
return token.access_token;
}, "Error getting access token:");
}

View File

@@ -191,7 +191,7 @@ class UserManager {
username: email,
password: hashedPassword,
grant_type: code ? "mfa" : "password",
scope: "notesnook.sync offline_access IdentityServerApi",
scope: "notesnook.sync offline_access openid IdentityServerApi",
client_id: "notesnook",
"mfa:code": code,
"mfa:method": method

View File

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

View File

@@ -318,9 +318,17 @@ 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,
@@ -330,38 +338,19 @@ export class Notes implements ICollection {
dateCreated: undefined,
dateModified: undefined
});
if (!duplicateId) continue;
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()) {
for (const relation of await this.db.relations
.to(note, "notebook")
.get()) {
await this.db.relations.add(
{ type: relation.fromType, id: relation.fromId },
{ type: "notebook", 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,9 +63,6 @@ export class Relations implements ICollection {
});
}
from(
reference: ItemReference | ItemReferences
): RelationsArray<keyof RelatableTable>;
from(
reference: ItemReference | ItemReferences,
types: (keyof RelatableTable)[]
@@ -76,19 +73,16 @@ 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,
type ? (Array.isArray(type) ? type : ([type] as TType[])) : undefined,
Array.isArray(type) ? type : [type],
"from"
);
}
to(
reference: ItemReference | ItemReferences
): RelationsArray<keyof RelatableTable>;
to(
reference: ItemReference | ItemReferences,
types: (keyof RelatableTable)[]
@@ -99,12 +93,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,
type ? (Array.isArray(type) ? type : ([type] as TType[])) : undefined,
Array.isArray(type) ? type : [type],
"to"
);
}
@@ -230,26 +224,21 @@ 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[] | undefined,
private readonly types: TType[],
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]>(
table,
this.table,
this.db
.sql()
.selectFrom<keyof DatabaseSchema>(table)
.selectFrom<keyof DatabaseSchema>(this.table)
.where("id", "in", (b) =>
b
.selectFrom("relations")
@@ -367,12 +356,10 @@ class RelationsArray<TType extends keyof RelatableTable> {
) => {
if (this.direction === "to") {
return builder
.$if(!!this.types, (eb) =>
eb.where(
"fromType",
this.types!.length > 1 ? "in" : "==",
this.types!.length > 1 ? this.types : this.types![0]
)
.where(
"fromType",
this.types.length > 1 ? "in" : "==",
this.types.length > 1 ? this.types : this.types[0]
)
.where("toType", "==", this.reference.type)
.where(
@@ -383,12 +370,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)
)
@@ -396,12 +383,10 @@ class RelationsArray<TType extends keyof RelatableTable> {
.$narrowType<{ id: string }>();
} else {
return builder
.$if(!!this.types, (eb) =>
eb.where(
"toType",
this.types!.length > 1 ? "in" : "==",
this.types!.length > 1 ? this.types : this.types![0]
)
.where(
"toType",
this.types.length > 1 ? "in" : "==",
this.types.length > 1 ? this.types : this.types[0]
)
.where("fromType", "==", this.reference.type)
.where(
@@ -412,12 +397,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 && href.startsWith("nn://")) {
if (name === "a" && href.startsWith("nn://")) {
const link = resolve(href);
if (!link) return;
attr[ATTRIBUTES.href] = link;
@@ -244,10 +244,7 @@ export class Tiptap {
if (types.includes("internalLinks")) {
result.internalLinks.push(
...findAll(
(e) =>
e.tagName === "a" &&
!!e.attribs.href &&
e.attribs.href.startsWith("nn://"),
(e) => e.tagName === "a" && e.attribs.href.startsWith("nn://"),
document.childNodes
)
.map((e) => parseInternalLink(e.attribs.href))

View File

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

View File

@@ -289,26 +289,6 @@ 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>,
public type: TCollectionType,
type: TCollectionType,
eventManager: EventManager,
sanitizer: Sanitizer
) {

View File

@@ -75,7 +75,7 @@ export class SQLCollection<
_startTransaction: (
executor: (tr: Kysely<DatabaseSchema>) => Promise<void>
) => Promise<void>,
public readonly type: TCollectionType,
private readonly type: TCollectionType,
private readonly eventManager: EventManager,
private readonly sanitizer: Sanitizer
) {}
@@ -227,16 +227,14 @@ export class SQLCollection<
ids: string[],
partial: Partial<SQLiteItem<T>>,
options: {
sendEvent?: boolean;
modify?: boolean;
sendEvent: 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()
@@ -246,16 +244,16 @@ export class SQLCollection<
await tx
.updateTable<keyof DatabaseSchema>(this.type)
.where("id", "in", chunk)
.$if(!!condition, (eb) => eb.where(condition!))
.$if(!!options.condition, (eb) => eb.where(options.condition!))
.set({
...partial,
dateModified: modify ? Date.now() : undefined,
dateModified: Date.now(),
synced: partial.synced || false
})
.execute();
}
});
if (sendEvent) {
if (options.sendEvent) {
this.eventManager.publish(EVENTS.databaseUpdated, <UpdateEvent>{
type: "update",
collection: this.type,
@@ -286,12 +284,6 @@ 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)])
@@ -312,12 +304,6 @@ 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,9 +28,7 @@ import {
format,
ILogger
} from "@notesnook/logger";
import { Kysely, Migration, MigrationProvider } from "kysely";
import { SQLiteOptions, createDatabase } from "./database";
import { toChunks } from "./utils/array";
import { IStorage } from "./interfaces";
const WEEK = 86400000 * 7;
@@ -40,46 +38,10 @@ 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(db: Kysely<LogDatabaseSchema>) {
this.writer = new DatabaseLogWriter(db);
constructor(storage: IStorage) {
this.writer = new DatabaseLogWriter(storage);
}
write(log: LogMessage) {
@@ -88,10 +50,10 @@ class DatabaseLogReporter {
}
class DatabaseLogWriter {
private queue: LogMessageWithDate[] = [];
private queue: Map<string, LogMessage> = new Map();
private hasCleared = false;
constructor(private readonly db: Kysely<LogDatabaseSchema>) {
constructor(private readonly storage: IStorage) {
setInterval(() => {
setTimeout(() => {
if (!this.hasCleared) {
@@ -104,86 +66,86 @@ class DatabaseLogWriter {
}
push(message: LogMessage) {
const date = new Date(message.timestamp);
(message as LogMessageWithDate).date = `${date.getFullYear()}-${
date.getMonth() + 1
}-${date.getDate()}`;
this.queue.push(message as LogMessageWithDate);
const key = new Date(message.timestamp).toLocaleDateString();
this.queue.set(`${key}:${message.timestamp}`, message);
}
async flush() {
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();
}
if (this.queue.size === 0) return;
const queueCopy = Array.from(this.queue.entries());
this.queue = new Map();
await this.storage.writeMulti(queueCopy);
}
async rotate() {
const range = Date.now() - WEEK;
await this.db.deleteFrom("logs").where("timestamp", "<", range).execute();
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);
}
}
class DatabaseLogManager {
constructor(private readonly db: Kysely<LogDatabaseSchema>) {}
constructor(private readonly storage: IStorage) {}
async get() {
const logs = await this.db
.selectFrom("logs")
.select([
"timestamp",
"message",
"level",
"scope",
"extras",
"elapsed",
"date"
])
.execute();
const groupedLogs: Record<string, LogMessage[]> = {};
const logKeys = await this.storage.getAllKeys();
const logEntries = await this.storage.readMulti<LogMessage>(logKeys);
const logs: Record<string, LogMessage[]> = {};
for (const log of logs) {
const key = log.date!;
if (!groupedLogs[key]) groupedLogs[key] = [];
groupedLogs[key].push(log as 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);
}
return Object.keys(groupedLogs)
return Object.keys(logs)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.map((key) => ({
key,
logs: groupedLogs[key]?.sort((a, b) => a.timestamp - b.timestamp)
logs: logs[key]?.sort((a, b) => a.timestamp - b.timestamp)
}));
}
async clear() {
await this.db.deleteFrom("logs").execute();
const logKeys = await this.storage.getAllKeys();
await this.storage.removeMulti(logKeys);
}
async delete(key: string) {
await this.db.deleteFrom("logs").where("date", "==", key).execute();
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);
}
}
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);
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);
}
}
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 && link.startsWith("nn://");
return 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==",
"dev": true
"devOptional": 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==",
"dev": true,
"devOptional": 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==",
"dev": true
"devOptional": 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==",
"dev": true,
"devOptional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -17822,6 +17822,20 @@
"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

@@ -22,7 +22,9 @@ import {
getFontById,
getTableOfContents,
TiptapOptions,
usePermissionHandler
Toolbar,
usePermissionHandler,
useTiptap
} from "@notesnook/editor";
import { toBlobURL } from "@notesnook/editor/dist/utils/downloader";
import { useThemeColors } from "@notesnook/theme";
@@ -44,13 +46,13 @@ import {
useTabContext,
useTabStore
} from "../hooks/useTabStore";
import { EventTypes, postAsyncWithTimeout, Settings } from "../utils";
import { pendingSaveRequests } from "../utils/pending-saves";
import { EmotionEditorToolbarTheme } from "../theme-factory";
import { EventTypes, postAsyncWithTimeout, randId, Settings } from "../utils";
import Header from "./header";
import StatusBar from "./statusbar";
import Tags from "./tags";
import TiptapEditorWrapper from "./tiptap";
import Title from "./title";
import { pendingSaveRequests } from "../utils/pending-saves";
globalThis.toBlobURL = toBlobURL as typeof globalThis.toBlobURL;
@@ -75,8 +77,6 @@ const Tiptap = ({
const biometryEnrolled = useTabStore((state) => state.biometryEnrolled);
const editorRoot = useRef<HTMLDivElement>(null);
const isFocusedRef = useRef<boolean>(false);
const [undo, setUndo] = useState(false);
const [redo, setRedo] = useState(false);
tabRef.current = tab;
function restoreNoteSelection(state?: NoteState) {
@@ -232,11 +232,14 @@ const Tiptap = ({
settings.dateFormat,
settings.timeFormat,
settings.markdownShortcuts,
tab.id,
tick
tick,
tab.id
]);
const _editor = useTiptap(tiptapOptions, [tiptapOptions]);
const update = useCallback(() => {
editors[tabRef.current.id]?.commands.setTextSelection(0);
setTick((tick) => tick + 1);
globalThis.editorControllers[tabRef.current.id]?.setTitlePlaceholder(
"Note title"
@@ -260,6 +263,7 @@ const Tiptap = ({
const controllerRef = useRef(controller);
globalThis.editorControllers[tab.id] = controller;
globalThis.editors[tab.id] = _editor;
useLayoutEffect(() => {
if (!getContentDiv().parentElement) {
@@ -423,8 +427,8 @@ const Tiptap = ({
onDoubleClick={onClickEmptyArea}
>
<Header
hasRedo={redo}
hasUndo={undo}
hasRedo={_editor?.can().redo() || false}
hasUndo={_editor?.can().undo() || false}
settings={settings}
noHeader={settings.noHeader || false}
/>
@@ -474,8 +478,7 @@ const Tiptap = ({
<p
style={{
width: "90%",
fontSize: "0.9rem",
color: colors.primary.paragraph
fontSize: "0.9rem"
}}
>
<ol>
@@ -855,23 +858,24 @@ const Tiptap = ({
/>
</div>
<TiptapEditorWrapper
key={tick + tab.id + "-editor"}
options={tiptapOptions}
settings={settings}
onEditorUpdate={(editor) => {
if (!editor) {
setUndo(false);
setRedo(false);
}
if (undo !== editor.can().undo()) {
setUndo(editor.can().undo());
}
if (redo !== editor.can().redo()) {
setRedo(editor.can().redo());
}
}}
/>
{tab.locked ? null : (
<EmotionEditorToolbarTheme>
<Toolbar
className="theme-scope-editorToolbar"
sx={{
display: settings.noToolbar ? "none" : "flex",
overflowY: "hidden",
minHeight: "50px",
backgroundColor: "red"
}}
editor={_editor}
location="bottom"
tools={[...settings.tools]}
defaultFontFamily={settings.fontFamily}
defaultFontSize={settings.fontSize}
/>
</EmotionEditorToolbarTheme>
)}
</div>
</>
);

View File

@@ -1,60 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Editor, TiptapOptions, Toolbar, useTiptap } from "@notesnook/editor";
import { useTabContext } from "../hooks/useTabStore";
import { EmotionEditorToolbarTheme } from "../theme-factory";
import { Settings } from "../utils";
import { useEffect } from "react";
export default function TiptapEditorWrapper(props: {
options: Partial<TiptapOptions>;
onEditorUpdate: (editor: Editor) => void;
settings: Settings;
}) {
const tab = useTabContext();
const editor = useTiptap(props.options, [props.options]);
globalThis.editors[tab.id] = editor;
useEffect(() => {
props.onEditorUpdate(editor);
}, [editor, props]);
return (
<>
{tab.locked ? null : (
<EmotionEditorToolbarTheme>
<Toolbar
className="theme-scope-editorToolbar"
sx={{
display: props.settings.noToolbar ? "none" : "flex",
overflowY: "hidden",
minHeight: "50px",
backgroundColor: "red"
}}
editor={editor}
location="bottom"
tools={[...props.settings.tools]}
defaultFontFamily={props.settings.fontFamily}
defaultFontSize={props.settings.fontSize}
/>
</EmotionEditorToolbarTheme>
)}
</>
);
}

View File

@@ -355,13 +355,13 @@ export function useEditorController({
}
scrollTo?.(noteState?.top || 0);
countWords(0);
}
break;
}
case "native:html":
if (htmlContentRef.current === value) break;
htmlContentRef.current = value;
logger("info", "LOADING NOTE HTML");
if (!editor) break;

View File

@@ -19,13 +19,7 @@ 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,
TextSelection
} from "prosemirror-state";
import { EditorState, Plugin, PluginKey, Transaction } from "prosemirror-state";
import { SearchSettings } from "../../toolbar/stores/search-store";
type DispatchFn = (tr: Transaction) => void;
@@ -280,58 +274,44 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
},
moveToNextResult:
() =>
({ state, dispatch }) => {
({ state, dispatch, commands }) => {
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];
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(from),
tr.mapping.map(to)
)
);
commands.setTextSelection({ from, to });
scrollIntoView();
this.storage.selectedIndex = nextIndex;
tr.setMeta("selectedIndex", nextIndex);
state.tr.setMeta("selectedIndex", nextIndex);
if (dispatch) updateView(state, dispatch);
return true;
},
moveToPreviousResult:
() =>
({ state, dispatch }) => {
({ state, dispatch, commands }) => {
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];
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(from),
tr.mapping.map(to)
)
);
commands.setTextSelection({ from, to });
scrollIntoView();
this.storage.selectedIndex = prevIndex;
tr.setMeta("selectedIndex", prevIndex);
state.tr.setMeta("selectedIndex", prevIndex);
if (dispatch) updateView(state, dispatch);
return true;
},
replace:
(term) =>
({ chain, tr, dispatch }) => {
({ commands, tr, dispatch }) => {
const { selectedIndex, results } = this.storage;
if (!dispatch || !results || results.length <= 0) return false;
@@ -340,7 +320,9 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
const { from, to } = results[index];
tr.insertText(term, from, to);
return chain().moveToNextResult().run();
dispatch(tr);
commands.moveToNextResult();
return true;
},
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 { getToolbarElement } from "../utils/dom";
import { getEditorContainer, 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: getToolbarElement(),
target: editor.isEditable ? getToolbarElement() : getEditorContainer(),
isTargetAbsolute: true,
location: "below",
location: editor.isEditable ? "below" : "top",
align: "end",
yOffset: 5
yOffset: editor.isEditable ? 5 : -50
}}
blocking={false}
focusOnRender={false}

View File

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

View File

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

View File

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

View File

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

View File

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