Compare commits

..

50 Commits

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,16 +86,16 @@ const Sort = ({ type, screen }) => {
<Button
title={
groupOptions?.sortDirection === "asc"
? groupOptions.groupBy === "abc" ||
groupOptions.sortBy === "title"
? groupOptions?.groupBy === "abc" ||
groupOptions?.sortBy === "title"
? "A - Z"
: groupOptions.sortBy === "dueDate"
: groupOptions?.sortBy === "dueDate"
? "Earliest first"
: "Old - New"
: groupOptions.groupBy === "abc" ||
groupOptions.sortBy === "title"
: groupOptions?.groupBy === "abc" ||
groupOptions?.sortBy === "title"
? "Z - A"
: groupOptions.sortBy === "dueDate"
: groupOptions?.sortBy === "dueDate"
? "Latest first"
: "New - Old"
}
@@ -134,7 +134,7 @@ const Sort = ({ type, screen }) => {
alignItems: "center"
}}
>
{groupOptions.groupBy === "abc" ? (
{groupOptions?.groupBy === "abc" ? (
<Button
type="secondary"
title="Title"
@@ -154,18 +154,18 @@ const Sort = ({ type, screen }) => {
item === "dateEdited") ? null : (
<Button
key={item}
type={groupOptions.sortBy === item ? "selected" : "plain"}
type={groupOptions?.sortBy === item ? "selected" : "plain"}
title={SORT[item]}
height={40}
iconPosition="left"
icon={groupOptions.sortBy === item ? "check" : null}
icon={groupOptions?.sortBy === item ? "check" : null}
style={{
marginRight: 10,
paddingHorizontal: 8
}}
buttonType={{
text:
groupOptions.sortBy === item
groupOptions?.sortBy === item
? colors.primary.accent
: colors.secondary.paragraph
}}
@@ -213,11 +213,11 @@ const Sort = ({ type, screen }) => {
key={item}
testID={"btn-" + item}
type={
groupOptions.groupBy === GROUP[item] ? "selected" : "plain"
groupOptions?.groupBy === GROUP[item] ? "selected" : "plain"
}
buttonType={{
text:
groupOptions.groupBy === GROUP[item]
groupOptions?.groupBy === GROUP[item]
? colors.primary.accent
: colors.secondary.paragraph
}}
@@ -235,7 +235,7 @@ const Sort = ({ type, screen }) => {
updateGroupOptions(_groupOptions);
}}
height={40}
icon={groupOptions.groupBy === GROUP[item] ? "check" : null}
icon={groupOptions?.groupBy === GROUP[item] ? "check" : null}
title={
item.slice(0, 1).toUpperCase() + item.slice(1, item.length)
}

View File

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

View File

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

View File

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

View File

@@ -351,9 +351,18 @@ export const useEditor = (
if (!unlocked)
throw new Error("Could not save note, vault is locked");
}
noteData.contentId = note?.contentId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await db.vault?.save(noteData as any);
if (typeof noteData.title === "string") {
await db.notes.add({
title: noteData.title,
id: noteData.id
});
}
noteData.contentId = note?.contentId;
if (data) {
await db.vault?.save(noteData as any);
}
clearTimeout(saveTimer);
}
@@ -696,14 +705,13 @@ export const useEditor = (
);
}
console.log("readonly state changed...", note.readonly);
useTabStore.getState().updateTab(tabId, {
readonly: note.readonly
});
}
if (data.type === "tiptap" && note && !isLocal) {
if (lastContentChangeTime.current[noteId] >= data.dateEdited) {
if (lastContentChangeTime.current[noteId] >= data.dateModified) {
return;
}
@@ -728,7 +736,7 @@ export const useEditor = (
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current?.data) {
if (_nextContent === currentContents.current[note.id]?.data) {
return;
}
lastContentChangeTime.current[note.id] = note.dateEdited;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -978,7 +978,7 @@
},
"../web": {
"name": "@notesnook/web",
"version": "3.0.0",
"version": "3.0.4",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -233,6 +233,8 @@ addEventListener("message", async (event) => {
await worker.open(event.data.dbName, event.data.async, event.data.uri);
const providerPort = createSharedServicePort(worker);
postMessage(null, [providerPort]);
self.addEventListener("beforeunload", () => worker.close());
}
});

View File

@@ -72,14 +72,18 @@ export class WaSqliteWorkerDriver implements Driver {
service.activate(
() =>
new Promise<MessagePort>((resolve) => {
new Promise<{ port: MessagePort; onclose: () => void }>((resolve) => {
console.log("initializing worker");
this.needsInitialization = true;
const worker = new Worker();
worker.addEventListener(
"message",
(event) => resolve(event.ports[0]),
(event) =>
resolve({
port: event.ports[0],
onclose: () => worker.terminate()
}),
{ once: true }
);
worker.postMessage({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -179,7 +179,7 @@ class Database {
subscriptions = new Subscriptions(this.tokenManager);
offers = new Offers();
debug = new Debug();
pricing = new Pricing();
pricing = Pricing;
user = new UserManager(this);
syncer = new Sync(this);

View File

@@ -92,7 +92,10 @@ class MFAManager {
}
async sendCode(method: "sms" | "email") {
const token = await this.tokenManager.getAccessToken();
const token = await this.tokenManager.getAccessToken([
"IdentityServerApi",
"auth:grant_types:mfa"
]);
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";
type Product = {
export type Product = {
country: string;
countryCode: string;
sku?: string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,15 @@ 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",
@@ -79,10 +88,14 @@ class TokenManager {
return scopes.includes("offline_access") && Boolean(refresh_token);
}
async getAccessToken(forceRenew = false) {
async getAccessToken(
scopes: Scope[] = ["notesnook.sync", "IdentityServerApi"],
forceRenew = false
) {
return await getSafeToken(async () => {
const token = await this.getToken(true, forceRenew);
if (!token || token.scope.includes("auth:grant_types")) return;
if (!token || !token.scope) return;
if (!scopes.some((s) => token.scope.includes(s))) return;
return token.access_token;
}, "Error getting access token:");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -160,6 +160,7 @@ type AsyncOrSyncResult<Async extends boolean, Response> = Async extends true
: Response;
export interface DatabaseCollection<T, IsAsync extends boolean> {
type: keyof DatabaseSchema;
clear(): Promise<void>;
init(): Promise<void>;
upsert(item: T): Promise<void>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -4376,7 +4376,7 @@
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==",
"devOptional": true
"dev": true
},
"node_modules/@types/q": {
"version": "1.5.8",
@@ -4400,7 +4400,7 @@
"version": "18.2.39",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz",
"integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==",
"devOptional": true,
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -4435,7 +4435,7 @@
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
"devOptional": true
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.6",
@@ -9736,7 +9736,7 @@
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"devOptional": true,
"dev": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -17822,20 +17822,6 @@
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",

View File

@@ -478,7 +478,8 @@ const Tiptap = ({
<p
style={{
width: "90%",
fontSize: "0.9rem"
fontSize: "0.9rem",
color: colors.primary.paragraph
}}
>
<ol>

View File

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

View File

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

View File

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

View File

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

View File

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