Compare commits

...

100 Commits

Author SHA1 Message Date
Abdullah Atta
b7bd06c9f3 Release v3.3.22 (#9952)
* web: fix file size is 0 error on attachment upload (#9951)

* web: bump version to 3.3.22
2026-06-06 09:48:03 +05:00
Abdullah Atta
766a8f28d4 web: bump version to 3.3.22 2026-06-06 09:22:20 +05:00
Abdullah Atta
86512b5ce7 web: fix file size is 0 error on attachment upload (#9951) 2026-06-06 09:10:29 +05:00
Ammar Ahmed
a820fcde53 editor: allow any width/height value in EmbedSizeOptions when inserting embed (#9925)
* editor: allow any width/height value in EmbedSizeOptions when inserting embeds

* editor: only accept valid url as value

* editor: allow embeding normal website urls

* editor: check url if embedSource=url

* editor: use defaultValue prop for width/height
2026-06-04 13:16:51 +05:00
01zulfi
5ddf1eddf5 web: validate max date when creating reminder (#9937)
* web: validate max date when creating reminder
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: skip max date validation on repeat mode in reminder dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-04 13:16:01 +05:00
Asad Ali
4237d2eb4a desktop: fix spell check issue for non-latin languages #9423 (#9443)
* desktop: fix spell check issue for non-latin languages #9423

Signed-off-by: Asad Ali <asadali.wolf@gmail.com>

* Delete apps/desktop/src/api/spell-checker.test.ts

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

* desktop: refactor

---------

Signed-off-by: Asad Ali <asadali.wolf@gmail.com>
Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-06-04 09:36:34 +05:00
Ammar Ahmed
f67cd503d6 Merge pull request #9922 from streetwriters/fix/400
mobile: hide toc button if no headings in editor
2026-06-04 08:54:08 +05:00
Ammar Ahmed
14039d6a1e Merge pull request #9921 from streetwriters/fix/401
Fix long tag name does not wrap properly in editor
2026-06-04 08:53:50 +05:00
Ammar Ahmed
d1fed9072e Merge pull request #9890 from kashaf-ansari-dev/fix-duplicate-note-message
mobile: add success message after note duplication
2026-06-04 08:52:31 +05:00
kashaf-ansari-dev
b003a3ffed mobile: reduce translation diff noise
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-03 14:17:33 +05:00
kashaf-ansari-dev
55e10e4755 mobile: add success message after note duplication
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-03 14:14:07 +05:00
Ammar Ahmed
7c1dd0f79a Merge pull request #9915 from streetwriters/fix/359-360
mobile: fix unable to edit/delete reminder when accessing reminders from note properties
2026-06-03 14:03:41 +05:00
Ammar Ahmed
229cf195a8 Merge pull request #9920 from streetwriters/fix/357
Fix screen jerks when opening add reminder screen from note
2026-06-03 14:02:33 +05:00
Ammar Ahmed
38da6ac08b Merge pull request #9918 from streetwriters/fix/318
Fix themes do not load properly on mobile in theme selector
2026-06-03 14:01:58 +05:00
Ammar Ahmed
597dc587cd Merge pull request #9919 from streetwriters/fix/317
Fix subscription not active opens a sheet that keeps loading if there is an error
2026-06-03 14:01:32 +05:00
Ammar Ahmed
5c76b92789 Merge pull request #9893 from kashaf-ansari-dev/fix-publish-password-validation
mobile: validate password field before publishing note
2026-06-03 14:01:09 +05:00
Abdullah Atta
8418d5fa9c Merge pull request #9886 from streetwriters/web/notebook-tag-group-options
web: allow notebook/tag/color level group&sort preferences
2026-06-03 13:35:06 +05:00
01zulfi
cd071f8f58 web: ensure full year range is visible in reminder dialog's day picker (#9928)
* web: ensure full year range is visible in reminder dialog's day picker
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: include boundary years in day picker's fluid year dropdown
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-03 11:52:36 +05:00
01zulfi
32ddc3fa40 web: refresh tag & notebook filtered lists on item rename (#9923)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 15:27:47 +05:00
01zulfi
68d07a7862 web: fix properties panel in focus mode (#9917)
* allow properties panel to open/close when app is in focus mode

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 13:06:50 +05:00
01zulfi
2aa5162fc9 web: set edited date as max date in note creation date edit's day picker (#9924)
* web: set edited date as max date in note creation date edit's day picker
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: fix creation date not being able to set to same as edited date
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 13:02:06 +05:00
01zulfi
cf5565d09d web: don't allow note expiry to be set beyond 1 year in the future (#9901)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 08:56:21 +05:00
Ammar Ahmed
f129e0c411 mobile: hide toc button if no headings in note 2026-06-01 13:18:13 +05:00
01zulfi
bb43d80e05 Merge pull request #9916 from streetwriters/release/3.3.21
web: bump version to 3.3.21
2026-06-01 12:52:57 +05:00
Ammar Ahmed
4e4d3866df mobile: limit tag to single line only 2026-06-01 12:43:41 +05:00
Ammar Ahmed
b2088ef782 mobile: fix long tag name does not wrap properly in editor 2026-06-01 12:27:37 +05:00
Ammar Ahmed
07db14f6bf mobile: fix screen jerks when opening add reminder screen from note 2026-06-01 11:53:00 +05:00
Ammar Ahmed
82d37701c5 mobile: fix gap in buttons 2026-06-01 11:09:29 +05:00
Ammar Ahmed
b48ab3c85d mobile: fix query error on fetching themes in theme-selector 2026-06-01 10:56:35 +05:00
01zulfi
52d7d90b68 web: bump version to 3.3.21
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-01 10:21:57 +05:00
Ammar Ahmed
bb7ff4f376 mobile: show error if no active subscription is found 2026-06-01 10:13:31 +05:00
Ammar Ahmed
43b35917ef mobile: fix subscription not active screen gets stuck on loading on iOS 2026-06-01 09:32:05 +05:00
Ammar Ahmed
bcfa34ca05 mobile: fix unable to edit/delete reminder when accessing reminders from note properties 2026-06-01 08:40:30 +05:00
Abdullah Atta
aab6a36067 Do not refetch user before decrypting each chunk in sync (#9805)
* core: do not refetch user before decrypting each chunk in sync

* web: fix editor readonly state not updating in realtime
2026-05-25 19:42:03 +05:00
01zulfi
c7e0cd35e7 editor: expand collapsed nodes on search scroll if search text inside callout (#9745)
* editor: expand callout on search scroll if search text inside callout
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: expand outline list && headings on search scroll
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: expand parents & scroll into view on type

* editor: fix parents expanding on every transaction

* editor: add a small debounce to delay search

* editor: fix editor not scrolling to selected search result

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-25 19:41:06 +05:00
01zulfi
bd3edc535a web: use getGroupOptionsById & setGroupOptionsById
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-25 15:04:17 +05:00
01zulfi
1dff7a121d core: fix group options by id tests
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-25 14:50:57 +05:00
Ammar Ahmed
3f3467e3a7 mobile: add support for sorting/grouping items based on notebook/tag/color id 2026-05-25 13:21:48 +05:00
Ammar Ahmed
39bb9afa0e core: add getGroupOptionsById/setGroupOptionsById 2026-05-25 13:21:00 +05:00
kashaf-ansari-dev
886e757715 mobile: removed redundant isLocked check for password field in publish form
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-05-25 11:21:55 +05:00
Ammar Ahmed
f1124c4112 Merge pull request #9896 from streetwriters/ci-fix-ios-workflow
ci: fix ios workflow
2026-05-25 10:18:54 +05:00
Ammar Ahmed
a1575ab5ea ci: fix 2026-05-25 10:18:16 +05:00
Ammar Ahmed
d31eaef6d4 ci: update 2026-05-25 10:17:22 +05:00
Ammar Ahmed
930e31d109 ci: update 2026-05-25 08:44:08 +05:00
Ammar Ahmed
2a6cc90592 ci: fix missing ios platform error 2026-05-25 08:43:46 +05:00
Ammar Ahmed
a13962f8c9 ci: fix ios workflow 2026-05-23 13:49:15 +05:00
kashaf-ansari-dev
5c93645969 mobile: migrate publish inputs to FormInput with validation
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-05-23 11:03:00 +05:00
01zulfi
a4c8259a02 web: bump version to 3.3.20 (#9888)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 20:28:04 +05:00
kashaf-ansari-dev
07defdfa78 mobile: validate password field before publishing note
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-05-22 17:15:38 +05:00
01zulfi
fc93a39164 web: fix weird scroll when toggling switch in attach files dialog (#9891)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 15:46:36 +05:00
01zulfi
9b588c50bf web: fix day picker to allow selecting all months within min/max range (#9879)
* web: fix day picker to allow selecting all months within min/max range
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: disable month nav buttons if they exceed min/max range in DayPicker
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 11:07:21 +05:00
01zulfi
27b00d4ef9 web: allow notebook/tag level group&sort preferences
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 10:48:27 +05:00
Ammar Ahmed
91e0437554 Merge pull request #9872 from streetwriters/mobile/release-3.3.24
mobile: release v3.3.24
2026-05-20 09:46:10 +05:00
01zulfi
4dcf14fce1 core: handle unknown collection types in sync (#9878)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-19 13:38:20 +05:00
01zulfi
8f8ac25d20 desktop: add support for opening file links (#9784)
* desktop: add support for opening file links
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: unescape paths with spaces

* web: wrap message in confirm dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: show message box if path doesn't exist when opening file link
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: disable opening file links on flatpak
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: wrap long file paths in electron dialog
* on linux, long messages in dialog aren't wrapped, so we need to wrap manually

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: fix persistent "path not found" error

* intl: update strings

---------

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-19 13:27:46 +05:00
01zulfi
b594d58d31 web: improve attachments UX (#9718)
* web: improve attaching attachments UX
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: don't couple editor logic in attach files dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: close attach file dialog after single file && remove countdown
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show file size exceeded message in attach file dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: add 'processing' status message when file picker is opened
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show "processing" modal after picking files

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-19 13:25:17 +05:00
01zulfi
20921df3f1 Merge pull request #9869 from streetwriters/release/3.3.19
web: bump version to 3.3.19
2026-05-18 15:23:49 +05:00
01zulfi
43d3036525 web: fix subscription settings not showing
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-18 13:19:33 +05:00
Ammar Ahmed
f725499039 mobile: release 3.3.24 2026-05-18 12:47:36 +05:00
01zulfi
cd743a92cb web: bump version to 3.3.19
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-18 10:31:21 +05:00
Abdullah Atta
ea0550cb05 desktop: disable updates for portable desktop app (#9855)
* desktop: disable updates for portable desktop app

* web: hide setting group if all settings under it are hidden

* web: hide check for updates button on flatpak/snap/portable apps

* desktop: disable auto updates for flatpak/snap/portable apps
2026-05-15 18:59:01 +05:00
Ammar Ahmed
d9d5cb2168 Merge pull request #9832 from streetwriters/fix/310
mobile: fix form validation in set your name dialog
2026-05-15 08:42:32 +05:00
Ammar Ahmed
69e509da1c Merge pull request #9857 from streetwriters/fix/crash-on-upgrade
mobile: fix app crashes on upgrade from v2.6.x to v3
2026-05-15 08:41:43 +05:00
Ammar Ahmed
6ba1fda2f2 mobile: fix app crashes on upgrade from v2.6.x to v3 2026-05-14 13:43:20 +05:00
Ammar Ahmed
93efecd6c3 Merge pull request #9839 from streetwriters/mobile/release-3.3.23
mobile: release 3.3.23
2026-05-14 10:42:25 +05:00
Ammar Ahmed
70d1119cf0 mobile: fix crash on tap set your name 2026-05-14 09:39:01 +05:00
Ammar Ahmed
3f11b76811 mobile: fix form validation in set your name dialog 2026-05-14 09:38:15 +05:00
01zulfi
d912c93f81 web: fix validation error message on add notebook dialog (#9852)
* only title is required field

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-14 09:25:21 +05:00
01zulfi
e635bf628a web: fix expiring notes option on free plan (#9853)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-14 09:25:13 +05:00
01zulfi
8048c90a89 web: disable months/year menu options where applicable (#9846)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-13 11:46:23 +05:00
01zulfi
8093f44a70 web: skip attachments restore from backup if user not logged in (#9778)
* web: skip attachments restore from backup if user not logged in
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show login message when downloading attachments without login
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show backup restored success toast
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-12 09:08:26 +05:00
Ammar Ahmed
d0bfc21b49 mobile: release 3.3.23 2026-05-11 14:04:02 +05:00
01zulfi
f67f90cecd Merge pull request #9838 from streetwriters/release/3.3.18
web: bump version to 3.3.18
2026-05-11 13:47:06 +05:00
Ammar Ahmed
ff4e2631cb Merge pull request #9828 from streetwriters/fix/313
Add input validation for 2fa method configuration
2026-05-11 13:38:44 +05:00
Ammar Ahmed
9779685102 mobile: fix no success message on copying 2fa codes 2026-05-11 13:38:19 +05:00
Ammar Ahmed
f2925725b8 mobile: add input validation for 2fa method configuration 2026-05-11 13:38:17 +05:00
Ammar Ahmed
9ca9fe3f99 Merge pull request #9827 from streetwriters/fix/312
Fix copy button text is cut in 2fa settings page
2026-05-11 13:36:49 +05:00
Ammar Ahmed
8e5039fa7f Merge pull request #9831 from streetwriters/fix/289
Add form validation and error handling in change email on Mobile
2026-05-11 13:36:15 +05:00
Ammar Ahmed
2f47fe331d Merge pull request #9825 from streetwriters/fix/288
Add form validation in change password screen
2026-05-11 13:35:45 +05:00
Ammar Ahmed
238abee126 mobile: add form validation in change password screen 2026-05-11 13:35:28 +05:00
Ammar Ahmed
a8d0fa3b94 Merge pull request #9820 from streetwriters/fix/286
Fix rename attachment does not work on iOS
2026-05-11 13:33:00 +05:00
Ammar Ahmed
27b3cfcd23 Merge pull request #9819 from streetwriters/fix/285
Fix Reminder allows past time in edit mode without validation error
2026-05-11 13:32:39 +05:00
01zulfi
d3f1690dca web: bump version to 3.3.18
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-11 10:19:20 +05:00
Ammar Ahmed
d4d7eac240 mobile: fix loading indicator causes cancel button to jump 2026-05-08 10:44:05 +05:00
Ammar Ahmed
e7fb246c25 mobile: show errors under input in rename dialog 2026-05-08 10:44:04 +05:00
Ammar Ahmed
03defb3a48 mobile: fix defaultValue is used if input is empty in dialog input 2026-05-08 10:42:25 +05:00
Ammar Ahmed
73c44d476b mobile: fix rename attachment does not work on iOS 2026-05-08 10:41:53 +05:00
Ammar Ahmed
61aaaba452 mobile: cleanup 2026-05-08 10:02:57 +05:00
Ammar Ahmed
9992bf8c32 mobile: add form validation in change email 2026-05-08 09:26:11 +05:00
Ammar Ahmed
b8b7d37799 core: remap Invalid token error to Invalid code in changeEmail 2026-05-08 09:24:39 +05:00
Abdullah Atta
0aaabfab62 web: exclude hidden settings from setting search (#9772)
* web: exclude hidden settings from search

* web: add support for searching setting section groups
2026-05-08 08:53:00 +05:00
Ammar Ahmed
14d680c092 mobile: show all errors inline with inputs/data-entry items 2026-05-07 18:42:47 +05:00
Ammar Ahmed
28f95b0061 mobile: fix plans keep loading on paywall screen 2026-05-07 13:01:38 +05:00
Ammar Ahmed
419dd555f4 mobile: fix reminder allows past time in edit mode without validation error 2026-05-07 13:01:37 +05:00
Ammar Ahmed
1312ce7d50 Merge pull request #9818 from streetwriters/fix/284
Trim title before checking if title is valid in add reminder screen
2026-05-07 12:58:46 +05:00
Ammar Ahmed
d16cd510b3 mobile: cleanup 2026-05-07 12:58:05 +05:00
Ammar Ahmed
af08ed5174 mobile: show title is required error under the input 2026-05-07 12:58:05 +05:00
Ammar Ahmed
365e1d7029 mobile: trim title before checking if title is valid 2026-05-07 12:57:25 +05:00
Ammar Ahmed
80b75fc28c Merge pull request #9821 from streetwriters/fix/287
Show confirm dialog before deleting attachment in attachment manager
2026-05-07 12:54:10 +05:00
Ammar Ahmed
e023f11cd9 mobile: fix copy button text is cut in 2fa settings page 2026-05-07 09:22:59 +05:00
99 changed files with 2565 additions and 1121 deletions

View File

@@ -38,6 +38,11 @@ jobs:
with:
xcode-version: "26.1.1"
- name: Setup iOS Platform
run: |
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit

View File

@@ -19,6 +19,11 @@ jobs:
with:
xcode-version: "26.1.1"
- name: Setup iOS Platform
run: |
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit

View File

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

View File

@@ -33,16 +33,17 @@ import { AutoLaunch } from "../utils/autolaunch";
import { config, DesktopIntegration } from "../utils/config";
import { bringToFront } from "../utils/bring-to-front";
import { getTheme, setTheme, Theme } from "../utils/theme";
import { mkdirSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { dirname } from "path";
import { resolvePath } from "../utils/resolve-path";
import { observable } from "@trpc/server/observable";
import { AssetManager } from "../utils/asset-manager";
import { isFlatpak, isSnap } from "../utils";
import { isFlatpak, isPortable, isSnap } from "../utils";
import { setupDesktopIntegration } from "../utils/desktop-integration";
import { rm } from "fs/promises";
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
import { platform } from "os";
const t = initTRPC.create();
@@ -60,6 +61,7 @@ const NotificationOptions = z.object({
export const osIntegrationRouter = t.router({
isFlatpak: t.procedure.query(() => isFlatpak()),
isSnap: t.procedure.query(() => isSnap()),
isPortable: t.procedure.query(() => isPortable()),
zoomFactor: t.procedure.query(() => config.zoomFactor),
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
@@ -191,9 +193,28 @@ export const osIntegrationRouter = t.router({
}),
openPath: t.procedure
.input(z.object({ type: z.literal("path"), link: z.string() }))
.query(({ input }) => {
.query(async ({ input }) => {
if (isFlatpak()) return;
const { type, link } = input;
if (type === "path") return shell.openPath(resolvePath(link));
if (type !== "path") return;
const resolvedPath = resolvePath(
// remove leading slash from path on windows
platform() === "win32" ? link.slice(1) : link
);
if (!existsSync(resolvedPath)) {
if (globalThis.window) {
await dialog.showMessageBox(globalThis.window, {
type: "error",
title: "Path not found",
message: `The path does not exist:\n${wrapPath(resolvedPath)}`
});
}
return;
}
await shell.openPath(resolvedPath);
}),
bringToFront: t.procedure.query(() => bringToFront()),
changeTheme: t.procedure
@@ -298,3 +319,7 @@ function toMenuItem(
}
}
}
function wrapPath(path: string, maxLineLength = 100): string {
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
}

View File

@@ -84,35 +84,55 @@ const LANGUAGES: Record<string, string> = {
type Language = { code: string; name: string };
const LANGUAGE_REDIRECT_MAP: Record<string, string> = {
es: "es-MX",
"es-419": "es-MX",
"es-ES": "es-AR"
};
export const spellCheckerRouter = t.router({
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
languages: t.procedure.query(
() =>
<Language[]>(
globalThis.window?.webContents.session.availableSpellCheckerLanguages.map(
(code) => ({
code,
name: LANGUAGES[code]
})
)
)
),
enabledLanguages: t.procedure.query(
() =>
<Language[]>(
globalThis.window?.webContents.session
.getSpellCheckerLanguages()
.map((code) => ({
code,
name: LANGUAGES[code]
}))
)
),
setLanguages: t.procedure
.input(z.array(z.string()))
.mutation(({ input: languages }) =>
globalThis.window?.webContents.session.setSpellCheckerLanguages(languages)
),
languages: t.procedure.query(() => {
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
return <Language[]>available
.map((code) => ({
code,
name: LANGUAGES[code] || code
}))
.sort((a, b) => a.name.localeCompare(b.name));
}),
enabledLanguages: t.procedure.query(() => {
const enabled =
globalThis.window?.webContents.session.getSpellCheckerLanguages() || [];
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
const resolved = enabled
.map((code) => resolveLanguage(code, available))
.filter(Boolean) as string[];
return <Language[]>resolved.map((code) => ({
code,
name: LANGUAGES[code] || code
}));
}),
setLanguages: t.procedure.input(z.array(z.string())).mutation(({ input }) => {
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
const resolved = input
.map((code) => resolveLanguage(code, available))
.filter(Boolean) as string[];
globalThis.window?.webContents.session.setSpellCheckerLanguages(resolved);
}),
toggle: t.procedure
.input(z.object({ enabled: z.boolean() }))
.mutation(({ input: { enabled } }) => {
@@ -128,3 +148,16 @@ export const spellCheckerRouter = t.router({
);
})
});
function resolveLanguage(code: string, available: string[]) {
if (LANGUAGE_REDIRECT_MAP[code]) {
const working = LANGUAGE_REDIRECT_MAP[code];
return available.includes(working) ? working : code;
}
const fallback = code.split("-")[0];
return available.includes(code)
? code
: available.includes(fallback)
? fallback
: undefined;
}

View File

@@ -24,6 +24,7 @@ import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
import { z } from "zod";
import { config } from "../utils/config";
import { app } from "electron";
import { isFlatpak, isPortable, isSnap } from "../utils";
type UpdateInfo = { version: string };
type Progress = { percent: number };
@@ -31,13 +32,15 @@ type Progress = { percent: number };
const t = initTRPC.create();
let cancellationToken: CancellationToken | undefined = undefined;
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
const updatesSupported = !isFlatpak() && !isSnap() && !isPortable();
export const updaterRouter = t.router({
autoUpdates: t.procedure.query(() => config.automaticUpdates),
autoUpdates: t.procedure.query(
() => updatesSupported && config.automaticUpdates
),
releaseTrack: t.procedure.query(() => config.releaseTrack),
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
download: t.procedure.query(async () => {
if (cancellationToken) return;
if (!updatesSupported || cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<string[]>((resolve, reject) => {
downloadTimeout = setTimeout(async () => {
@@ -52,7 +55,7 @@ export const updaterRouter = t.router({
});
}),
check: t.procedure.query(async () => {
if (cancellationToken) return;
if (!updatesSupported || cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<void>((resolve) => {
downloadTimeout = setTimeout(async () => {

View File

@@ -33,3 +33,7 @@ export function isFlatpak() {
export function isSnap() {
return process.env.SNAP !== undefined;
}
export function isPortable() {
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
}

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3103
versionCode 3105
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')

View File

@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { RefObject, useEffect, useState } from "react";
import { View } from "react-native";
import { TextInput, View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
@@ -59,6 +59,7 @@ import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
import Navigation from "../../services/navigation";
import { createFormRef, validators } from "../ui/input/form-input";
const Actions = ({
attachment,
@@ -153,24 +154,48 @@ const Actions = ({
{
name: strings.rename(),
onPress: () => {
presentDialog({
input: true,
title: strings.renameFile(),
defaultValue: attachment.filename,
positivePress: async (value) => {
if (value && value.length > 0) {
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
}
return true;
},
positiveText: strings.rename()
});
close?.();
setTimeout(() => {
presentDialog({
title: strings.renameFile(),
form: {
formRef: createFormRef({
name: attachment.filename
}),
items: [
{
name: "name",
defaultValue: attachment.filename,
placeholder: strings.enterTitle(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.nameIsRequired())]
}
],
onFormSubmit: async (form) => {
try {
const value = form.getValue("name");
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
ToastManager.show({
message: `Attachment renamed to ${value}`,
type: "success"
});
return true;
} catch (e) {
form.setError("name", (e as Error).message);
return false;
}
}
},
positiveText: strings.rename()
});
}, 500);
},
icon: "form-textbox"
},

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
@@ -26,42 +27,44 @@ import { eSendEvent, ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
import { eOpenRecoveryKeyDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Paragraph from "../ui/typography/paragraph";
import { TextInput } from "react-native-gesture-handler";
export const ChangePassword = () => {
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
oldPassword: "",
password: ""
})
);
const oldPasswordInputRef = useRef<TextInput>(null);
const oldPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const passwordInputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const user = useUserStore((state) => state.user);
const changePassword = async () => {
setError(undefined);
formRef.current.clearErrors();
if (!user?.isEmailConfirmed) {
ToastManager.show({
heading: strings.emailNotConfirmed(),
message: strings.emailNotConfirmedDesc(),
type: "error",
context: "local"
});
setError(strings.emailNotConfirmedDesc());
return;
}
if (error || !oldPassword.current || !password.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
if (!formRef.current.validate()) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const result = await BackupService.run(
@@ -74,8 +77,8 @@ export const ChangePassword = () => {
}
const passwordChanged = await db.user.changePassword(
oldPassword.current,
password.current
values.oldPassword,
values.password
);
if (!passwordChanged) {
@@ -91,15 +94,15 @@ export const ChangePassword = () => {
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
} catch (e) {
const message = (e as Error).message;
setLoading(false);
ToastManager.show({
heading: strings.passwordChangeFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
if (/old password/i.test(message)) {
formRef.current.setError("oldPassword", message);
} else {
setError(message);
}
}
setLoading(false);
};
return (
@@ -110,36 +113,61 @@ export const ChangePassword = () => {
}}
>
<Dialog context="change-password-dialog" />
<Input
<FormInput
name="oldPassword"
formRef={formRef}
fwdRef={oldPasswordInputRef}
onChangeText={(value) => {
oldPassword.current = value;
}}
loading={loading}
validators={[validators.required(strings.currentPasswordRequired())]}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.oldPassword()}
placeholder={strings.currentPassword()}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
onErrorCheck={(e) => setError(e)}
loading={loading}
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
validationType="password"
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
onSubmitEditing={() => {
changePassword();
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error}
</Paragraph>
) : null}
<Notice text={strings.changePasswordNotice()} type="alert" />
<View style={{ height: 10 }} />

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { StyleSheet, View } from "react-native";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
@@ -79,6 +79,11 @@ const DialogButtons = ({
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
@@ -105,7 +110,6 @@ const DialogButtons = ({
style={{
marginLeft: 10
}}
loading={loading}
bold
type={positiveType || "transparent"}
title={positiveTitle}

View File

@@ -67,15 +67,13 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
// Handle form submission if form is available
if (dialogInfo?.form && formRef.current) {
inputRef.current?.blur();
setLoading(true);
try {
const isValid = await formRef.current.validate();
if (!isValid) {
setLoading(false);
return;
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
if (result === false) {
setLoading(false);
@@ -93,7 +91,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
let result = false;
try {
result = await dialogInfo.positivePress(
values.current.inputValue || dialogInfo.defaultValue,
values.current.inputValue,
checked
);
} catch (e) {

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
GroupHeader,
GroupingByIdKey,
GroupingKey,
GroupOptions,
ItemType
@@ -46,6 +47,8 @@ type SectionHeaderProps = {
screen?: RouteName;
groupOptions: GroupOptions;
group: GroupingKey;
groupId?: string;
type?: GroupingByIdKey;
onOpenJumpToDialog: () => void;
itemCount?: number;
};
@@ -62,7 +65,9 @@ export const SectionHeader = React.memo<
groupOptions,
group,
onOpenJumpToDialog,
itemCount
itemCount,
groupId,
type
}: SectionHeaderProps) {
const { colors } = useThemeColors();
const isCompactModeEnabled = useIsCompactModeEnabled(
@@ -143,8 +148,10 @@ export const SectionHeader = React.memo<
component: (
<Sort
screen={screen}
type={dataType}
dataType={dataType}
type={type}
group={group}
groupId={groupId}
hideGroupOptions={
screen === "Reminders" || screen === "Search"
}

View File

@@ -17,7 +17,12 @@ 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 { GroupingKey, Item, VirtualizedGrouping } from "@notesnook/core";
import {
GroupingByIdKey,
GroupingKey,
Item,
VirtualizedGrouping
} from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import { LegendList, LegendListRenderItemProps } from "@legendapp/list";
import React, { useEffect, useRef } from "react";
@@ -55,6 +60,7 @@ type ListProps = {
placeholder?: PlaceholderData;
groupType: GroupingKey;
id?: string;
type?: GroupingByIdKey;
};
const onMomentumScrollEnd = () => {
@@ -74,7 +80,7 @@ export default function List(props: ListProps) {
props.dataType === "notebook" ||
notebooksListMode === "compact";
const groupOptions = useGroupOptions(props.groupType);
const groupOptions = useGroupOptions(props.groupType, props.id, props.type);
const _onRefresh = async () => {
Sync.run("global", false, "full", () => {
@@ -96,23 +102,27 @@ export default function List(props: ListProps) {
index={itemProps.index}
isSheet={props.isRenderedInActionSheet || false}
items={props.data}
groupId={props.id}
groupOptions={groupOptions}
group={props.groupType as GroupingKey}
renderedInRoute={props.renderedInRoute}
customAccentColor={props.customAccentColor}
dataType={props.dataType}
type={props.type}
scrollRef={scrollRef}
/>
);
},
[
groupOptions,
props.groupType,
props.customAccentColor,
props.data,
props.dataType,
props.isRenderedInActionSheet,
props.renderedInRoute
props.data,
props.id,
props.groupType,
props.renderedInRoute,
props.customAccentColor,
props.dataType,
groupOptions,
props.type
]
);

View File

@@ -26,6 +26,7 @@ import {
Color,
GroupHeader,
GroupOptions,
GroupingByIdKey,
GroupingKey,
HighlightedResult,
Item,
@@ -40,7 +41,7 @@ import {
} from "@notesnook/core";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
import { getGroupOptions } from "../../hooks/use-group-options";
import { useIsCompactModeEnabled } from "../../hooks/use-is-compact-mode-enabled";
import { eSendEvent } from "../../services/event-manager";
import { RouteName } from "../../stores/use-navigation-store";
@@ -49,8 +50,8 @@ import { SectionHeader } from "../list-items/headers/section-header";
import { NoteWrapper } from "../list-items/note/wrapper";
import { NotebookWrapper } from "../list-items/notebook/wrapper";
import ReminderItem from "../list-items/reminder";
import TagItem from "../list-items/tag";
import { SearchResult } from "../list-items/search-result";
import TagItem from "../list-items/tag";
type ListItemWrapperProps<TItem = Item> = {
group: GroupingKey;
@@ -62,6 +63,8 @@ type ListItemWrapperProps<TItem = Item> = {
dataType: string;
scrollRef: any;
groupOptions: GroupOptions;
groupId?: string;
type?: GroupingByIdKey;
};
export function ListItemWrapper(props: ListItemWrapperProps) {
@@ -183,6 +186,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
dataType={item.type}
group={group}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -220,6 +225,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
dataType={item.type}
group={group}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -250,6 +257,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
group={group}
dataType={item.type}
color={props.customAccentColor}
type={props.type}
groupId={props.groupId}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
@@ -276,6 +285,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
group={group}
dataType={item.type}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -303,6 +314,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
group={group}
dataType={item.type}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
itemCount={items?.placeholders.length}
@@ -322,11 +335,16 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
}
}
function getDate(item: Notebook | Note, groupType?: GroupingKey): number {
function getDate(
item: Notebook | Note,
groupType?: GroupingKey,
id?: string,
type?: GroupingByIdKey
): number {
return (
getSortValue(
groupType
? db.settings.getGroupOptions(groupType)
? getGroupOptions(groupType, id, type)
: {
sortBy: "dateEdited",
sortDirection: "desc"

View File

@@ -45,12 +45,15 @@ import { DefaultAppStyles } from "../../../utils/styles";
import DialogHeader from "../../dialog/dialog-header";
import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import Input from "../../ui/input";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { useAsync } from "react-async-hook";
import { eMenuItemUpdate } from "../../../utils/events";
import { useIsFeatureAvailable } from "@notesnook/common";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
async function fetchMonographData(noteId: string) {
const monographId = db.monographs.monograph(noteId);
@@ -76,26 +79,31 @@ const PublishNoteSheet = ({
const isFeatureAvailable = useIsFeatureAvailable("monographAnalytics");
const [isLocked, setIsLocked] = useState(false);
const [publishing, setPublishing] = useState(false);
const customTitle = useRef<string>("");
const pwdInput = useRef<TextInput>(null);
const titleInput = useRef<TextInput>(null);
const passwordValue = useRef<string>(undefined);
const monographData = useAsync(async () => {
return fetchMonographData(note?.id);
}, []);
const monograph = monographData.result?.monograph;
customTitle.current = monograph?.title || note.title || "";
const publishUrl = monograph && `${hosts.MONOGRAPH_HOST}/${monograph?.id}`;
const isPublished = db.monographs.monograph(note?.id);
const formRef = useRef(
createFormRef({
title: monograph?.title || note.title || "",
password: ""
})
);
useEffect(() => {
(async () => {
if (monograph) {
setSelfDestruct(!!monograph?.selfDestruct);
if (monograph.password) {
passwordValue.current = await db.monographs.decryptPassword(
const password = await db.monographs.decryptPassword(
monograph?.password
);
formRef.current.setValue("password", password);
setIsLocked(!!monograph?.password);
}
}
@@ -104,20 +112,23 @@ const PublishNoteSheet = ({
const publishNote = async () => {
if (publishing) return;
formRef.current.clearErrors();
if (!formRef.current.validate()) return;
const values = formRef.current.getValues();
setPublishLoading(true);
try {
if (note?.id) {
if (isLocked && !passwordValue.current) return;
await db.monographs.publish(note.id, customTitle.current, {
selfDestruct: selfDestruct,
password: isLocked ? passwordValue.current : undefined
await db.monographs.publish(note.id, values.title, {
selfDestruct,
password: isLocked ? values.password : undefined
});
await monographData.execute();
Navigation.queueRoutesForUpdate();
eSendEvent(eMenuItemUpdate);
setPublishLoading(false);
}
requestInAppReview();
} catch (e) {
@@ -127,9 +138,9 @@ const PublishNoteSheet = ({
type: "error",
context: "local"
});
} finally {
setPublishLoading(false);
}
setPublishLoading(false);
};
const setPublishLoading = (value: boolean) => {
setPublishing(value);
@@ -247,11 +258,12 @@ const PublishNoteSheet = ({
</TouchableOpacity>
) : null}
<Input
<FormInput
name="title"
formRef={formRef}
fwdRef={titleInput}
onChangeText={(value) => (customTitle.current = value)}
defaultValue={customTitle.current}
placeholder={strings.noteTitle()}
validators={[validators.required(strings.titleIsRequired())]}
/>
<TouchableOpacity
@@ -298,13 +310,16 @@ const PublishNoteSheet = ({
{isLocked ? (
<>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={pwdInput}
onChangeText={(value) => (passwordValue.current = value)}
blurOnSubmit
secureTextEntry
defaultValue={passwordValue.current}
placeholder={strings.enterPassword()}
validators={[
validators.required(strings.passwordRequired())
]}
containerStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}

View File

@@ -1,173 +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 { Item, ItemReference, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { RefObject, useEffect, useState } from "react";
import { View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../../common/database";
import {
PresentSheetOptions,
presentSheet
} from "../../../services/event-manager";
import { useRelationStore } from "../../../stores/use-relation-store";
import { AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import DialogHeader from "../../dialog/dialog-header";
import List from "../../list";
import SheetProvider from "../../sheet-provider";
import { Button, ButtonProps } from "../../ui/button";
import Paragraph from "../../ui/typography/paragraph";
type RelationsListProps = {
actionSheetRef: RefObject<ActionSheetRef>;
close?: () => void;
update?: (options: PresentSheetOptions) => void;
item: { id: string; type: string };
referenceType: string;
relationType: "to" | "from";
title: string;
button?: ButtonProps;
onAdd: () => void;
};
const IconsByType = {
reminder: "bell"
};
export const RelationsList = ({
actionSheetRef,
item,
referenceType,
relationType,
title,
button,
onAdd
}: RelationsListProps) => {
const updater = useRelationStore((state) => state.updater);
const { colors } = useThemeColors();
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
const hasNoRelations = !items || items?.placeholders?.length === 0;
useEffect(() => {
db.relations?.[relationType]?.(
{ id: item?.id, type: item?.type } as ItemReference,
referenceType as any
)
.selector.sorted({
sortBy: "dateEdited",
sortDirection: "desc"
})
.then((grouped) => {
setTimeout(() => {
setItems(grouped);
}, 300);
});
}, [relationType, referenceType, item?.id, item?.type, updater]);
return (
<View style={{ paddingHorizontal: DefaultAppStyles.GAP, height: "100%" }}>
<SheetProvider context="local" />
<DialogHeader
title={title}
button={hasNoRelations ? undefined : button}
/>
{hasNoRelations ? (
<View
style={{
height: "85%",
justifyContent: "center",
alignItems: "center"
}}
>
<Icon
name={IconsByType[referenceType as keyof typeof IconsByType]}
size={60}
color={colors.primary.icon}
/>
<Paragraph>{strings.noLinksFound()}</Paragraph>
<Button
onPress={() => {
onAdd?.();
}}
fontSize={AppFontSize.sm}
// width="100%"
type="inverted"
icon="plus"
title={strings.addItem(
referenceType as "notebook" | "tag" | "reminder" | "note"
)}
/>
</View>
) : (
<List
data={items}
loading={false}
groupType={
referenceType === "note"
? "notes"
: referenceType === "tag"
? "tags"
: referenceType === "notebook"
? "notebooks"
: referenceType === "reminder"
? "reminders"
: "notes"
}
dataType={referenceType as any}
isRenderedInActionSheet={true}
/>
)}
</View>
);
};
RelationsList.present = ({
reference,
referenceType,
relationType,
title,
button,
onAdd
}: {
reference: { id: string; type: string };
referenceType: string;
relationType: "to" | "from";
title: string;
button?: ButtonProps;
onAdd: () => void;
}) => {
presentSheet({
component: (ref, close, update) => (
<RelationsList
actionSheetRef={ref}
close={close}
update={update}
item={reference}
referenceType={referenceType}
relationType={relationType}
title={title}
button={button}
onAdd={onAdd}
/>
)
});
};

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
GroupingByIdKey,
GroupingKey,
GroupOptions,
ItemType,
@@ -27,7 +28,10 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { View } from "react-native";
import { db } from "../../../common/database";
import {
getGroupOptions,
setGroupOptionsById
} from "../../../hooks/use-group-options";
import { eSendEvent } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { RouteName } from "../../../stores/use-navigation-store";
@@ -43,20 +47,24 @@ import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
const Sort = ({
type,
dataType,
screen,
hideGroupOptions,
group: groupType
group: groupType,
groupId,
type
}: {
type: ItemType;
dataType: ItemType;
type?: GroupingByIdKey;
screen?: RouteName;
group: GroupingKey;
hideGroupOptions?: boolean;
groupId?: string;
}) => {
const { colors } = useThemeColors();
const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(groupType)
getGroupOptions(groupType, groupId, type)
);
const getSortButtonTitle = () => {
@@ -79,16 +87,17 @@ const Sort = ({
};
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
await db.settings.setGroupOptions(groupType, _groupOptions);
console.log(groupId, type);
setGroupOptionsById(groupType, _groupOptions, groupId, type);
setGroupOptions(_groupOptions);
setTimeout(() => {
if (screen) Navigation.queueRoutesForUpdate(screen);
if (type === "notebook") {
if (dataType === "notebook") {
useNotebookStore.getState().refresh();
} else if (type === "tag") {
} else if (dataType === "tag") {
useTagStore.getState().refresh();
}
eSendEvent(eGroupOptionsUpdated, groupType);
eSendEvent(eGroupOptionsUpdated, groupType, groupId, type);
eSendEvent(refreshNotesPage);
}, 1);
};

View File

@@ -493,7 +493,7 @@ const TabBar = (props: SimpleTabBarProps) => {
presentSheet({
component: (
<Sort
type={
dataType={
props.navigationState.index === 1
? "notebook"
: "tag"

View File

@@ -22,6 +22,7 @@ import {
ColorValue,
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle
@@ -220,6 +221,7 @@ interface FormInputProps extends TextInputProps {
inputStyle?: TextInputProps["style"];
containerStyle?: ViewStyle;
wrapperStyle?: ViewStyle;
errorStyle?: TextStyle;
}
export function FormInput({
@@ -245,6 +247,7 @@ export function FormInput({
onBlur,
onPress,
onChangeText,
errorStyle,
...restProps
}: FormInputProps) {
const { colors, isDark } = useThemeColors();
@@ -391,7 +394,7 @@ export function FormInput({
{fieldError ? (
<Paragraph
size={AppFontSize.xs}
style={{ marginTop: 5, color: colors.error.icon }}
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
>
<AppIcon
color={colors.error.accent}

View File

@@ -45,11 +45,11 @@ import ExportNotesSheet from "../components/sheets/export-notes";
import PaywallSheet from "../components/sheets/paywall";
import PublishNoteSheet from "../components/sheets/publish-note";
import { ReferencesList } from "../components/sheets/references";
import { RelationsList } from "../components/sheets/relations-list/index";
import { useSideBarDraggingStore } from "../components/side-menu/dragging-store";
import { ButtonProps } from "../components/ui/button";
import AddReminder from "../screens/add-reminder";
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
import RelationsList from "../screens/relations-list";
import {
eSendEvent,
eSubscribeEvent,
@@ -368,7 +368,14 @@ export const useActions = ({
inputPlaceholder: strings.name(),
defaultValue: item.title,
positivePress: async (value) => {
if (!value || value.trim().length === 0) return;
if (!value || value.trim().length === 0) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
await db.colors.add({
id: item.id,
title: value
@@ -777,6 +784,12 @@ export const useActions = ({
const duplicateNote = async () => {
await db.notes.duplicate(item.id);
Navigation.queueRoutesForUpdate();
ToastManager.show({
heading: strings.noteDuplicated(),
type: "success",
context: "local"
});
await sleep(500);
close();
};
@@ -1040,8 +1053,9 @@ export const useActions = ({
title: strings.dataTypesPluralCamelCase.reminder(),
icon: "clock-outline",
onPress: async () => {
close();
RelationsList.present({
reference: item,
item,
referenceType: "reminder",
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),
@@ -1058,26 +1072,6 @@ export const useActions = ({
}
AddReminder.present(undefined, item);
close();
},
button: {
type: "plain",
onPress: async () => {
if (features && !features.activeReminders.isAllowed) {
ToastManager.show({
type: "info",
message: features.activeReminders.error,
actionText: strings.upgrade(),
func: () => {
PaywallSheet.present(features.activeReminders);
}
});
return;
}
AddReminder.present(undefined, item);
close();
},
icon: "plus",
iconSize: 20
}
});
},
@@ -1121,8 +1115,9 @@ export const useActions = ({
id: "add-reminder",
title: strings.remindMe(),
icon: "clock-plus-outline",
onPress: () => {
onPress: async () => {
close();
await sleep(100);
AddReminder.present(undefined, item);
}
},

View File

@@ -16,28 +16,61 @@ 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 { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { db } from "../common/database";
import { eSubscribeEvent, eUnSubscribeEvent } from "../services/event-manager";
import Navigation from "../services/navigation";
import { eGroupOptionsUpdated } from "../utils/events";
import { useSettingStore } from "../stores/use-setting-store";
import { GroupingByIdKey, GroupingKey, GroupOptions } from "@notesnook/core";
export function useGroupOptions(type: any) {
export function getGroupOptions(
groupingKey: GroupingKey,
id?: string,
type?: GroupingByIdKey
) {
return id && type
? db.settings?.getGroupOptionsById(id, type)
: db.settings?.getGroupOptions(groupingKey);
}
export function setGroupOptionsById(
groupingKey: GroupingKey,
groupOptions: GroupOptions,
id?: string,
type?: GroupingByIdKey
) {
return id && type
? db.settings?.setGroupOptionsById(id, type, groupOptions)
: db.settings?.setGroupOptions(groupingKey, groupOptions);
}
export function useGroupOptions(
groupingKey: GroupingKey,
id?: string,
type?: GroupingByIdKey
) {
const appLoading = useSettingStore((state) => state.isAppLoading);
const [groupOptions, setGroupOptions] = useState(
db.settings?.getGroupOptions(type)
getGroupOptions(groupingKey, id, type)
);
console.log(groupingKey, id, type, groupOptions, "options");
const groupOptionsRef = useRef(groupOptions);
groupOptionsRef.current = groupOptions;
useEffect(() => {
const onUpdate = (groupType: string) => {
if (groupType !== type) return;
const options = db.settings?.getGroupOptions(type) as any;
const onUpdate = (_groupingKey: string, _id?: string, _type?: string) => {
if (_groupingKey !== groupingKey) return;
if (_id && _type && _id !== id && _type !== type) return;
const options = getGroupOptions(groupingKey, id, type);
if (!options) return;
if (
groupOptions?.groupBy !== options.groupBy ||
groupOptions?.sortBy !== options.sortBy ||
groupOptions?.sortDirection !== groupOptions?.sortDirection
groupOptionsRef.current?.groupBy !== options.groupBy ||
groupOptionsRef.current?.sortBy !== options.sortBy ||
groupOptionsRef.current?.sortDirection !== options?.sortDirection
) {
console.log("onUpdate", _id, _type);
setGroupOptions({ ...options });
Navigation.queueRoutesForUpdate();
}
@@ -46,13 +79,13 @@ export function useGroupOptions(type: any) {
eSubscribeEvent(eGroupOptionsUpdated, onUpdate);
if (!appLoading) {
onUpdate(type);
onUpdate(groupingKey);
}
return () => {
eUnSubscribeEvent(eGroupOptionsUpdated, onUpdate);
};
}, [type, groupOptions, appLoading]);
}, [groupingKey, appLoading, id, type]);
return groupOptions;
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Plan, SubscriptionPlan } from "@notesnook/core";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useAsync } from "react-async-hook";
import { Platform } from "react-native";
import Config from "react-native-config";
@@ -181,6 +181,8 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
const [cancelPromo, setCancelPromo] = useState(false);
const [userCanRequestTrial, setUserCanRequestTrial] = useState(false);
const [webPricingPlans, setWebPricingPlans] = useState<Plan[]>([]);
const plansRef = useRef(plans);
plansRef.current = plans;
const getProduct = React.useCallback(
(planId: string, skuId: string) => {
@@ -190,11 +192,11 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
);
return (
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plans.find((p) => p.id === planId)?.products?.[skuId]
plansRef.current.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plansRef.current.find((p) => p.id === planId)?.products?.[skuId]
);
},
[plans, webPricingPlans]
[webPricingPlans]
);
const getProductAndroid = (planId: string, skuId: string) => {
@@ -253,35 +255,41 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
useEffect(() => {
const loadPlans = async () => {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find((p) => p.productId === sku);
try {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find(
(p) => p.productId === sku
);
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find(
(p) => p.productId === sku
);
});
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find((p) => p.productId === sku);
});
});
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
empty */
}
}
}
setLoadingPlans(false);
setLoadingPlans(false);
} catch (e) {}
};
loadPlans();
}, [options?.promoOffer, cancelPromo, hasTrialOffer]);

View File

@@ -279,6 +279,7 @@ let MoveNotes: any = null;
let Settings: any = null;
let ManageTags: any = null;
let AddReminder: any = null;
let RelationsList: any = null;
let PayWall: any = null;
let Wrapped: any = null;
export const RootNavigation = () => {
@@ -385,6 +386,15 @@ export const RootNavigation = () => {
return AddReminder;
}}
/>
<RootStack.Screen
name="RelationsList"
getComponent={() => {
RelationsList =
RelationsList || require("../screens/relations-list").default;
return RelationsList;
}}
/>
<RootStack.Screen
name="PayWall"
getComponent={() => {

View File

@@ -35,7 +35,6 @@ import { db } from "../../common/database";
import { Dialog } from "../../components/dialog";
import { Header } from "../../components/header";
import { Button } from "../../components/ui/button";
import Input from "../../components/ui/input";
import { ReminderTime } from "../../components/ui/reminder-time";
import Paragraph from "../../components/ui/typography/paragraph";
import { DDS } from "../../services/device-detection";
@@ -59,6 +58,11 @@ import { TimeSince } from "../../components/ui/time-since";
import Heading from "../../components/ui/typography/heading";
import { eOnLoadNote } from "../../utils/events";
import { fluidTabsRef } from "../../utils/global-refs";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import AppIcon from "../../components/ui/AppIcon";
const ReminderModes =
Platform.OS === "ios"
@@ -91,7 +95,17 @@ const ReminderNotificationModes = {
export default function AddReminder(props: NavigationProps<"AddReminder">) {
const { reminder, reference } = props.route.params;
useNavigationFocus(props.navigation, { focusOnInit: true });
useNavigationFocus(props.navigation, {
focusOnInit: true,
onFocus: () => {
if (!props.route.params?.reminder) {
setTimeout(() => {
titleRef.current?.focus();
}, 200);
}
return false;
}
});
const { colors, isDark } = useThemeColors();
const weekFormat = useSettingStore((state) => state.weekFormat);
const [reminderMode, setReminderMode] = useState<Reminder["mode"]>(
@@ -113,7 +127,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const [repeatFrequency, setRepeatFrequency] = useState(1);
const referencedItem = reference ? (reference as Note) : null;
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
const formRef = useRef(
createFormRef({
title: reminder?.title || referencedItem?.title || "",
description: reminder?.description || referencedItem?.headline || ""
})
);
const title = useRef<string | undefined>(
!reminder ? referencedItem?.title : reminder?.title
);
@@ -132,6 +151,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
: null,
[reminder?.id]
);
const [dateError, setDateError] = useState<string>();
const [selectDayError, setSelectDayError] = useState<string>();
const showDatePicker = () => {
setDatePickerVisibility(true);
@@ -143,6 +164,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const handleConfirm = (date: Date) => {
timer.current = setTimeout(() => {
setDateError(undefined);
hideDatePicker();
setDate(date);
}, 10);
@@ -172,26 +194,27 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
async function saveReminder() {
try {
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!formRef.current.validate()) return;
if (date.getTime() < Date.now() && reminderMode === "once") {
setDateError(strings.dateError());
return;
}
if (
reminderMode === ReminderModes.Repeat &&
recurringMode !== "day" &&
recurringMode !== "year" &&
selectedDays.length === 0
)
throw new Error(strings.selectDayError());
if (!title.current) throw new Error(strings.setTitleError());
if (
date.getTime() < Date.now() &&
reminderMode === "once" &&
!props.route.params.reminder
) {
throw new Error(strings.dateError());
setSelectDayError(strings.selectDayError());
return;
}
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
date.setSeconds(0, 0);
const reminderId = await db.reminders?.add({
@@ -261,21 +284,26 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<Input
<FormInput
name="title"
validators={[validators.required(strings.titleIsRequired())]}
formRef={formRef}
fwdRef={titleRef}
defaultValue={reminder?.title || referencedItem?.title}
placeholder={strings.remindeMeOf()}
onChangeText={(text) => (title.current = text)}
autoFocus
wrapperStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
onSubmit={() => {
onSubmitEditing={() => {
descriptionRef.current?.focus();
}}
/>
<Input
<FormInput
name="description"
validators={[]}
formRef={formRef}
defaultValue={
reminder ? reminder?.description : referencedItem?.headline
}
@@ -461,6 +489,22 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
/>
))}
</ScrollView>
{selectDayError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{selectDayError}
</Paragraph>
) : null}
</View>
) : null}
@@ -476,16 +520,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="datetime"
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
onConfirm={handleConfirm}
onCancel={hideDatePicker}
isDarkModeEnabled={isDark}
firstDayOfWeek={weekFormat === "Mon" ? 1 : 0}
is24Hour={db.settings.getTimeFormat() === "24-hour"}
date={date || new Date(Date.now())}
themeVariant={isDark ? "dark" : "light"}
/>
<DatePicker
date={date}
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
maximumDate={dayjs(date).add(3, "months").toDate()}
onDateChange={handleConfirm}
theme={isDark ? "dark" : "light"}
@@ -519,6 +570,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
}}
/>
)}
{dateError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{dateError}
</Paragraph>
) : null}
</View>
)}

View File

@@ -46,7 +46,6 @@ import EditorTabs from "../../../components/sheets/editor-tabs";
import { Issue } from "../../../components/sheets/github/issue";
import LinkNote from "../../../components/sheets/link-note";
import PaywallSheet from "../../../components/sheets/paywall";
import { RelationsList } from "../../../components/sheets/relations-list";
import TableOfContents from "../../../components/sheets/toc";
import { DDS } from "../../../services/device-detection";
import {
@@ -80,6 +79,7 @@ import { fluidTabsRef } from "../../../utils/global-refs";
import { sleep } from "../../../utils/time";
import AddReminder from "../../add-reminder";
import ManageTags from "../../manage-tags";
import RelationsList from "../../relations-list";
import { useDragState } from "../../settings/editor/state";
import { EditorMessage, EditorProps, useEditorType } from "./types";
import { useTabStore } from "./use-tab-store";
@@ -466,7 +466,7 @@ export const useEditorEvents = (
const note = await db.notes.note(noteId);
if (!note) return;
RelationsList.present({
reference: note as any,
item: note as any,
referenceType: "reminder",
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),

View File

@@ -41,6 +41,7 @@ import { View } from "react-native";
import { Notebooks } from "../../components/sheets/notebooks";
import { useSettingStore } from "../../stores/use-setting-store";
import { rootNavigatorRef } from "../../utils/global-refs";
import { getGroupOptions } from "../../hooks/use-group-options";
const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
const [notes, setNotes] = useState<VirtualizedGrouping<Note>>();
@@ -120,7 +121,9 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
params.current.id = notebook.id;
const notes = await db.relations
.from(notebook, "note")
.selector.grouped(db.settings.getGroupOptions("notes"));
.selector.grouped(
getGroupOptions("notes", notebook.id, "notebook")
);
setNotes(notes);
await notes.item(0, resolveItems);
syncWithNavigation();
@@ -190,6 +193,7 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
onRequestUpdate();
}}
id={params.current?.id}
type="notebook"
renderedInRoute="Notebook"
headerTitle={notebook?.title}
loading={loading}

View File

@@ -26,6 +26,7 @@ import useNavigationStore, {
NotesScreenParams
} from "../../stores/use-navigation-store";
import { PLACEHOLDER_DATA, openEditor } from "./common";
import { getGroupOptions } from "../../hooks/use-group-options";
export const ColoredNotes = ({
navigation,
route
@@ -52,7 +53,7 @@ ColoredNotes.get = async (params: NotesScreenParams, grouped = true) => {
return await db.relations
.from({ id: params.id, type: "color" }, "note")
.selector.grouped(db.settings.getGroupOptions("notes"));
.selector.grouped(getGroupOptions("notes", params.id, "color"));
};
ColoredNotes.navigate = (item: Color, canGoBack: boolean) => {

View File

@@ -18,8 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { resolveItems } from "@notesnook/common";
import { Tag, VirtualizedGrouping } from "@notesnook/core";
import { Color, Note } from "@notesnook/core";
import { Color, Note, Tag, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import React, { useEffect, useRef, useState } from "react";
import { db } from "../../common/database";
import { FloatingButton } from "../../components/container/floating-button";
@@ -39,10 +39,9 @@ import useNavigationStore, {
NotesScreenParams,
RouteName
} from "../../stores/use-navigation-store";
import { setOnFirstSave } from "./common";
import { strings } from "@notesnook/intl";
import { useSettingStore } from "../../stores/use-setting-store";
import { rootNavigatorRef } from "../../utils/global-refs";
import { setOnFirstSave } from "./common";
export interface RouteProps<T extends RouteName> extends NavigationProps<T> {
get: (
@@ -172,7 +171,7 @@ const NotesPage = ({
if (loadingNotes) {
onRequestUpdate(params.current);
}
}, [loadingNotes, get, isAppLoading]);
}, [loadingNotes, get, isAppLoading, onRequestUpdate]);
useEffect(() => {
eSubscribeEvent(route.name, onRequestUpdate);
@@ -222,6 +221,7 @@ const NotesPage = ({
loading={false}
renderedInRoute={route.name}
id={params.current?.id}
type={params.current?.item?.type}
headerTitle={title || "Monographs"}
customAccentColor={accentColor}
placeholder={placeholder}

View File

@@ -26,6 +26,7 @@ import useNavigationStore, {
NotesScreenParams
} from "../../stores/use-navigation-store";
import { PLACEHOLDER_DATA, openEditor } from "./common";
import { getGroupOptions } from "../../hooks/use-group-options";
export const TaggedNotes = ({
navigation,
@@ -53,7 +54,7 @@ TaggedNotes.get = async (params: NotesScreenParams, grouped = true) => {
return await db.relations
.from({ id: params.id, type: "tag" }, "note")
.selector.grouped(db.settings.getGroupOptions("notes"));
.selector.grouped(getGroupOptions("notes", params.id, "tag"));
};
TaggedNotes.navigate = (item: Tag, canGoBack?: boolean) => {

View File

@@ -0,0 +1,142 @@
/*
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 { Item, ItemReference, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { Header } from "../../components/header";
import List from "../../components/list";
import { Button } from "../../components/ui/button";
import Paragraph from "../../components/ui/typography/paragraph";
import Navigation, { NavigationProps } from "../../services/navigation";
import { useRelationStore } from "../../stores/use-relation-store";
import { AppFontSize } from "../../utils/size";
type RelationsListProps = {
item: Item;
referenceType: "notebook" | "tag" | "reminder" | "note";
relationType: "to" | "from";
title: string;
onAdd?: () => void;
};
const IconsByType = {
reminder: "bell"
};
function RelationsList(props: NavigationProps<"RelationsList">) {
const { item, referenceType, relationType, title, onAdd } = props.route
.params as RelationsListProps;
const updater = useRelationStore((state) => state.updater);
const { colors } = useThemeColors();
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
const hasNoRelations = !items || items?.placeholders?.length === 0;
useEffect(() => {
db.relations?.[relationType]?.(
{ id: item?.id, type: item?.type } as ItemReference,
referenceType
)
.selector.sorted({
sortBy: "dateEdited",
sortDirection: "desc"
})
.then((grouped) => {
setTimeout(() => {
setItems(grouped);
}, 300);
});
}, [relationType, referenceType, item?.id, item?.type, updater]);
return (
<SafeAreaView
style={{
flex: 1,
backgroundColor: colors.primary.background,
width: "100%"
}}
>
<Header title={title} canGoBack />
<View style={{ flex: 1 }}>
{hasNoRelations ? (
<View
style={{
height: "85%",
justifyContent: "center",
alignItems: "center"
}}
>
<Icon
name={IconsByType[referenceType as keyof typeof IconsByType]}
size={60}
color={colors.primary.icon}
/>
<Paragraph>{strings.noLinksFound()}</Paragraph>
<Button
onPress={onAdd}
fontSize={AppFontSize.sm}
type="inverted"
icon="plus"
title={strings.addItem(referenceType)}
/>
</View>
) : (
<List
data={items}
loading={false}
groupType={
referenceType === "note"
? "notes"
: referenceType === "tag"
? "tags"
: referenceType === "notebook"
? "notebooks"
: referenceType === "reminder"
? "reminders"
: "notes"
}
dataType={referenceType}
/>
)}
</View>
</SafeAreaView>
);
}
RelationsList.present = ({
item,
relationType,
referenceType,
title,
onAdd
}: RelationsListProps) => {
Navigation.navigate("RelationsList", {
item,
relationType,
referenceType,
title,
onAdd
});
};
export default RelationsList;

View File

@@ -21,6 +21,7 @@ import { sanitizeFilename, useIsFeatureAvailable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors, VariantsWithStaticColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import isMobilePhone from "validator/lib/isMobilePhone";
import React, {
Dispatch,
SetStateAction,
@@ -28,7 +29,13 @@ import React, {
useRef,
useState
} from "react";
import { ActivityIndicator, Linking, Platform, View } from "react-native";
import {
ActivityIndicator,
Linking,
Platform,
TextInput,
View
} from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { FlatList } from "react-native-gesture-handler";
import * as ScopedStorage from "react-native-scoped-storage";
@@ -37,7 +44,10 @@ import filesystem from "../../common/filesystem";
import DialogHeader from "../../components/dialog/dialog-header";
import { Button } from "../../components/ui/button";
import { IconButton } from "../../components/ui/icon-button";
import Input from "../../components/ui/input";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { Pressable } from "../../components/ui/pressable";
import Seperator from "../../components/ui/seperator";
import { SvgView } from "../../components/ui/svg";
@@ -183,27 +193,60 @@ export const MFASetup = ({
}: MFAStepProps) => {
const { colors } = useThemeColors();
const user = useUserStore((state) => state.user);
const methodId = method?.id;
const formRef = useRef(
createFormRef({
target: "",
code: ""
})
);
const targetInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
const [authenticatorDetails, setAuthenticatorDetails] = useState({
sharedKey: null,
authenticatorUri: null
});
const code = useRef<string>(undefined);
const phoneNumber = useRef<string>(undefined);
const { seconds, setId, start } = useTimer(method?.id);
const [loading, setLoading] = useState(method?.id === "app" ? true : false);
const [enabling, setEnabling] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(false);
const [generalError, setGeneralError] = useState<string>();
useEffect(() => {
if (method?.id === "app") {
db.mfa?.setup("app").then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
});
if (methodId === "app") {
setLoading(true);
db.mfa
?.setup("app")
.then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
})
.catch((error: Error) => {
setLoading(false);
setGeneralError(error.message);
});
return;
}
}, [method?.id]);
setLoading(false);
}, [methodId]);
useEffect(() => {
if (!methodId) return;
formRef.current.clearErrors();
formRef.current.setValue(
"target",
methodId === "email"
? user?.email || ""
: methodId === "app"
? authenticatorDetails.sharedKey || ""
: formRef.current.getValue("target")
);
formRef.current.setValue("code", "");
setGeneralError(undefined);
}, [authenticatorDetails.sharedKey, methodId, user?.email]);
const codeHelpText = {
app: "After putting the above code in authenticator app, the app will display a code that you can enter below.",
@@ -212,15 +255,43 @@ export const MFASetup = ({
"You will receive a 2FA code on your email address which you can enter below"
};
const targetValidators =
method?.id === "sms"
? [
validators.required(strings.phoneNumberNotEntered()),
(value: string) =>
isMobilePhone(value, "any", {
strictMode: true
})
? undefined
: strings.enterValidPhone()
]
: method?.id === "email"
? [
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]
: [];
const codeValidators = [
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim()) ? undefined : strings.enterSixDigitCode()
];
const onNext = async () => {
if (!code.current || code.current.length !== 6) return;
if (formRef.current.validateField("code")) return;
try {
if (!method) return;
const code = formRef.current.getValue("code").trim();
setGeneralError(undefined);
setEnabling(true);
if (recovery) {
await db.mfa.enableFallback(method.id, code.current);
await db.mfa.enableFallback(method.id, code);
} else {
await db.mfa.enable(method.id, code.current);
await db.mfa.enable(method.id, code);
}
const user = await db.user.fetchUser();
@@ -229,14 +300,18 @@ export const MFASetup = ({
setEnabling(false);
} catch (e) {
const error = e as Error;
ToastManager.error(error, "Error submitting 2fa code");
formRef.current.setError("code", error.message);
setEnabling(false);
}
};
const onSendCode = async () => {
if (error) return;
if (!method || sending) return;
if (method.id !== "app" && formRef.current.validateField("target")) {
return;
}
if (method.id === "app" && authenticatorDetails.sharedKey) {
Clipboard.setString(authenticatorDetails.sharedKey);
if (authenticatorDetails.authenticatorUri) {
@@ -254,30 +329,37 @@ export const MFASetup = ({
}
try {
if (seconds) throw new Error(strings.resendCodeWait());
if (method.id === "sms" && !phoneNumber.current)
throw new Error(strings.phoneNumberNotEntered());
const target = formRef.current.getValue("target").trim();
setGeneralError(undefined);
if (seconds) {
setGeneralError(strings.resendCodeWait());
return;
}
setSending(true);
await db.mfa.setup(method?.id, phoneNumber.current);
await db.mfa.setup(method.id, method.id === "sms" ? target : undefined);
if (method.id === "sms") {
setId(method.id + phoneNumber.current);
setId(method.id + target);
}
await sleep(300);
start(
60,
method.id === "sms" ? method.id + phoneNumber.current : method.id
);
start(60, method.id === "sms" ? method.id + target : method.id);
setSending(false);
ToastManager.show({
heading: strings["2faCodeSentVia"](method.id),
type: "success",
context: "local"
});
codeInputRef.current?.focus();
} catch (e) {
setSending(false);
const error = e as Error;
ToastManager.error(error, strings.errorSend2fa());
if (method.id === "sms" || method.id === "email") {
formRef.current.setError("target", error.message);
} else {
setGeneralError(error.message);
}
}
};
@@ -316,55 +398,54 @@ export const MFASetup = ({
</View>
) : (
<>
<Input
loading={method?.id !== "sms" ? true : false}
value={
method?.id === "email"
? user?.email
: method?.id === "app"
? authenticatorDetails?.sharedKey || ""
: undefined
<FormInput
key={`${method.id}-${authenticatorDetails.sharedKey || user?.email || ""}`}
name="target"
formRef={formRef}
fwdRef={targetInputRef}
loading={method?.id !== "sms"}
editable={method.id === "sms"}
defaultValue={
method.id === "email"
? user?.email || ""
: method.id === "app"
? authenticatorDetails.sharedKey || ""
: undefined
}
multiline={method.id === "app"}
onChangeText={(value) => {
phoneNumber.current = value;
onChangeText={() => {
setGeneralError(undefined);
}}
placeholder={
method?.id === "email"
method.id === "email"
? strings.enterEmailAddress()
: "+1234567890"
}
onSubmit={() => {
onSendCode();
}}
onErrorCheck={(e) => setError(e)}
validationType={method?.id === "email" ? "email" : "phonenumber"}
onSubmitEditing={onSendCode}
validators={targetValidators}
keyboardType={
method.id == "email" ? "email-address" : "phone-pad"
}
errorMessage={
method?.id === "email"
? strings.enterValidEmail()
: strings.enterValidPhone()
method.id === "email" ? "email-address" : "phone-pad"
}
buttons={
error ? null : (
<Button
onPress={onSendCode}
loading={sending}
title={
sending
? null
: method.id === "app"
<Button
onPress={onSendCode}
loading={sending}
style={{
paddingVertical: 0,
paddingHorizontal: 0
}}
title={
sending
? null
: method.id === "app"
? strings.copy()
: `${
seconds
? strings.resendCode(seconds as number)
: strings.sendCode()
}`
}
/>
)
}
/>
}
/>
@@ -373,13 +454,22 @@ export const MFASetup = ({
</Heading>
<Paragraph>{codeHelpText[method?.id]}</Paragraph>
<Seperator />
<Input
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder="xxxxxx"
maxLength={6}
loading={loading}
textAlign="center"
keyboardType="numeric"
onChangeText={(value) => (code.current = value)}
onChangeText={() => {
setGeneralError(undefined);
}}
onSubmitEditing={onNext}
returnKeyLabel={strings.next()}
returnKeyType="done"
validators={codeValidators}
inputStyle={{
fontSize: AppFontSize.lg,
height: 60,
@@ -392,7 +482,25 @@ export const MFASetup = ({
borderWidth: 0,
width: undefined
}}
errorStyle={{
textAlign: "center"
}}
/>
{generalError ? (
<Paragraph
size={AppFontSize.sm}
style={{
color: colors.error.icon,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
textAlign: "center",
width: "100%"
}}
>
{generalError}
</Paragraph>
) : null}
<Seperator />
<Button
title={enabling ? null : strings.next()}
@@ -517,7 +625,7 @@ export const MFARecoveryCodes = ({
ToastManager.show({
heading: strings.codesCopied(),
type: "success",
context: "global"
context: "local"
});
}}
style={{

View File

@@ -16,15 +16,20 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { db } from "../../../common/database";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import { eUserLoggedIn } from "../../../utils/events";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import Input from "../../../components/ui/input";
import { Button } from "../../../components/ui/button";
import FormInput, {
createFormRef,
validators
} from "../../../components/ui/input/form-input";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { eUserLoggedIn } from "../../../utils/events";
import { DefaultAppStyles } from "../../../utils/styles";
enum EmailChangeSteps {
verify,
@@ -32,14 +37,16 @@ enum EmailChangeSteps {
}
export const ChangeEmail = () => {
const { colors } = useThemeColors();
const [step, setStep] = useState(EmailChangeSteps.verify);
const emailChangeData = useRef<{
email?: string;
password?: string;
code?: string;
}>({});
const formRef = useRef(
createFormRef({
email: "",
password: "",
code: ""
})
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const emailInputRef = useRef<TextInput>(null);
const passInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
@@ -47,44 +54,52 @@ export const ChangeEmail = () => {
const onSubmit = async () => {
try {
if (step === EmailChangeSteps.verify) {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error
)
return;
const hasEmailError = formRef.current.validateField("email");
const hasPasswordError = formRef.current.validateField("password");
if (hasEmailError || hasPasswordError) return;
const { email, password } = formRef.current.getValues();
setLoading(true);
const verified = await db.user?.verifyPassword(
emailChangeData.current.password
);
const verified = await db.user?.verifyPassword(password);
if (!verified) throw new Error(strings.passwordIncorrect());
await db.user?.sendVerificationEmail(emailChangeData.current.email);
await db.user?.sendVerificationEmail(email);
setStep(EmailChangeSteps.changeEmail);
formRef.current.clearErrors();
formRef.current.setValue("code", "");
setLoading(false);
} else {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error ||
!emailChangeData.current.code
)
return;
await db.user?.changeEmail(
emailChangeData.current.email,
emailChangeData.current.password,
emailChangeData.current.code
);
const hasCodeError = formRef.current.validateField("code");
if (hasCodeError) return;
const { email, password, code } = formRef.current.getValues();
setLoading(true);
await db.user?.changeEmail(email, password, code);
eSendEvent(eUserLoggedIn);
close?.();
ToastManager.show({
heading: strings.emailUpdated(emailChangeData.current.email),
heading: strings.emailUpdated(email),
type: "success",
context: "global"
});
Navigation.goBack();
setLoading(false);
}
} catch (e) {
setLoading(false);
ToastManager.error(e as Error);
const error = e as Error;
if (step === EmailChangeSteps.verify) {
if (error.message === strings.passwordIncorrect()) {
formRef.current.setError("password", error.message);
} else {
formRef.current.setError("email", error.message);
}
return;
} else {
formRef.current.setError("code", error.message);
return;
}
}
};
@@ -97,34 +112,54 @@ export const ChangeEmail = () => {
>
{step === EmailChangeSteps.verify ? (
<>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
placeholder={strings.enterNewEmail()}
validationType="email"
onErrorCheck={(e) => setError(e)}
onChangeText={(email) => {
emailChangeData.current.email = email;
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]}
onSubmitEditing={() => {
passInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
placeholder={strings.enterAccountPassword()}
secureTextEntry
onChangeText={(pass) => {
emailChangeData.current.password = pass;
}}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={onSubmit}
/>
</>
) : (
<>
<Input
key="code-input"
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder={strings.code()}
defaultValue=""
onChangeText={(code) => {
emailChangeData.current.code = code;
}}
keyboardType="number-pad"
autoCapitalize="none"
autoCorrect={false}
maxLength={6}
validators={[
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim())
? undefined
: strings.enterSixDigitCode()
]}
onSubmitEditing={onSubmit}
/>
</>
)}
@@ -135,8 +170,8 @@ export const ChangeEmail = () => {
loading
? undefined
: step === EmailChangeSteps.verify
? strings.verify()
: strings.changeEmail()
? strings.verify()
: strings.changeEmail()
}
type="accent"
style={{

View File

@@ -47,6 +47,14 @@ export async function verifyUser(
negativeText: closeText || strings.cancel(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const user = await db.user.getUser();
let verified = !user ? true : await db.user.verifyPassword(value);
if (verified) {
@@ -95,6 +103,14 @@ export async function verifyUserWithApplock() {
keyboardType: keyboardType,
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await validateAppLockPassword(value);
if (!verified) {
ToastManager.show({

View File

@@ -429,36 +429,61 @@ export const settingsGroups: SettingSection[] = [
{
id: "subscription-not-active",
name: strings.subscriptionNotActivated(),
hidden: () => Platform.OS !== "ios",
useHook: () => useUserStore((state) => state.user),
hidden: (user) =>
Platform.OS !== "ios" ||
(user as User)?.subscription?.plan !== SubscriptionPlan.FREE,
modifer: async () => {
if (Platform.OS === "android") return;
presentSheet({
title: strings.loadingSubscription(),
paragraph: strings.loadingSubscriptionDesc()
});
const subscriptions = await RNIap.getPurchaseHistory();
subscriptions.sort(
(a, b) => b.transactionDate - a.transactionDate
);
const currentSubscription = subscriptions[0];
presentSheet({
title: strings.notesnookPro(),
paragraph: strings.subscribedOnVerify(
new Date(currentSubscription.transactionDate).toLocaleString()
),
action: async () => {
presentSheet({
title: strings.verifySubscription(),
paragraph: strings.subscriptionVerifyWait()
try {
presentSheet({
title: strings.loadingSubscription(),
paragraph: strings.loadingSubscriptionDesc(),
progress: true
});
const subscriptions = await RNIap.getPurchaseHistory();
subscriptions.sort(
(a, b) => b.transactionDate - a.transactionDate
);
const currentSubscription = subscriptions[0];
if (
!currentSubscription ||
dayjs(currentSubscription.transactionDate).isBefore(
dayjs().subtract(30, "day")
)
) {
ToastManager.show({
message: "No active subscription found",
type: "info"
});
await PremiumService.subscriptions.verify(
currentSubscription
);
eSendEvent(eCloseSheet);
},
icon: "information-outline",
actionText: strings.verify()
});
return;
}
presentSheet({
title: strings.notesnookPro(),
paragraph: strings.subscribedOnVerify(
new Date(
currentSubscription.transactionDate
).toLocaleString()
),
action: async () => {
presentSheet({
title: strings.verifySubscription(),
paragraph: strings.subscriptionVerifyWait()
});
await PremiumService.subscriptions.verify(
currentSubscription
);
eSendEvent(eCloseSheet);
},
icon: "information-outline",
actionText: strings.verify()
});
} catch (e) {
eSendEvent(eCloseSheet);
}
},
description: strings.verifySubDesc()
},
@@ -529,6 +554,14 @@ export const settingsGroups: SettingSection[] = [
positiveText: strings.delete(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await db.user?.verifyPassword(value);
if (verified) {
setTimeout(async () => {

View File

@@ -31,7 +31,11 @@ import type {
ThemesRouter
} from "@notesnook/themes-server";
import { keepLocalCopy, pick } from "@react-native-documents/picker";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
notifyManager,
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import React, { useState } from "react";
@@ -69,7 +73,8 @@ export const themeTrpcClient = createTRPCProxyClient<ThemesRouter>({
})
]
});
notifyManager.setBatchNotifyFunction((cb) => cb());
notifyManager.setNotifyFunction((cb) => cb());
function ThemeSelector() {
const [darkTheme, lightTheme] = useThemeStore((state) => [
state.darkTheme,
@@ -80,30 +85,20 @@ function ThemeSelector() {
const themeColors = colors;
const [searchQuery, setSearchQuery] = useState<string>();
const [colorScheme, setColorScheme] = useState<string>();
const filters = [];
if (searchQuery) filters.push({ type: "term" as const, value: searchQuery });
if (colorScheme)
filters.push({ type: "colorScheme" as const, value: colorScheme });
const themes = trpc.themes.useInfiniteQuery(
{
limit: 10,
compatibilityVersion: THEME_COMPATIBILITY_VERSION,
filters: [
...(searchQuery && searchQuery !== ""
? [
{
type: "term" as const,
value: searchQuery
}
]
: []),
...(colorScheme && colorScheme !== ""
? [
{
type: "colorScheme" as const,
value: colorScheme
}
]
: [])
]
filters
},
{
keepPreviousData: true,
getNextPageParam: (lastPage) => lastPage.nextCursor
}
);
@@ -366,12 +361,14 @@ function ThemeSelector() {
>
<View
style={{
flexDirection: "row"
flexDirection: "row",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<Button
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
type={
colorScheme === "" || !colorScheme ? "accent" : "secondary"
@@ -384,7 +381,8 @@ function ThemeSelector() {
/>
<Button
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
type={colorScheme === "dark" ? "accent" : "secondary"}
title={strings.dark()}
@@ -395,7 +393,8 @@ function ThemeSelector() {
/>
<Button
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
fontSize={AppFontSize.xs}
type={colorScheme === "light" ? "accent" : "secondary"}
@@ -409,7 +408,8 @@ function ThemeSelector() {
<Button
title={strings.loadFromFile()}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
type={"secondaryAccented"}
icon="folder"

View File

@@ -29,6 +29,10 @@ import ImagePicker from "react-native-image-crop-picker";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { presentDialog } from "../../components/dialog/functions";
import {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { PlanLimits } from "../../components/sheets/plan-limits";
import AppIcon from "../../components/ui/AppIcon";
import { Button } from "../../components/ui/button";
@@ -197,19 +201,34 @@ const SettingsUserSection = ({ item }) => {
title: strings.setFullName(),
paragraph: strings.setFullNameDesc(),
positiveText: strings.save(),
input: true,
inputPlaceholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
positivePress: async (value) => {
db.settings
.setProfile({
fullName: value
})
.then(async () => {
form: {
formRef: createFormRef({
fullName: userProfile?.fullName || ""
}),
items: [
{
name: "fullName",
placeholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
validators: [
validators.required(strings.nameIsRequired())
]
}
],
onFormSubmit: async (form) => {
try {
await db.settings.setProfile({
fullName: form.getValue("fullName")
});
useUserStore.setState({
profile: db.settings.getProfile()
});
});
return true;
} catch (e) {
form.setError("fullName", e.message);
return false;
}
}
}
});
}}

View File

@@ -71,6 +71,7 @@ const routeNames = {
Archive: "Archive",
ManageTags: "ManageTags",
AddReminder: "AddReminder",
RelationsList: "RelationsList",
PayWall: "PayWall",
Wrapped: "Wrapped"
};

View File

@@ -26,7 +26,6 @@ import {
} from "../stores/use-setting-store";
import { NotesnookModule } from "../utils/notesnook-module";
import { scale, updateSize } from "../utils/size";
import { DatabaseLogger } from "../common/database";
import { useUserStore } from "../stores/use-user-store";
import ScreenGuardModule from "react-native-screenguard";
ScreenGuardModule.initSettings();
@@ -89,7 +88,6 @@ function migrateAppLock() {
biometricsAuthEnabled: true
});
}
DatabaseLogger.debug("App lock Migrated");
}
function migrateSettings(settings: SettingStore["settings"]) {

View File

@@ -108,6 +108,13 @@ export interface RouteParams extends ParamListBase {
reminder?: Reminder;
reference?: Note;
};
RelationsList: {
item: Item;
referenceType: "notebook" | "tag" | "reminder" | "note";
relationType: "to" | "from";
title: string;
onAdd?: () => void;
};
Intro: GenericRouteParam;
PayWall: {
canGoBack?: boolean;

View File

@@ -65,6 +65,14 @@ export async function unlockVault({
paragraph: paragraph,
inputPlaceholder: strings.enterPassword(),
positivePress: async (value) => {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const unlocked = await db.vault.unlock(value);
if (!unlocked) {
ToastManager.show({

View File

@@ -2235,14 +2235,35 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-screenguard (1.0.0):
- react-native-screenguard (2.0.0-beta5):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Codegen
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SDWebImage (~> 5.11.1)
- SDWebImage (~> 5.11)
- SocketRocket
- Yoga
- react-native-share-extension (2.9.5):
- React
- react-native-sodium (1.6.8):
@@ -3097,8 +3118,6 @@ PODS:
- RNNotifee/NotifeeCore (= 7.4.12)
- RNNotifee/NotifeeCore (7.4.12):
- React-Core
- RNPrivacySnapshot (1.0.0):
- React-Core
- RNReanimated (4.2.0):
- boost
- DoubleConversion
@@ -3573,7 +3592,6 @@ DEPENDENCIES:
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
- RNKeychain (from `../node_modules/react-native-keychain`)
- "RNNotifee (from `../node_modules/@ammarahmed/notifee-react-native`)"
- RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
@@ -3839,8 +3857,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-keychain"
RNNotifee:
:path: "../node_modules/@ammarahmed/notifee-react-native"
RNPrivacySnapshot:
:path: "../node_modules/react-native-privacy-snapshot"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
RNScreens:
@@ -3942,7 +3958,7 @@ SPEC CHECKSUMS:
react-native-pdf: edc236298f13f1609e42d41e45b8b6ea88ed10f9
react-native-quick-sqlite: 1ed8d3db1e22a8604d006be69f06053382e93bb0
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
react-native-screenguard: 9fc3b4ad5b97783fc0832638fae0dce51272c661
react-native-screenguard: 975d4612dce0c348b19b08a48aa23e9b68584d98
react-native-share-extension: fdc6aaab51591a2d445df239c446aaa3a99658ec
react-native-sodium: 066f76e46c9be13e9260521e3fa994937c4cdab4
react-native-theme-switch-animation: 449d6db7a760f55740505e7403ae8061debc9a7e
@@ -3995,7 +4011,6 @@ SPEC CHECKSUMS:
RNImageCropPicker: 5fd4ceaead64d8c53c787e4e559004f97bc76df7
RNKeychain: ffd0513e676445c637410b47249460cbf56bc9cb
RNNotifee: dea82c9ec44684eeeac9da85deb5eeb8ebe5937f
RNPrivacySnapshot: ccad3a548338c2f526bb7b1789af3fb0618b7d1d
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
RNScreens: ffbb0296608eb3560de641a711bbdb663ed1f6b4
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2182
IOS_MARKETING_VERSION = 3.3.22
IOS_CURRENT_PROJECT_VERSION = 2184
IOS_MARKETING_VERSION = 3.3.24
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2182
IOS_MARKETING_VERSION = 3.3.22
IOS_CURRENT_PROJECT_VERSION = 2184
IOS_MARKETING_VERSION = 3.3.24
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Staging iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2182
IOS_MARKETING_VERSION = 3.3.22
IOS_CURRENT_PROJECT_VERSION = 2184
IOS_MARKETING_VERSION = 3.3.24
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.3.22",
"version": "3.3.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.3.22",
"version": "3.3.24",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -51,10 +51,10 @@
"@sayem314/react-native-keep-awake": "^1.3.1",
"@streetwriters/kysely": "^0.27.4",
"@streetwriters/showdown": "^3.0.1-alpha.2",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.45.2",
"@trpc/react-query": "^10.45.2",
"@trpc/server": "^10.45.2",
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.45.2",
"@trpc/react-query": "10.45.2",
"@trpc/server": "10.45.2",
"@types/validator": "^13.12.2",
"absolutify": "^0.1.0",
"async-mutex": "0.5.0",
@@ -5634,9 +5634,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.41.0.tgz",
"integrity": "sha512-193R4Jp9hjvlij6LryxrB5Mpbffd2L9PeWh3KlIy/hJV4SkBOfiQZ+jc5qAZLDCrdbkA5FjGj+UoDYw6TcNnyA==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.18.0.tgz",
"integrity": "sha512-PP4mG8MD08sq64RZCqMfXMYfaj7+Oulwg7xZ/fJoEOdTZNcPIgaOkHajZvUBsNLbi/0ViMvJB4cFkL2Jg2WPbw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5644,12 +5644,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.42.0.tgz",
"integrity": "sha512-j0tiofkzE3CSrYKmVRaKuwGgvCE+P2OOEDlhmfjeZf5ufcuFHwYwwgw3j08n4WYPVZ+OpsHblcFYezhKA3jDwg==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.18.0.tgz",
"integrity": "sha512-s1kdbGMdVcfUIllzsHUqVUdktBT5uuIRgnvrqFNLjl9TSOXEoBSDrhjsGjao0INQZv8cMpQlgOh3YH9YtN6cKw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "4.41.0",
"@tanstack/query-core": "4.18.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
@@ -5657,8 +5657,8 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {

View File

@@ -1,6 +1,6 @@
{
"name": "@notesnook/mobile",
"version": "3.3.22",
"version": "3.3.24",
"private": true,
"license": "GPL-3.0-or-later",
"scripts": {
@@ -69,10 +69,10 @@
"@sayem314/react-native-keep-awake": "^1.3.1",
"@streetwriters/kysely": "^0.27.4",
"@streetwriters/showdown": "^3.0.1-alpha.2",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.45.2",
"@trpc/react-query": "^10.45.2",
"@trpc/server": "^10.45.2",
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.45.2",
"@trpc/react-query": "10.45.2",
"@trpc/server": "10.45.2",
"@types/validator": "^13.12.2",
"absolutify": "^0.1.0",
"async-mutex": "0.5.0",

View File

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

View File

@@ -231,15 +231,40 @@ export async function restoreBackupFile(backupFile: File) {
}
await db.initCollections();
} else {
const { createUnzipIterator } = await import(
"../utils/streams/unzip-stream"
);
let skipAttachments = false;
if (!useUserStore.getState().isLoggedIn) {
let hasAttachments = false;
for await (const entry of createUnzipIterator(backupFile)) {
if (
entry.name.startsWith("attachments/") &&
entry.name !== "attachments/.attachments_key"
) {
hasAttachments = true;
break;
}
}
if (hasAttachments) {
const result = await ConfirmDialog.show({
title: strings.loginToRestoreAttachments(),
message: strings.loginToRestoreAttachmentsDesc(),
positiveButtonText: strings.yes(),
negativeButtonText: strings.no()
});
if (!result) return;
skipAttachments = true;
}
}
const error = await TaskManager.startTask<Error | void>({
title: strings.restoringBackup(),
subtitle: strings.restoringBackupDesc(),
type: "modal",
action: async (report) => {
const { createUnzipIterator } = await import(
"../utils/streams/unzip-stream"
);
let cachedPassword: string | undefined = undefined;
let cachedKey: string | undefined = undefined;
// const { read, totalFiles } = await Reader(backupFile);
@@ -254,13 +279,13 @@ export async function restoreBackupFile(backupFile: File) {
isValid = true;
continue;
}
if (entry.name === "attachments/.attachments_key")
if (!skipAttachments && entry.name === "attachments/.attachments_key")
attachmentsKey = JSON.parse(await entry.text()) as
| SerializedKey
| Cipher<"base64">;
else if (entry.name.startsWith("attachments/"))
else if (!skipAttachments && entry.name.startsWith("attachments/"))
attachments.push(entry);
else entries.push(entry);
else if (!entry.name.startsWith("attachments/")) entries.push(entry);
}
if (!isValid)
console.warn(
@@ -339,6 +364,8 @@ export async function restoreBackupFile(backupFile: File) {
if (error) {
console.error(error);
showToast("error", `${strings.restoreFailed()}: ${error.message}`);
} else {
showToast("success", strings.backupRestored());
}
}
}

View File

@@ -16,6 +16,7 @@ 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 { useDatePicker } from "@rehookify/datepicker";
import { Box, Button, Flex, Text } from "@theme-ui/components";
import { useState } from "react";
@@ -31,6 +32,7 @@ type DayPickerProps = {
export function DayPicker(props: DayPickerProps) {
const { selected, sx, maxDate, minDate, onSelect } = props;
const [selectedDates, onDatesChange] = useState<Date[]>([selected]);
const {
data: { weekDays, months, years, calendars },
propGetters: { dayButton, addOffset, subtractOffset, setOffset }
@@ -44,7 +46,21 @@ export function DayPicker(props: DayPickerProps) {
maxDate,
minDate
},
years: { numberOfYears: 99 },
years: {
/**
* When minDate and maxDate are provided, we switch to "fluid" mode and calculate the number of years in a way that all valid years are visible.
*
* When not provided, use default mode and numberOfYears.
*/
numberOfYears:
minDate && maxDate
? Math.max(
Math.abs(maxDate.getFullYear() - selected.getFullYear()) + 1,
Math.abs(selected.getFullYear() - minDate.getFullYear()) + 1
) * 2
: 99,
mode: minDate && maxDate ? "fluid" : "decade"
},
calendar: {
startDay: 0
}
@@ -52,6 +68,24 @@ export function DayPicker(props: DayPickerProps) {
const { month, year, days } = calendars[0];
const currentMonthDate = months.find((m) => m.month === month)?.$date;
const isNextMonthBeyondMax =
maxDate && currentMonthDate
? new Date(
currentMonthDate.getFullYear(),
currentMonthDate.getMonth() + 1,
1
) > stripTime(maxDate)
: false;
const isPrevMonthBeforeMin =
minDate && currentMonthDate
? new Date(
currentMonthDate.getFullYear(),
currentMonthDate.getMonth(),
1
) <= stripTime(minDate)
: false;
return (
<Flex
sx={{
@@ -66,7 +100,10 @@ export function DayPicker(props: DayPickerProps) {
<Button
variant="icon"
sx={{ p: 0 }}
{...subtractOffset({ months: 1 })}
{...subtractOffset(
{ months: 1 },
{ disabled: isPrevMonthBeforeMin }
)}
>
<ChevronLeft />
</Button>
@@ -83,9 +120,12 @@ export function DayPicker(props: DayPickerProps) {
value={month}
onChange={(e) => {
const selectedOption = e.target.selectedOptions[0];
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
e
const d = clampDate(
new Date(selectedOption.dataset.date || ""),
minDate,
maxDate
);
setOffset(d)?.onClick?.(e as any);
}}
>
{months.map((month) => (
@@ -93,6 +133,7 @@ export function DayPicker(props: DayPickerProps) {
key={month.month + year}
value={month.month}
data-date={month.$date.toDateString()}
disabled={month.disabled}
>
{month.month}
</option>
@@ -111,9 +152,12 @@ export function DayPicker(props: DayPickerProps) {
value={year}
onChange={(e) => {
const selectedOption = e.target.selectedOptions[0];
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
e
const d = clampDate(
new Date(selectedOption.dataset.date || ""),
minDate,
maxDate
);
setOffset(d)?.onClick?.(e as any);
}}
>
{years.map((year) => (
@@ -121,13 +165,23 @@ export function DayPicker(props: DayPickerProps) {
key={year.year}
value={year.year}
data-date={year.$date.toDateString()}
disabled={year.disabled}
>
{year.year}
</option>
))}
</select>
</Flex>
<Button variant="icon" sx={{ p: 0 }} {...addOffset({ months: 1 })}>
<Button
variant="icon"
sx={{ p: 0 }}
{...addOffset(
{ months: 1 },
{
disabled: isNextMonthBeyondMax
}
)}
>
<ChevronRight />
</Button>
</Flex>
@@ -193,3 +247,24 @@ export function DayPicker(props: DayPickerProps) {
</Flex>
);
}
function clampDate(d: Date, minDate?: Date, maxDate?: Date) {
/**
* Strip time for accurate comparison
*/
const normalizedMaxDate = maxDate ? stripTime(maxDate) : null;
const normalizedMinDate = minDate ? stripTime(minDate) : null;
if (normalizedMaxDate && d > normalizedMaxDate) {
return normalizedMaxDate;
}
if (normalizedMinDate && d < normalizedMinDate) {
return normalizedMinDate;
}
return d;
}
function stripTime(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

View File

@@ -27,7 +27,6 @@ import {
Lock,
NewTab,
Note,
NoteAdd,
NoteRemove,
Pin,
Plus,
@@ -98,7 +97,6 @@ type ToolButton = {
export function EditorActionBar() {
const { isMaximized, isFullscreen, hasNativeWindowControls } =
useWindowControls();
const isFocusMode = useAppStore((store) => store.isFocusMode);
const activeTab = useEditorStore((store) => store.getActiveTab());
const activeSession = useEditorStore((store) =>
activeTab ? store.getSession(activeTab.sessionId) : undefined
@@ -187,8 +185,7 @@ export function EditorActionBar() {
activeSession &&
activeSession.type !== "new" &&
activeSession.type !== "locked" &&
activeSession.type !== "conflicted" &&
!isFocusMode,
activeSession.type !== "conflicted",
onClick: () => useEditorStore.getState().toggleProperties(),
toggled: arePropertiesVisible
},

View File

@@ -50,7 +50,8 @@ import { FlexScrollContainer } from "../scroll-container";
import Tiptap, { OnChangeHandler } from "./tiptap";
import Header from "./header";
import { Attachment } from "../icons";
import { attachFiles, AttachmentProgress, insertAttachments } from "./picker";
import { AttachmentProgress, insertAttachments } from "./picker";
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
import { useEditorManager } from "./manager";
import {
saveAttachment,
@@ -617,13 +618,11 @@ export function Editor(props: EditorProps) {
}
const mime = type === "file" ? "*/*" : "image/*";
const attachments = await insertAttachments(mime);
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!attachments) return;
for (const attachment of attachments) {
editor?.attachFile(attachment);
}
await insertAttachments(mime, (attachments) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
});
}}
onGetAttachmentData={async (attachment) => {
logger.debug("Getting attachment data", {
@@ -646,10 +645,14 @@ export function Editor(props: EditorProps) {
return result;
}}
onAttachFiles={async (files) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
const result = await attachFiles(files);
if (!result) return;
result.forEach((attachment) => editor?.attachFile(attachment));
await AttachFilesDialog.show({
files,
onDone: (attachments) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
}
});
}}
onInsertInternalLink={async (attributes) => {
const link = await NoteLinkingDialog.show({ attributes });
@@ -799,17 +802,20 @@ function DropZone(props: DropZoneProps) {
}}
onDrop={async (e) => {
try {
const { activeEditorId, getEditor } = useEditorManager.getState();
const editor = getEditor(activeEditorId || "")?.editor;
if (!e.dataTransfer.files?.length || !editor) return;
const { activeEditorId } = useEditorManager.getState();
if (!e.dataTransfer.files?.length || !activeEditorId) return;
e.preventDefault();
const attachments = await attachFiles(
Array.from(e.dataTransfer.files)
);
for (const attachment of attachments || []) {
editor.attachFile(attachment);
}
await AttachFilesDialog.show({
files: Array.from(e.dataTransfer.files),
onDone: (attachments) => {
const editor = useEditorManager
.getState()
.getEditor(activeEditorId)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
}
});
} catch (e) {
logger.error(e as Error, "Failed to attach file from drag and drop");
showToast("error", strings.failedToAttachFile());

View File

@@ -18,86 +18,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SerializedKey } from "@notesnook/crypto";
import { AppEventManager, AppEvents } from "../../common/app-events";
import { db } from "../../common/db";
import { TaskManager } from "../../common/task-manager";
import { showToast } from "../../utils/toast";
import { showFilePicker } from "../../utils/file-picker";
import { Attachment } from "@notesnook/editor";
import { ImagePickerDialog } from "../../dialogs/image-picker-dialog";
import { strings } from "@notesnook/intl";
import {
getUploadedFileSize,
hashStream,
writeEncryptedFile
} from "../../interfaces/fs";
import Config from "../../utils/config";
import { compressImage, FileWithURI } from "../../utils/image-compressor";
import { ImageCompressionOptions } from "../../stores/setting-store";
import { checkFeature } from "../../common";
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
import { strings } from "@notesnook/intl";
export async function insertAttachments(type = "*/*") {
export async function insertAttachments(
type: string,
onDone: (attachments: Attachment[]) => void
): Promise<void> {
const files = await showFilePicker({
acceptedFileTypes: type || "*/*",
multiple: true
});
if (!files) return;
return await attachFiles(files, type === "*/*");
}
if (!files || files.length === 0) return;
export async function attachFiles(
files: File[],
skipSpecialImageHandling = false
) {
let images = files.filter((f) => f.type.startsWith("image/"));
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
"imageCompression",
ImageCompressionOptions.ASK_EVERY_TIME
);
switch (imageCompressionConfig) {
case ImageCompressionOptions.ENABLE: {
const compressedImages: FileWithURI[] = [];
for (const image of images) {
const compressed = await compressImage(image, {
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
width: (naturalWidth) => naturalWidth,
height: (_, naturalHeight) => naturalHeight,
resize: "contain",
quality: 0.7
});
compressedImages.push(
new FileWithURI([compressed], image.name, {
lastModified: image.lastModified,
type: image.type
})
);
}
images = compressedImages;
break;
}
case ImageCompressionOptions.DISABLE:
break;
default:
images =
images.length > 0
? (await ImagePickerDialog.show({
images
})) || []
: [];
}
const documents = files.filter((f) => !f.type.startsWith("image/"));
const attachments: Attachment[] = [];
for (const file of [...images, ...documents]) {
const attachment =
!skipSpecialImageHandling && file.type.startsWith("image/")
? await pickImage(file)
: await pickFile(file);
if (!attachment) continue;
attachments.push(attachment);
}
return attachments;
await AttachFilesDialog.show({
files,
skipSpecialImageHandling: type === "*/*",
onDone
});
}
export async function reuploadAttachment(
@@ -109,9 +58,16 @@ export async function reuploadAttachment(
});
if (!selectedFile) return;
if (
!(await checkFeature("fileSize", {
value: selectedFile.size
}))
) {
return;
}
const options: AddAttachmentOptions = {
expectedFileHash,
showProgress: false,
forceWrite: true
};
@@ -124,6 +80,72 @@ export async function reuploadAttachment(
}
}
type AttachFilesMessage =
| { type: "compressing"; index: number }
| { type: "encrypting"; index: number }
| {
type: "done";
index: number;
attachment: Attachment | undefined;
}
| { type: "error"; index: number; error: string };
export async function* attachFiles(
files: File[],
shouldCompress: boolean[],
skipSpecialImageHandling = false
): AsyncGenerator<AttachFilesMessage> {
for (let i = 0; i < files.length; i++) {
let file = files[i];
const shouldCompressFile = shouldCompress[i];
if (shouldCompressFile) {
yield { type: "compressing", index: i };
try {
const compressed = await compressImage(file, {
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
width: (naturalWidth) => naturalWidth,
height: (_, naturalHeight) => naturalHeight,
resize: "contain",
quality: 0.7
});
file = new FileWithURI([compressed], file.name, {
lastModified: file.lastModified,
type: file.type
});
} catch (e) {
yield {
type: "error",
index: i,
error: (e as Error).message || strings.compressionFailed()
};
continue;
}
}
yield { type: "encrypting", index: i };
try {
const allowed = await checkFeature("fileSize", {
value: file.size,
type: "toast"
});
if (!allowed) {
throw new Error(strings.fileSizeLimitExceededPleaseUpgrade());
}
const attachment =
!skipSpecialImageHandling && file.type.startsWith("image/")
? await pickImage(file)
: await pickFile(file);
yield { type: "done", index: i, attachment: attachment || undefined };
} catch (e) {
yield { type: "error", index: i, error: (e as Error).message };
}
}
}
/**
* @param {File} file
* @returns
@@ -133,8 +155,6 @@ async function pickFile(
options?: AddAttachmentOptions
): Promise<Attachment | undefined> {
try {
if (!(await checkFeature("fileSize", { value: file.size }))) return;
const hash = await addAttachment(file, options);
return {
type: "file",
@@ -158,8 +178,6 @@ async function pickImage(
options?: AddAttachmentOptions
): Promise<Attachment | undefined> {
try {
if (!(await checkFeature("fileSize", { value: file.size }))) return;
const hash = await addAttachment(file, options);
const dimensions = await getImageDimensions(file);
return {
@@ -171,6 +189,7 @@ async function pickImage(
...dimensions
};
} catch (e) {
console.error(e);
showToast("error", (e as Error).message);
}
}
@@ -190,7 +209,6 @@ export type AttachmentProgress = {
type AddAttachmentOptions = {
expectedFileHash?: string;
showProgress?: boolean;
forceWrite?: boolean;
};
@@ -198,80 +216,46 @@ async function addAttachment(
file: File,
options: AddAttachmentOptions = {}
): Promise<string> {
const { expectedFileHash, showProgress = true } = options;
const { expectedFileHash } = options;
let forceWrite = options.forceWrite;
const action = async () => {
const reader = file.stream().getReader();
const { hash, type: hashType } = await hashStream(reader);
reader.releaseLock();
const reader = file.stream().getReader();
const { hash, type: hashType } = await hashStream(reader);
reader.releaseLock();
if (expectedFileHash && hash !== expectedFileHash)
throw new Error(
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
);
if (expectedFileHash && hash !== expectedFileHash)
throw new Error(
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
);
const exists = await db.attachments.attachment(hash);
if (!forceWrite && exists) {
forceWrite = (await getUploadedFileSize(hash)) === 0;
const exists = await db.attachments.attachment(hash);
if (!forceWrite && exists) {
forceWrite = (await getUploadedFileSize(hash)) === 0;
}
if (forceWrite || !exists) {
if (forceWrite && exists) {
if (!(await db.fs().deleteFile(hash, false)))
throw new Error("Failed to delete attachment from server.");
await db.attachments.reset(exists.id);
}
if (forceWrite || !exists) {
if (forceWrite && exists) {
if (!(await db.fs().deleteFile(hash, false)))
throw new Error("Failed to delete attachment from server.");
await db.attachments.reset(exists.id);
}
const key: SerializedKey = await getEncryptionKey();
const key: SerializedKey = await getEncryptionKey();
const output = await writeEncryptedFile(file, key, hash);
if (!output) throw new Error("Could not encrypt file.");
const output = await writeEncryptedFile(file, key, hash);
if (!output) throw new Error("Could not encrypt file.");
await db.attachments.add({
...output,
hash,
hashType,
filename: exists?.filename || file.name,
mimeType: exists?.type || file.type,
key
});
}
await db.attachments.add({
...output,
hash,
hashType,
filename: exists?.filename || file.name,
mimeType: exists?.type || file.type,
key
});
}
return hash;
};
const result = showProgress
? await withProgress(file, action)
: await action();
if (result instanceof Error) throw result;
return result;
}
function withProgress<T>(
file: File,
action: () => Promise<T>
): Promise<T | Error> {
return TaskManager.startTask({
type: "modal",
title: strings.encryptingAttachment(),
subtitle: strings.encryptingAttachmentDesc(),
action: (report) => {
const event = AppEventManager.subscribe(
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
({ type, total, loaded }: AttachmentProgress) => {
if (type !== "encrypt") return;
report({
current: Math.round((loaded / total) * 100),
total: 100,
text: file.name
});
}
);
return action().finally(() => event.unsubscribe());
}
});
return hash;
}
function getImageDimensions(file: File) {

View File

@@ -58,6 +58,7 @@ import { useStore as useThemeStore } from "../../stores/theme-store";
import { writeToClipboard } from "../../utils/clipboard";
import { useEditorStore } from "../../stores/editor-store";
import { DayFormat, parseInternalLink } from "@notesnook/core";
import { desktop } from "../../common/desktop-bridge";
import Skeleton from "react-loading-skeleton";
import useMobile from "../../hooks/use-mobile";
import useTablet from "../../hooks/use-tablet";
@@ -68,6 +69,7 @@ import { showFeatureNotAllowedToast } from "../../common/toasts";
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
import { ConfirmDialog } from "../../dialogs/confirm";
import { strings } from "@notesnook/intl";
import { showToast } from "../../utils/toast";
export type OnChangeHandler = (
content: () => string,
@@ -421,13 +423,32 @@ function TipTap(props: TipTapProps) {
previewAttachment: onPreviewAttachment,
createInternalLink: onInsertInternalLink,
getAttachmentData: onGetAttachmentData,
openLink: (url, openInNewTab) => {
openLink: async (url, openInNewTab) => {
const link = parseInternalLink(url);
if (link && link.type === "note") {
useEditorStore.getState().openSession(link.id, {
activeBlockId: link.params?.blockId || undefined,
openInNewTab: openInNewTab
});
} else if (url.startsWith("file:")) {
if (!IS_DESKTOP_APP) {
showToast("error", strings.cantOpenFileLinksInBrowsers());
return;
}
const path = new URL(url).pathname;
const ok = await ConfirmDialog.show({
title: strings.openingLocalFile(),
message: strings.openingLocalFileDesc(path),
positiveButtonText: strings.open(),
negativeButtonText: strings.cancel()
});
if (!ok) return;
await desktop?.integration.openPath.query({
type: "path",
link: decodeURIComponent(path)
});
} else window.open(url, "_blank");
}
};

View File

@@ -45,6 +45,7 @@ import {
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useStore as useSearchStore } from "../../stores/search-store";
import type { Context } from "../list-container/types";
const groupByToTitleMap = {
none: "None",
@@ -61,6 +62,7 @@ type GroupingMenuOptions = {
groupingKey: GroupingKey;
refresh: () => void;
isSearching?: boolean;
context?: Context;
};
const groupByMenu: (options: GroupingMenuOptions) => MenuItem | null = (
@@ -192,6 +194,39 @@ export function showSortMenu(groupingKey: GroupingKey, refresh: () => void) {
);
}
function getGroupOptions(
context: Context | undefined,
isSearching: boolean | undefined,
groupingKey: GroupingKey
): GroupOptions {
return isSearching
? db.settings.getGroupOptions("search")
: context?.type === "notebook" ||
context?.type === "tag" ||
context?.type === "color"
? db.settings.getGroupOptionsById(context.id, context.type)
: db.settings.getGroupOptions(groupingKey);
}
async function setGroupOptions(
options: GroupingMenuOptions,
groupOptions: GroupOptions
) {
if (
options.context?.type === "notebook" ||
options.context?.type === "tag" ||
options.context?.type === "color"
) {
await db.settings.setGroupOptionsById(
options.context.id,
options.context.type,
groupOptions
);
} else {
await db.settings.setGroupOptions(options.groupingKey, groupOptions);
}
}
async function changeGroupOptions(
options: GroupingMenuOptions,
item: Omit<MenuButtonItem, "type">
@@ -207,7 +242,9 @@ async function changeGroupOptions(
? "dateModified"
: groupOptions.sortBy;
}
await db.settings.setGroupOptions(options.groupingKey, groupOptions);
await setGroupOptions(options, groupOptions);
if (options.groupingKey === "search")
useSearchStore.setState({ sortOptions: groupOptions });
options.refresh();
@@ -235,6 +272,7 @@ type GroupHeaderProps = {
onSelectGroup: () => void;
isFocused: boolean;
isSearching?: boolean;
context?: Context;
};
function GroupHeader(props: GroupHeaderProps) {
const {
@@ -246,10 +284,12 @@ function GroupHeader(props: GroupHeaderProps) {
refresh,
onSelectGroup,
isFocused,
isSearching
isSearching,
context
} = props;
const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(isSearching ? "search" : groupingKey)
getGroupOptions(context, isSearching, groupingKey)
);
const groupHeaderRef = useRef<HTMLDivElement>(null);
const { openMenu, target } = useMenuTrigger();
@@ -359,8 +399,10 @@ function GroupHeader(props: GroupHeaderProps) {
groupByToTitleMap[groupOptions.groupBy || "default"]
}`}
onClick={() => {
const groupOptions = db.settings.getGroupOptions(
isSearching ? "search" : groupingKey
const groupOptions = getGroupOptions(
context,
isSearching,
groupingKey
);
setGroupOptions(groupOptions);
@@ -368,7 +410,8 @@ function GroupHeader(props: GroupHeaderProps) {
groupingKey: isSearching ? "search" : groupingKey,
groupOptions,
refresh,
isSearching
isSearching,
context
};
const groupBy = groupByMenu({
...menuOptions,

View File

@@ -229,7 +229,8 @@ import {
mdiArrowUp,
mdiInbox,
mdiConsoleLine,
mdiDeleteSweepOutline
mdiDeleteSweepOutline,
mdiCloseCircle
} from "@mdi/js";
import { useTheme } from "@emotion/react";
import { Theme } from "@notesnook/theme";
@@ -586,3 +587,4 @@ export const HamburgerMenu = createIcon(mdiMenu);
export const ArrowUp = createIcon(mdiArrowUp);
export const Inbox = createIcon(mdiInbox);
export const ClearTrash = createIcon(mdiDeleteSweepOutline);
export const CloseCircle = createIcon(mdiCloseCircle);

View File

@@ -370,6 +370,7 @@ function ItemRenderer({
title={resolvedItem.group.title}
isFocused={index === focusedGroupIndex}
index={index}
context={itemContext}
onSelectGroup={async () => {
if (!items.groups) return;

View File

@@ -104,6 +104,7 @@ import ListItem from "../list-item";
import { PublishDialog } from "../publish-view";
import TimeAgo from "../time-ago";
import { NoteExpiryDateDialog } from "../../dialogs/note-expiry-date-dialog";
import { withFeatureCheck } from "../../common";
type NoteProps = NoteResolvedData & {
item: NoteType;
@@ -665,11 +666,11 @@ export const noteMenuItems: (
title: strings.setExpiry(),
icon: Destruct.path,
premium: !features.expiringNotes.isAllowed,
onClick: async () => {
onClick: withFeatureCheck(features.expiringNotes, async () => {
await NoteExpiryDateDialog.show({
noteId: note.id
});
}
})
},
{
type: "button",

View File

@@ -41,7 +41,6 @@ import {
DefaultEditorSession
} from "../../stores/editor-store";
import { db } from "../../common/db";
import { useStore as useAppStore } from "../../stores/app-store";
import { useStore as useAttachmentStore } from "../../stores/attachment-store";
import { store as noteStore } from "../../stores/note-store";
import Toggle from "./toggle";
@@ -109,7 +108,6 @@ type EditorPropertiesProps = {
};
function EditorProperties(props: EditorPropertiesProps) {
const toggleProperties = useEditorStore((store) => store.toggleProperties);
const isFocusMode = useAppStore((store) => store.isFocusMode);
const dateFormat = useSettingStore((store) => store.dateFormat);
const timeFormat = useSettingStore((store) => store.timeFormat);
const metadataItems = [
@@ -140,7 +138,8 @@ function EditorProperties(props: EditorPropertiesProps) {
"diff"
])
);
if (isFocusMode || !session) return null;
if (!session) return null;
return (
<Flex
sx={{

View File

@@ -44,7 +44,7 @@ export const AddNotebookDialog = DialogManager.register(
const onSubmit = useCallback(async () => {
if (!title.current.trim())
return showToast("error", strings.allFieldsRequired());
return showToast("error", strings.titleIsRequired());
const id = await db.notebooks.add({
id: props.notebook?.id,

View File

@@ -48,6 +48,8 @@ import Skeleton from "react-loading-skeleton";
dayjs.extend(customParseFormat);
const MAX_DATE = dayjs().add(99, "year").endOf("year").toDate();
export type AddReminderDialogProps = BaseDialogProps<boolean> & {
reminder?: Reminder;
note?: Note;
@@ -200,6 +202,14 @@ export const AddReminderDialog = DialogManager.register(
return;
}
if (mode !== Modes.REPEAT && date.isAfter(MAX_DATE)) {
showToast(
"error",
strings.maximumReminderDate(getFormattedDate(MAX_DATE, "date"))
);
return;
}
const id = await db.reminders.add({
id: reminder?.id,
recurringMode,
@@ -431,7 +441,7 @@ export const AddReminderDialog = DialogManager.register(
}}
selected={dayjs(date).toDate()}
minDate={new Date()}
maxDate={new Date(new Date().getFullYear() + 99, 11, 31)}
maxDate={MAX_DATE}
onSelect={(day) => {
if (!day) return;
const date = getFormattedDate(day, "date");

View File

@@ -0,0 +1,373 @@
/*
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 { useEffect, useMemo, useRef, useState } from "react";
import Dialog from "../components/dialog";
import { ScrollContainer } from "@notesnook/ui";
import { Box, Flex, Image, Switch, Text } from "@theme-ui/components";
import { formatBytes } from "@notesnook/common";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
import { checkFeature } from "../common";
import { AppEventManager, AppEvents } from "../common/app-events";
import { attachFiles, AttachmentProgress } from "../components/editor/picker";
import Config from "../utils/config";
import { ImageCompressionOptions } from "../stores/setting-store";
import { Attachment } from "@notesnook/editor";
import {
CheckCircle,
Loading,
File as FileIcon,
CloseCircle
} from "../components/icons";
type FileStatus = "pending" | "compressing" | "encrypting" | "done" | "error";
type FileState = {
file: File;
status: FileStatus;
progress: number;
error?: string;
compress: boolean;
};
type AttachFilesDialogProps = BaseDialogProps<false> & {
files: File[];
skipSpecialImageHandling?: boolean;
onDone: (attachments: Attachment[]) => void;
};
export const AttachFilesDialog = DialogManager.register(
function AttachFilesDialog({
files,
skipSpecialImageHandling,
onDone,
onClose
}: AttachFilesDialogProps) {
const hasImages = files.some((f) => f.type.startsWith("image/"));
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
"imageCompression",
ImageCompressionOptions.ASK_EVERY_TIME
);
const [fileStates, setFileStates] = useState<FileState[]>(() =>
files.map((file) => ({
file,
status: "pending",
progress: 0,
compress: file.type.startsWith("image/")
? imageCompressionConfig !== ImageCompressionOptions.DISABLE
: false
}))
);
const [showCompressionPrompt, setShowCompressionPrompt] = useState(
hasImages &&
imageCompressionConfig === ImageCompressionOptions.ASK_EVERY_TIME
);
const processingRef = useRef(false);
useEffect(() => {
const event = AppEventManager.subscribe(
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
({ type, total, loaded }: AttachmentProgress) => {
if (type !== "encrypt") return;
setFileStates((prev) =>
prev.map((s) => {
/**
* only one file is encrypted at a time, so we can just update progress of the state with "encrypting" status
*/
if (s.status !== "encrypting") return s;
return {
...s,
progress: Math.round((loaded / total) * 100)
};
})
);
}
);
return () => {
event.unsubscribe();
};
}, []);
useEffect(() => {
if (showCompressionPrompt || processingRef.current) return;
processingRef.current = true;
const shouldCompress: boolean[] = fileStates.map((s) => !!s.compress);
(async () => {
const attachments: Attachment[] = [];
let hasError = false;
for await (const message of attachFiles(
files,
shouldCompress,
skipSpecialImageHandling
)) {
const { index } = message;
switch (message.type) {
case "compressing":
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? { ...s, status: "compressing" as FileStatus }
: s
)
);
break;
case "encrypting":
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? { ...s, status: "encrypting" as FileStatus, progress: 0 }
: s
)
);
break;
case "done":
if (message.attachment) attachments.push(message.attachment);
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? {
...s,
status: "done" as FileStatus
}
: s
)
);
break;
case "error":
hasError = true;
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? {
...s,
status: "error" as FileStatus,
error: message.error
}
: s
)
);
break;
}
}
onDone(attachments);
if (files.length === 1 && !hasError) onClose(false);
})();
}, [showCompressionPrompt]);
return (
<Dialog
isOpen={true}
title={
showCompressionPrompt
? strings.imageCompression()
: strings.attachingFiles()
}
description={
showCompressionPrompt ? strings.imageCompressionDesc() : ""
}
onClose={() => onClose(false)}
width={500}
positiveButton={
showCompressionPrompt
? {
text: strings.done(),
onClick: () => setShowCompressionPrompt(false)
}
: undefined
}
negativeButton={{
text: strings.close(),
onClick: () => onClose(false)
}}
>
<ScrollContainer
style={{
maxHeight: 350,
display: "flex",
flexDirection: "column",
position: "relative"
}}
>
{fileStates.map((state, index) => (
<FileRow
key={`${state.file.name}-${index}`}
state={state}
showDivider={fileStates.length > 1}
showCompressionToggle={showCompressionPrompt}
onToggleCompress={async () => {
if (
!(await checkFeature("fullQualityImages", { type: "toast" }))
) {
return;
}
setFileStates((prev) =>
prev.map((s, idx) =>
idx === index ? { ...s, compress: !s.compress } : s
)
);
}}
/>
))}
</ScrollContainer>
</Dialog>
);
}
);
function FileRow({
state,
showDivider,
showCompressionToggle,
onToggleCompress
}: {
state: FileState;
showDivider?: boolean;
showCompressionToggle?: boolean;
onToggleCompress?: () => void;
}) {
const { file, status, progress, error, compress } = state;
const isImage = file.type.startsWith("image/");
const thumbnail = useMemo(
() => (isImage ? URL.createObjectURL(file) : undefined),
[file, isImage]
);
useEffect(() => {
return () => {
if (thumbnail) URL.revokeObjectURL(thumbnail);
};
}, [thumbnail]);
return (
<Flex
sx={{
alignItems: "center",
py: 1,
px: 1,
gap: 2,
borderBottom: showDivider ? "1px solid var(--border)" : undefined
}}
>
{thumbnail ? (
<Image
src={thumbnail}
sx={{
width: 40,
height: 40,
objectFit: "cover",
borderRadius: "default",
flexShrink: 0
}}
/>
) : (
<Flex
sx={{
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
bg: "background-secondary",
borderRadius: "default"
}}
>
<FileIcon size={20} color="icon" />
</Flex>
)}
<Flex sx={{ flex: 1, flexDirection: "column", minWidth: 0 }}>
<Text
variant="body"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{file.name}
</Text>
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
{formatBytes(file.size)}
{status === "compressing"
? `${strings.compressing()}...`
: status === "encrypting"
? `${strings.encrypting()} ${progress}%`
: status === "error"
? `${error}`
: ""}
</Text>
</Flex>
<Flex sx={{ flexShrink: 0, alignItems: "center" }}>
{showCompressionToggle && isImage ? (
<Switch
sx={{
m: 0,
bg: compress ? "accent" : "icon-secondary",
flexShrink: 0,
scale: 0.75
}}
checked={compress}
onChange={onToggleCompress}
/>
) : showCompressionToggle && !isImage ? (
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
N/A
</Text>
) : status === "done" ? (
<CheckCircle size={20} color="accent" />
) : status === "error" ? (
<CloseCircle size={20} color="accent-error" />
) : status === "encrypting" || status === "compressing" ? (
<Flex sx={{ alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 60,
height: 4,
bg: "border",
borderRadius: "full",
overflow: "hidden"
}}
>
<Box
sx={{
width: `${status === "compressing" ? 50 : progress}%`,
height: "100%",
bg: "accent",
transition: "width 0.2s ease"
}}
/>
</Box>
</Flex>
) : (
<Loading size={16} color="icon" />
)}
</Flex>
</Flex>
);
}

View File

@@ -123,6 +123,7 @@ export const ConfirmDialog = DialogManager.register(function ConfirmDialog(
<Text
as="div"
variant="body"
sx={{ overflowWrap: "break-word" }}
dangerouslySetInnerHTML={{ __html: mdToHtml(message) }}
/>
) : null}

View File

@@ -65,6 +65,7 @@ export const EditNoteCreationDateDialog = DialogManager.register(
onClose(false);
}}
title={strings.editCreationDate()}
description={`${strings.note()}: ${strings.creationDateCannotBeAfterLastEditedDate()}`}
negativeButton={{
text: strings.cancel(),
onClick: () => {
@@ -76,14 +77,10 @@ export const EditNoteCreationDateDialog = DialogManager.register(
text: strings.save(),
onClick: async () => {
try {
if (date.isAfter(dayjs())) {
showToast("error", "Creation date cannot be in the future");
return;
}
if (dateEdited && date.isAfter(dayjs(dateEdited))) {
if (dateEdited && date.isAfter(dayjs(dateEdited), "minute")) {
showToast(
"error",
"Creation date cannot be after last edited date"
strings.creationDateCannotBeAfterLastEditedDate()
);
return;
}
@@ -140,7 +137,7 @@ export const EditNoteCreationDateDialog = DialogManager.register(
width: 300
}}
selected={dayjs(date).toDate()}
maxDate={new Date()}
maxDate={new Date(dateEdited)}
onSelect={(day) => {
if (!day) return;
const date = getFormattedDate(day, "date");

View File

@@ -22,7 +22,14 @@ import Dialog from "../components/dialog";
import { getHomeRoute, hardNavigate } from "../navigation";
import { appVersion } from "../utils/version";
import Config from "../utils/config";
import { ArrowRight, Checkmark, Icon, Warn } from "../components/icons";
import {
ArrowRight,
Checkmark,
Icon,
Warn,
File,
InternalLink
} from "../components/icons";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";

View File

@@ -1,147 +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 { useEffect, useState } from "react";
import Dialog from "../components/dialog";
import { ScrollContainer } from "@notesnook/ui";
import { Flex, Image, Label, Text } from "@theme-ui/components";
import { formatBytes } from "@notesnook/common";
import { compressImage, FileWithURI } from "../utils/image-compressor";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
import { checkFeature } from "../common";
export type ImagePickerDialogProps = BaseDialogProps<false | File[]> & {
images: File[];
};
export const ImagePickerDialog = DialogManager.register(
function ImagePickerDialog(props: ImagePickerDialogProps) {
const [images, setImages] = useState<FileWithURI[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [compress, setCompress] = useState(true);
const selectedImage = images[selectedIndex];
useEffect(() => {
(async function () {
const images: FileWithURI[] = [];
for (const image of props.images) {
const compressed = compress
? await compressImage(image, {
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
width: (naturalWidth) => naturalWidth,
height: (_, naturalHeight) => naturalHeight,
resize: "contain",
quality: 0.7
})
: image;
images.push(
new FileWithURI([compressed], image.name, {
lastModified: image.lastModified,
type: image.type
})
);
}
setImages(images);
})();
}, [props.images, compress]);
useEffect(() => {
return () => {
images.forEach((i) => URL.revokeObjectURL(i.uri));
};
}, [images]);
return (
<Dialog
isOpen={true}
onClose={() => props.onClose(false)}
positiveButton={{
text: strings.insert(),
onClick: () => props.onClose(images)
}}
negativeButton={{
text: strings.cancel(),
onClick: () => props.onClose(false)
}}
>
{selectedImage && (
<Flex sx={{ flexDirection: "column", alignItems: "center", mt: 4 }}>
<Image
src={selectedImage.uri}
sx={{
maxHeight: 250,
objectFit: "contain",
alignSelf: "center",
borderRadius: "default"
}}
/>
<Text variant="subBody" sx={{ my: 2 }}>
{selectedImage.name} ({formatBytes(selectedImage.size)})
</Text>
</Flex>
)}
{images.length > 1 ? (
<ScrollContainer style={{ display: "flex" }}>
{images.map((image, index) => (
<Image
key={image.name + image.size}
src={image.uri}
sx={{
flexShrink: 0,
height: "55px",
width: "55px",
objectFit: "contain",
border:
selectedIndex === index
? "2px solid var(--accent)"
: "2px solid transparent",
borderRadius: "default"
}}
onClick={() => setSelectedIndex(index)}
/>
))}
</ScrollContainer>
) : null}
<Label variant="text.body" sx={{ mt: 2 }}>
<input
type="checkbox"
style={{
accentColor: "var(--accent)",
marginRight: 1,
width: 14,
height: 14
}}
defaultChecked={compress}
checked={compress}
onChange={async () => {
if (!(await checkFeature("fullQualityImages", { type: "toast" })))
return;
setCompress((s) => !s);
}}
/>
<span style={{ marginLeft: 5 }}>
Enable compression (recommended)
</span>
</Label>
</Dialog>
);
}
);

View File

@@ -54,19 +54,26 @@ export const NoteExpiryDateDialog = DialogManager.register(
return (
<Dialog
isOpen={true}
title={"Set Custom Expiry Date"}
title={strings.setExpiry()}
onClose={() => onClose(false)}
width={400}
positiveButton={{
text: strings.done(),
onClick: async () => {
if (date.isBefore(dayjs())) {
showToast("error", "Expiry date must be in the future");
showToast("error", strings.expiryDateMustBeInTheFuture());
return;
}
if (date.isAfter(dayjs().add(1, "year"))) {
showToast(
"error",
strings.expiryDateCannotBeMoreThan1YearInTheFuture()
);
return;
}
await db.notes.setExpiryDate(date.valueOf(), noteId);
store.refresh();
showToast("success", "Expiry date set");
showToast("success", strings.expiryDateSet());
onClose(true);
}
}}

View File

@@ -240,7 +240,8 @@ export const BehaviourSettings: SettingsGroup[] = [
useSettingStore.subscribe((s) => s.autoUpdates, listener),
isHidden: () =>
useSettingStore.getState().isFlatpak ||
useSettingStore.getState().isSnap,
useSettingStore.getState().isSnap ||
useSettingStore.getState().isPortable,
components: [
{
type: "toggle",

View File

@@ -47,6 +47,7 @@ import { FlexScrollContainer } from "../../components/scroll-container";
import { useCallback, useEffect, useRef, useState } from "react";
import {
DropdownSettingComponent,
Section,
SectionGroup,
SectionKeys,
Setting,
@@ -254,9 +255,15 @@ export const SettingsDialog = DialogManager.register(function SettingsDialog(
overflow: "auto"
}}
>
{activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))}
{activeSettings.length > 0 ? (
activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))
) : (
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
{strings.noResultsFound()}
</Text>
)}
</FlexScrollContainer>
</Flex>
</Dialog>
@@ -317,12 +324,17 @@ function SettingsSideBar(props: SettingsSideBarProps) {
SettingsGroups.filter((g) => g.section === route)
);
const groups: SettingsGroup[] = [];
let groups: SettingsGroup[] = [];
for (const group of SettingsGroups) {
const section = findSection(group.section);
if (section?.isHidden?.() || group.isHidden?.()) continue;
const isTitleMatch =
typeof group.header === "string" &&
group.header.toLowerCase().includes(query);
const isSectionMatch = group.section.includes(query);
const isSectionMatch = group.section
.toLowerCase()
.includes(query);
if (isTitleMatch || isSectionMatch) {
groups.push(group);
@@ -347,6 +359,18 @@ function SettingsSideBar(props: SettingsSideBarProps) {
if (!settings.length) continue;
groups.push({ ...group, settings });
}
const matchedSections = findSections(query);
if (matchedSections.length > 0) {
const matchedGroups = SettingsGroups.filter((g) =>
matchedSections.some((s) => s.key === g.section)
);
// remove groups whose sections were matched to avoid duplicate
// entries.
groups = groups.filter(
(g) => !matchedGroups.some((mg) => mg.section === g.section)
);
groups.push(...matchedGroups);
}
onNavigate(groups);
}}
/>
@@ -413,7 +437,10 @@ function SettingsGroupComponent(props: { item: SettingsGroup }) {
};
}, [onStateChange]);
if (item.isHidden?.()) return null;
const allHidden = item.settings.every((s) => s.isHidden?.());
const hasComponentHeader = typeof item.header !== "string";
if (item.isHidden?.() || (allHidden && !hasComponentHeader)) return null;
return (
<Flex
sx={{
@@ -717,3 +744,22 @@ function NumberInput({
</Flex>
);
}
function findSection(key: SectionKeys) {
for (const group of sectionGroups) {
const section = group.sections.find((s) => s.key === key);
if (section) return section;
}
return null;
}
function findSections(query: string) {
const sections: Section[] = [];
for (const group of sectionGroups) {
for (const section of group.sections) {
if (section.isHidden?.()) continue;
if (section.title.toLowerCase().includes(query)) sections.push(section);
}
}
return sections;
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SettingsGroup } from "./types";
import { SettingComponent, SettingsGroup } from "./types";
import { appVersion } from "../../utils/version";
import { writeText } from "clipboard-polyfill";
import { showToast } from "../../utils/toast";
@@ -50,6 +50,23 @@ export const AboutSettings: SettingsGroup[] = [
useAutoUpdateStore.subscribe((s) => s.status, listener),
components: () => {
const status = useAutoUpdateStore.getState().status;
const copyVersionButton: SettingComponent = {
type: "button",
action: async () => {
await writeText(appVersion.formatted);
showToast("info", strings.copied());
},
title: strings.copy(),
variant: "secondary"
};
if (
useSettingStore.getState().isFlatpak ||
useSettingStore.getState().isSnap ||
useSettingStore.getState().isPortable
) {
return [copyVersionButton];
}
return [
status?.type === "available"
? {
@@ -64,15 +81,7 @@ export const AboutSettings: SettingsGroup[] = [
title: strings.checkForUpdates(),
variant: "secondary"
},
{
type: "button",
action: async () => {
await writeText(appVersion.formatted);
showToast("info", strings.copied());
},
title: strings.copy(),
variant: "secondary"
}
copyVersionButton
];
}
},
@@ -82,7 +91,8 @@ export const AboutSettings: SettingsGroup[] = [
description: strings.releaseTrackDesc(),
isHidden: () =>
useSettingStore.getState().isFlatpak ||
useSettingStore.getState().isSnap,
useSettingStore.getState().isSnap ||
useSettingStore.getState().isPortable,
components: [
{
type: "dropdown",

View File

@@ -49,6 +49,7 @@ import {
} from "@notesnook/core";
import { logger } from "../utils/logger";
import { newQueue } from "@henrygd/queue";
import { strings } from "@notesnook/intl";
export const ABYTES = 17;
const CHUNK_SIZE = 512 * 1024;
@@ -499,12 +500,20 @@ async function downloadFile(
{ type: "download", hash: filename }
);
const signedUrl = (
await axios.get(url, {
headers,
responseType: "text"
})
).data;
const signedUrlResponse = await axios
.get(url, { headers, responseType: "text" })
.catch((e) => {
if (e.response?.status === 401) {
showToast("error", strings.pleaseLoginToDownloadAttachments());
return null;
}
throw e;
});
if (!signedUrlResponse) {
reportProgress(undefined, { type: "download", hash: filename });
return false;
}
const signedUrl = signedUrlResponse.data;
logger.debug("Got attachment signed url", { filename });
@@ -705,7 +714,10 @@ export async function getUploadedFileSize(filename: string) {
headers: { Authorization: `Bearer ${token}` }
});
const contentLength = parseInt(attachmentInfo.headers["content-length"]);
const contentLength = parseInt(
attachmentInfo.headers["x-object-size"] ??
attachmentInfo.headers["content-length"]
);
return isNaN(contentLength) ? 0 : contentLength;
} catch (e) {
logger.error(e, "Failed to get uploaded file size.", { filename });

View File

@@ -286,7 +286,8 @@ class EditorStore extends BaseStore<EditorStore> {
const clearIds: string[] = [];
for (const session of sessions) {
if (session.type === "new") continue;
if (session.note.id !== item.id && session.note.contentId !== item.id) continue;
if (session.note.id !== item.id && session.note.contentId !== item.id)
continue;
if (isDeleted(item) || isTrashItem(item))
clearIds.push(session.tabId);
// if a note becomes conflicted, reopen the session
@@ -331,6 +332,13 @@ class EditorStore extends BaseStore<EditorStore> {
!item.readonly
)
openSession(session.note.id, { force: true, silent: true });
// if a note is made readonly, reopen the session
else if (
session.type !== "readonly" &&
item.type === "note" &&
item.readonly
)
openSession(session.note.id, { force: true, silent: true });
// update the note in all sessions
else if (item.type === "note") {
updateSession(

View File

@@ -57,18 +57,23 @@ class NoteStore extends BaseStore<NoteStore> {
};
setContext = async (context?: Context) => {
const groupOptions =
context?.type === "notebook" ||
context?.type === "tag" ||
context?.type === "color"
? db.settings.getGroupOptionsById(context.id, context.type)
: db.settings.getGroupOptions(
context?.type === "favorite"
? "favorites"
: context?.type === "archive"
? "archive"
: "notes"
);
this.set({
context,
contextNotes: context
? await notesFromContext(context).grouped(
db.settings.getGroupOptions(
context.type === "favorite"
? "favorites"
: context.type === "archive"
? "archive"
: "notes"
)
)
? await notesFromContext(context).grouped(groupOptions)
: undefined
});
};

View File

@@ -90,6 +90,7 @@ class SettingStore extends BaseStore<SettingStore> {
autoUpdates = false;
isFlatpak = false;
isSnap = false;
isPortable = false;
proxyRules?: string;
isInboxEnabled = false;
@@ -104,6 +105,7 @@ class SettingStore extends BaseStore<SettingStore> {
profile: db.settings.getProfile(),
isFlatpak: await desktop?.integration.isFlatpak.query(),
isSnap: await desktop?.integration.isSnap.query(),
isPortable: await desktop?.integration.isPortable.query(),
desktopIntegrationSettings:
await desktop?.integration.desktopIntegration.query(),
privacyMode: await desktop?.integration.privacyMode.query(),

View File

@@ -18,28 +18,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { PAGE_VISIBILITY_CHANGE } from "./page-visibility";
import { strings } from "@notesnook/intl";
import { TaskManager } from "../common/task-manager";
type FilePickerOptions = { acceptedFileTypes: string; multiple?: boolean };
export function showFilePicker({
export async function showFilePicker({
acceptedFileTypes,
multiple
}: FilePickerOptions): Promise<File[]> {
return new Promise((resolve) => {
PAGE_VISIBILITY_CHANGE.ignore = true;
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", `${multiple || false}`);
input.setAttribute("accept", acceptedFileTypes);
input.dispatchEvent(new MouseEvent("click"));
input.oncancel = async function () {
resolve([]);
};
input.onchange = async function () {
if (!input.files) return resolve([]);
resolve(Array.from(input.files));
};
PAGE_VISIBILITY_CHANGE.ignore = true;
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", `${multiple || false}`);
input.setAttribute("accept", acceptedFileTypes);
input.dispatchEvent(new MouseEvent("click"));
const result = await TaskManager.startTask<File[]>({
type: "modal",
title: strings.processing(),
subtitle: strings.pleaseWait(),
action: () =>
new Promise((resolve) => {
input.oncancel = async function () {
resolve([]);
};
input.onchange = async function () {
if (!input.files) return resolve([]);
resolve(Array.from(input.files));
};
})
});
return result instanceof Error ? [] : result;
}
export async function readFile(file: File): Promise<string> {

View File

@@ -88,7 +88,11 @@ export class WebExtensionServer implements Server {
}
);
const attachment = (await attachFiles([clippedFile]))?.at(0);
let attachment;
for await (const message of attachFiles([clippedFile], [false])) {
if (message.type === "done") attachment = message.attachment;
else if (message.type === "error") return;
}
if (!attachment) return;
clipContent += h("iframe", [], {

View File

@@ -38,6 +38,7 @@ export function Notebooks() {
const roots = useStore((store) => store.notebooks);
const [filteredNotebooks, setFilteredNotebooks] =
useState<VirtualizedGrouping<NotebookType>>();
const inputRef = useRef<HTMLInputElement>(null);
const treeRef =
useRef<
VirtualizedTreeHandle<{ notebook: NotebookType; totalNotes: number }>
@@ -61,6 +62,13 @@ export function Notebooks() {
useEffect(() => {
treeRef.current?.refresh();
const query = inputRef.current?.value.trim();
if (!query) return;
(async () => {
setFilteredNotebooks(await db.lookup.notebooks(query).sorted());
})();
}, [roots]);
return (
@@ -165,6 +173,7 @@ export function Notebooks() {
)}
</Box>
<Input
ref={inputRef}
variant="clean"
placeholder="Filter notebooks..."
sx={{ borderTop: "1px solid var(--border)", mx: 0 }}

View File

@@ -23,23 +23,31 @@ import Placeholder from "../components/placeholders";
import { db } from "../common/db";
import { ListLoader } from "../components/loaders/list-loader";
import { Flex, Input } from "@theme-ui/components";
import { forwardRef, useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { debounce } from "@notesnook/common";
import { Tag, VirtualizedGrouping } from "@notesnook/core";
import ScrollContainer from "../components/scroll-container";
import { ScrollerProps } from "react-virtuoso";
import { SidebarScroller } from "../components/sidebar-scroller";
function Tags() {
const tags = useStore((store) => store.tags);
const refresh = useStore((store) => store.refresh);
const [filteredTags, setFilteredTags] = useState<VirtualizedGrouping<Tag>>();
const inputRef = useRef<HTMLInputElement>(null);
const items = filteredTags || tags;
useEffect(() => {
store.refresh();
}, []);
useEffect(() => {
const query = inputRef.current?.value.trim();
if (!query) return;
(async () => {
setFilteredTags(await db.lookup.tags(query).sorted());
})();
}, [tags]);
if (!items) return <ListLoader />;
return (
<Flex
@@ -62,6 +70,7 @@ function Tags() {
Scroller={SidebarScroller}
/>
<Input
ref={inputRef}
variant="clean"
placeholder="Filter tags..."
sx={{ borderTop: "1px solid var(--border)", mx: 0 }}

View File

@@ -0,0 +1,8 @@
---
title: Login to restore attachments in backup.
description: We require users to be logged in to restore attachments in backup.
---
# Login to restore attachments in backup.
We require users to be logged in to restore attachments in backup. This is because attachments are encrypted using a sub-key derived from your database encryption key. Without a login, we cannot encrypt/upload/sync attachments.

View File

@@ -87,3 +87,4 @@ navigation:
- path: faqs/what-are-merge-conflicts.md
- path: faqs/is-there-an-eta.md
- path: faqs/login-to-upload-attachments.md
- path: faqs/login-to-restore-attachments-in-backup.md

View File

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

View File

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

View File

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

View File

@@ -47,3 +47,29 @@ test("save trash cleanup interval", () =>
await db.settings.setTrashCleanupInterval(interval);
expect(db.settings.getTrashCleanupInterval()).toBe(interval);
}));
const GROUP_OPTIONS_BY_ID_TESTS = ["notebook", "tag", "color"];
for (const type of GROUP_OPTIONS_BY_ID_TESTS) {
test(`get ${type} id group options`, () =>
databaseTest().then(async (db) => {
const id = `test-${type}-id`;
const groupOptions = {
groupBy: "year",
sortBy: "title",
sortDirection: "asc"
};
await db.settings.setGroupOptionsById(id, type, groupOptions);
expect(db.settings.getGroupOptionsById(id, type)).toMatchObject(
groupOptions
);
}));
test(`get ${type} id group options fallback to notes group options`, () =>
databaseTest().then(async (db) => {
const id = `non-existent-${type}-id`;
const defaultOptions = db.settings.getGroupOptions("notes");
const result = db.settings.getGroupOptionsById(id, type);
expect(result).toMatchObject(defaultOptions);
}));
}

View File

@@ -400,6 +400,16 @@ export class Sync {
}
const collectionType = SYNC_COLLECTIONS_MAP[itemType];
if (!collectionType) {
this.logger.error(
new Error(
`Unknown collection type for item type ${itemType}. Skipping chunk.`
)
);
return;
}
const collection = this.db[collectionType].collection;
const localItems = await collection.records(chunk.items.map((i) => i.id));
let items: (MaybeDeletedItem<Item> | undefined)[] = [];

View File

@@ -404,7 +404,9 @@ class UserManager {
const masterKey = await this.getMasterKey();
if (!masterKey) return;
const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey");
const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey", {
refetchUser: false
});
if (!dataEncryptionKey)
return [
{
@@ -415,7 +417,10 @@ class UserManager {
const keys: { version: KeyVersion; key: SerializedKey }[] = [];
const legacyDataEncryptionKey = await this.keyManager.get(
"legacyDataEncryptionKey"
"legacyDataEncryptionKey",
{
refetchUser: false
}
);
if (legacyDataEncryptionKey)
keys.push({
@@ -548,18 +553,24 @@ class UserManager {
const email = newEmail.toLowerCase();
await http.patch(
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
{
type: "change_email",
new_email: newEmail,
password: await this.db.storage().hash(password, email, {
usesFallback: await this.usesFallbackPWHash(password)
}),
verification_code: code
},
token
);
try {
await http.patch(
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
{
type: "change_email",
new_email: newEmail,
password: await this.db.storage().hash(password, email, {
usesFallback: await this.usesFallbackPWHash(password)
}),
verification_code: code
},
token
);
} catch (e) {
const error = e as Error;
if (error.message === "Invalid token.") throw new Error("Invalid code.");
throw error;
}
}
recoverAccount(email: string) {

View File

@@ -32,7 +32,8 @@ import {
TrashCleanupInterval,
TimeFormat,
DayFormat,
WeekFormat
WeekFormat,
GroupingByIdKey
} from "../types.js";
import { ICollection } from "./collection.js";
import { SQLCachedCollection } from "../database/sql-cached-collection.js";
@@ -64,6 +65,9 @@ const defaultSettings: SettingItemMap = {
trashCleanupInterval: 7,
profile: undefined,
"groupOptions:notes:notebooks": {},
"groupOptions:notes:tags": {},
"groupOptions:notes:colors": {},
"groupOptions:trash": DEFAULT_GROUP_OPTIONS("trash"),
"groupOptions:tags": DEFAULT_GROUP_OPTIONS("tags"),
"groupOptions:notes": DEFAULT_GROUP_OPTIONS("notes"),
@@ -150,6 +154,34 @@ export class Settings implements ICollection {
return this.set(`groupOptions:${key}`, groupOptions);
}
async setGroupOptionsById(
id: string,
type: GroupingByIdKey,
groupOptions: GroupOptions
) {
const groupOptionsKey =
type === "notebook"
? "groupOptions:notes:notebooks"
: type === "tag"
? "groupOptions:notes:tags"
: "groupOptions:notes:colors";
const groupOptionsMap = this.get(groupOptionsKey);
groupOptionsMap[id] = groupOptions;
return this.set(groupOptionsKey, groupOptionsMap);
}
getGroupOptionsById(id: string, type: GroupingByIdKey) {
const groupOptions = this.get(
type === "notebook"
? "groupOptions:notes:notebooks"
: type === "tag"
? "groupOptions:notes:tags"
: "groupOptions:notes:colors"
);
return groupOptions[id] || this.get("groupOptions:notes");
}
setToolbarConfig(platform: ToolbarConfigPlatforms, config: ToolbarConfig) {
return this.set(`toolbarConfig:${platform}`, config);
}

View File

@@ -57,6 +57,7 @@ export const GroupingKey = [
"search"
] as const;
export type GroupingKey = (typeof GroupingKey)[number];
export type GroupingByIdKey = "notebook" | "tag" | "color";
export type ValueOf<T> = T[keyof T];
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
@@ -486,6 +487,12 @@ export type SettingItemMap = {
defaultTag: string | undefined;
profile: Profile | undefined;
} & Record<`groupOptions:${GroupingKey}`, GroupOptions> &
Record<
| `groupOptions:notes:notebooks`
| `groupOptions:notes:tags`
| `groupOptions:notes:colors`,
Record<string, GroupOptions>
> &
Record<`toolbarConfig:${ToolbarConfigPlatforms}`, ToolbarConfig | undefined> &
Record<`sideBarOrder:${SideBarSection}`, string[]> &
Record<`sideBarHiddenItems:${SideBarHideableSection}`, string[]>;

View File

@@ -139,6 +139,7 @@ function Header({
}): JSX.Element {
const tab = useTabContext();
const editor = editors[tab.id];
const tableOfContents = editorControllers[tab.id]?.getTableOfContents?.();
const insets = useSafeArea();
const openedTabsCount = useTabStore((state) => state.tabs.length);
const [isOpen, setOpen] = useState(false);
@@ -148,6 +149,8 @@ function Header({
state.canGoForward
]);
console.log(tableOfContents?.length);
return (
<div
style={{
@@ -496,26 +499,29 @@ function Header({
</span>
</MenuItem>
<MenuItem
value="toc"
style={{
display: "flex",
gap: 10,
alignItems: "center"
}}
>
<TableOfContentsIcon
size={20 * settings.fontScale}
color="var(--nn_primary_icon)"
/>
<span
{tableOfContents?.length ? (
<MenuItem
value="toc"
style={{
color: "var(--nn_primary_paragraph)"
display: "flex",
gap: 10,
alignItems: "center"
}}
>
{strings.toc()}
</span>
</MenuItem>
<TableOfContentsIcon
size={20 * settings.fontScale}
color="var(--nn_primary_icon)"
/>
<span
style={{
color: "var(--nn_primary_paragraph)"
}}
>
{strings.toc()}
</span>
</MenuItem>
) : null}
<MenuItem
value="scroll-top"
style={{

View File

@@ -113,13 +113,19 @@ function Tags(props: { settings: Settings; loading?: boolean }) {
backgroundColor:
index !== 0 ? "transparent" : "var(--nn_secondary_background)",
borderRadius: 6,
padding: "0px 4px",
height: "24px",
padding: "2px 4px",
height: "25px",
fontFamily: "Inter",
fontSize: 12,
color: "var(--nn_primary_icon)",
userSelect: "none",
WebkitUserSelect: "none"
WebkitUserSelect: "none",
textAlign: "left",
maxWidth: 150,
textOverflow: "ellipsis",
overflow: "hidden",
wordWrap: "break-word",
whiteSpace: "nowrap"
}}
onClick={(e) => {
e.preventDefault();

View File

@@ -18,7 +18,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 { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import {
EditorState,
Plugin,
@@ -28,6 +28,7 @@ import {
} from "prosemirror-state";
import { SearchSettings } from "../../toolbar/stores/search-store.js";
import { tiptapKeys } from "@notesnook/common";
import { toggleNodesUnderPos } from "../heading/index.js";
type DispatchFn = (tr: Transaction) => void;
declare module "@tiptap/core" {
@@ -295,12 +296,11 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
)
);
const domNode = this.editor.view.domAtPos(from).node;
scrollIntoView(domNode);
this.storage.selectedIndex = nextIndex;
tr.setMeta("isSearching", true);
tr.setMeta("selectedIndex", nextIndex);
if (dispatch) updateView(state, dispatch);
return true;
},
moveToPreviousResult:
@@ -322,10 +322,8 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
)
);
const domNode = this.editor.view.domAtPos(from).node;
scrollIntoView(domNode);
this.storage.selectedIndex = prevIndex;
tr.setMeta("isSearching", true);
tr.setMeta("selectedIndex", prevIndex);
if (dispatch) updateView(state, dispatch);
@@ -470,14 +468,90 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
decorations(state) {
return key.getState(state).results;
}
},
appendTransaction: (transactions, oldState, newState) => {
const isSearchTransaction = transactions.find((t) =>
t.getMeta("isSearching")
);
const selectedResult =
this.storage.results?.[this.storage.selectedIndex];
if (!isSearchTransaction || !selectedResult) return;
const tr = newState.tr;
scrollIntoView(this.editor.view, selectedResult.from);
if (expandCollapsedParents(tr, selectedResult.from)) {
return tr;
}
}
})
];
}
});
function scrollIntoView(domNode: Node) {
function expandCollapsedParents(tr: Transaction, pos: number) {
try {
let changed = false;
const $pos = tr.doc.resolve(pos);
for (let depth = 1; depth <= $pos.depth; depth++) {
const node = $pos.node(depth);
const nodePos = $pos.before(depth);
if (
(node.type.name === "callout" ||
node.type.name === "outlineListItem") &&
node.attrs.collapsed
) {
tr.setNodeAttribute(nodePos, "collapsed", false);
changed = true;
}
// expand collapsed heading that hid this node via hidden attribute
if (node.attrs.hidden) {
const parentNode = $pos.node(depth - 1);
const parentContentStart = depth === 1 ? 0 : $pos.before(depth - 1) + 1;
let collapsedHeadingPos = -1;
let collapsedHeadingLevel = -1;
parentNode.forEach((child, offset) => {
const childAbsPos = parentContentStart + offset;
if (childAbsPos >= nodePos) return;
if (
child.type.name === "heading" &&
child.attrs.collapsed &&
!child.attrs.hidden
) {
collapsedHeadingPos = childAbsPos;
collapsedHeadingLevel = child.attrs.level;
}
});
if (collapsedHeadingPos !== -1) {
tr.setNodeAttribute(collapsedHeadingPos, "collapsed", false);
toggleNodesUnderPos(
tr,
collapsedHeadingPos,
collapsedHeadingLevel,
false
);
changed = true;
}
}
}
if (changed) tr.setMeta("preventSave", true);
return changed;
} catch (e) {
console.error("Error expanding collapsed parents: ", e);
}
}
function scrollIntoView(view: EditorView, pos: number) {
setTimeout(() => {
const domNode = view.domAtPos(pos).node;
if ("scrollIntoView" in domNode) {
(domNode as Element).scrollIntoView({
behavior: "instant",

View File

@@ -17,17 +17,17 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Flex, Text } from "@theme-ui/components";
import { useCallback, useState } from "react";
import { Popup } from "../components/popup.js";
import { Input, Textarea } from "@theme-ui/components";
import { Embed, EmbedSizeOptions } from "../../extensions/embed/index.js";
import { convertUrlToEmbedUrl } from "@social-embed/lib";
import { InlineInput } from "../../components/inline-input/index.js";
import { Tabs, Tab } from "../../components/tabs/index.js";
import { strings } from "@notesnook/intl";
import { convertUrlToEmbedUrl, isValidUrl } from "@social-embed/lib";
import { Flex, Input, Text, Textarea } from "@theme-ui/components";
import { useCallback, useState } from "react";
import { InlineInput } from "../../components/inline-input/index.js";
import { Tab, Tabs } from "../../components/tabs/index.js";
import { Embed, EmbedSizeOptions } from "../../extensions/embed/index.js";
import { Popup } from "../components/popup.js";
type EmbedSource = "url" | "code";
export type EmbedPopupProps = {
onClose: (embed?: Embed) => void;
title: string;
@@ -38,33 +38,30 @@ export type EmbedPopupProps = {
export function EmbedPopup(props: EmbedPopupProps) {
const { onClose, onSizeChanged, title, embed } = props;
const [width, setWidth] = useState(embed?.width || 300);
const [height, setHeight] = useState(embed?.height || 150);
const [src, setSrc] = useState(embed?.src || "");
const [embedSource, setEmbedSource] = useState<EmbedSource>("url");
const [error, setError] = useState<string | null>(null);
const [size, setSize] = useState<EmbedSizeOptions>({
width: embed?.width || 300,
height: embed?.height || 150
});
const onSizeChange = useCallback(
(newWidth?: number, newHeight?: number) => {
const size: EmbedSizeOptions = newWidth
? {
width: newWidth,
height: newWidth * (height / width)
}
: newHeight
? {
width: newHeight * (width / height),
height: newHeight
}
: {
width: 0,
height: 0
};
setWidth(size.width);
setHeight(size.height);
if (onSizeChanged) onSizeChanged(size);
const hasNewWidth = Number.isFinite(newWidth);
const hasNewHeight = Number.isFinite(newHeight);
if (!hasNewWidth && !hasNewHeight) return;
setSize((size) => {
const newSize = {
width: hasNewWidth ? ((newWidth || 0) as number) : size.width,
height: hasNewHeight ? ((newHeight || 0) as number) : size.height
};
if (onSizeChanged) onSizeChanged(newSize);
return newSize;
});
},
[height, width, onSizeChanged]
[onSizeChanged]
);
return (
@@ -77,8 +74,9 @@ export function EmbedPopup(props: EmbedPopupProps) {
onClick: () => {
setError(null);
let _src = src;
let _width = width;
let _height = height;
let _width = size.width;
let _height = size.height;
if (embedSource === "code") {
const document = new DOMParser().parseFromString(src, "text/html");
if (document.getElementsByTagName("iframe").length <= 0)
@@ -100,8 +98,19 @@ export function EmbedPopup(props: EmbedPopupProps) {
if (heightValue && !isNaN(parseInt(heightValue)))
_height = parseInt(heightValue);
}
if (embedSource === "url" && !isValidUrl(src)) {
return setError("Please provide a valid url.");
}
const convertedUrl = convertUrlToEmbedUrl(_src);
if (convertedUrl) _src = convertedUrl;
if (!_src && embedSource === "url") {
return setError("Please provide a valid embed url.");
}
if (_src.startsWith("javascript:")) {
return setError("Embedding javascript code is not supported.");
}
@@ -147,7 +156,7 @@ export function EmbedPopup(props: EmbedPopupProps) {
label="width"
type="number"
placeholder={strings.width()}
value={width}
defaultValue={size.width}
sx={{
mr: 1,
fontSize: "body"
@@ -158,7 +167,7 @@ export function EmbedPopup(props: EmbedPopupProps) {
label="height"
type="number"
placeholder={strings.height()}
value={height}
defaultValue={size.height}
sx={{ fontSize: "body" }}
onChange={(e) =>
onSizeChange(undefined, e.target.valueAsNumber)

View File

@@ -25,6 +25,7 @@ import { ToolButton } from "../components/tool-button.js";
import { Editor } from "../../types.js";
import { useEditorSearchStore } from "../stores/search-store.js";
import { strings } from "@notesnook/intl";
import { inlineDebounce } from "@notesnook/common";
export type SearchReplacePopupProps = { editor: Editor };
export function SearchReplacePopup(props: SearchReplacePopupProps) {
@@ -94,7 +95,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
sx={{ p: 0, fontFamily: "monospace" }}
value={searchTerm}
onChange={(e) => {
search(e.target.value);
inlineDebounce("search", () => search(e.target.value), 100);
useEditorSearchStore.setState({ searchTerm: e.target.value });
}}
onKeyDown={(e) => {

View File

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -947,6 +948,10 @@ msgstr "Are you sure you want to logout and clear all data stored on THIS DEVICE
msgid "Are you sure you want to logout from this device? Any unsynced changes will be lost."
msgstr "Are you sure you want to logout from this device? Any unsynced changes will be lost."
#: src/strings.ts:2687
msgid "Are you sure you want to open this file: {filePath}?"
msgstr "Are you sure you want to open this file: {filePath}?"
#: src/strings.ts:1046
msgid "Are you sure you want to remove your name?"
msgstr "Are you sure you want to remove your name?"
@@ -983,6 +988,10 @@ msgstr "Attach image from URL"
msgid "Attached files"
msgstr "Attached files"
#: src/strings.ts:2678
msgid "Attaching files"
msgstr "Attaching files"
#: src/strings.ts:290
msgid "attachment"
msgstr "attachment"
@@ -992,7 +1001,7 @@ msgstr "attachment"
msgid "Attachment"
msgstr "Attachment"
#: src/strings.ts:2663
#: src/strings.ts:2667
msgid "Attachment deleted"
msgstr "Attachment deleted"
@@ -1645,6 +1654,10 @@ msgstr "Click to update"
msgid "Close"
msgstr "Close"
#: src/strings.ts:2679
msgid "Close ({seconds})"
msgstr "Close ({seconds})"
#: src/strings.ts:2019
msgid "Close all"
msgstr "Close all"
@@ -1754,6 +1767,14 @@ msgstr "Compress images before uploading"
msgid "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
msgstr "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
#: src/strings.ts:2680
msgid "Compressing"
msgstr "Compressing"
#: src/strings.ts:2684
msgid "Compression failed"
msgstr "Compression failed"
#: src/strings.ts:2262
msgid "Configure"
msgstr "Configure"
@@ -1952,6 +1973,10 @@ msgstr "Created at"
msgid "Creating a{0} backup"
msgstr "Creating a{0} backup"
#: src/strings.ts:2695
msgid "Creation date cannot be after last edited date"
msgstr "Creation date cannot be after last edited date"
#: src/strings.ts:2049
msgid "Credentials"
msgstr "Credentials"
@@ -1968,10 +1993,15 @@ msgstr "Curate the toolbar that fits your needs and matches your personality."
msgid "Current note"
msgstr "Current note"
#: src/strings.ts:1479
#: src/strings.ts:1485
msgid "Current password"
msgstr "Current password"
#: src/strings.ts:2670
msgid "Current password required"
msgstr "Current password required"
#: src/strings.ts:1229
msgid "Current path: {path}"
msgstr "Current path: {path}"
@@ -2541,6 +2571,10 @@ msgstr "Encrypted backup"
msgid "Encrypted, private, secure."
msgstr "Encrypted, private, secure."
#: src/strings.ts:2681
msgid "Encrypting"
msgstr "Encrypting"
#: src/strings.ts:940
msgid "Encrypting attachment"
msgstr "Encrypting attachment"
@@ -2737,6 +2771,18 @@ msgstr "Experience the next level of private note taking\""
msgid "Expiry date"
msgstr "Expiry date"
#: src/strings.ts:2692
msgid "Expiry date cannot be more than 1 year in the future"
msgstr "Expiry date cannot be more than 1 year in the future"
#: src/strings.ts:2690
msgid "Expiry date must be in the future"
msgstr "Expiry date must be in the future"
#: src/strings.ts:2693
msgid "Expiry date set"
msgstr "Expiry date set"
#: src/strings.ts:2550
msgid "Explore all plans"
msgstr "Explore all plans"
@@ -2907,10 +2953,18 @@ msgstr "File length is 0. Please upload this file again from the attachment mana
msgid "File length mismatch. Expected {expectedSize} but got {currentSize} bytes. Please upload this file again from the attachment manager."
msgstr "File length mismatch. Expected {expectedSize} but got {currentSize} bytes. Please upload this file again from the attachment manager."
#: src/strings.ts:2689
msgid "File links cannot be opened in browsers. Please use the Notesnook desktop app."
msgstr "File links cannot be opened in browsers. Please use the Notesnook desktop app."
#: src/strings.ts:949
msgid "File mismatch"
msgstr "File mismatch"
#: src/strings.ts:2683
msgid "File size limit exceeded. Please upgrade your plan."
msgstr "File size limit exceeded. Please upgrade your plan."
#: src/strings.ts:945
msgid "File size should be less than {sizeInMB}"
msgstr "File size should be less than {sizeInMB}"
@@ -3784,6 +3838,10 @@ msgstr "Login failed"
msgid "Login required"
msgstr "Login required"
#: src/strings.ts:2671
msgid "Login required to restore attachments"
msgstr "Login required to restore attachments"
#: src/strings.ts:779
msgid "Login successful"
msgstr "Login successful"
@@ -3888,6 +3946,10 @@ msgstr "Math & formulas"
msgid "Maximize"
msgstr "Maximize"
#: src/strings.ts:2698
msgid "Maximum reminder date is {maxDate}"
msgstr "Maximum reminder date is {maxDate}"
#: src/strings.ts:2080
msgid "Meet other privacy-minded people & talk to us directly about your concerns, issues and suggestions."
msgstr "Meet other privacy-minded people & talk to us directly about your concerns, issues and suggestions."
@@ -4034,6 +4096,10 @@ msgstr "Multi-layer encryption to most important notes"
msgid "Name"
msgstr "Name"
#: src/strings.ts:2669
msgid "Name is required."
msgstr "Name is required."
#: src/strings.ts:1688
msgid "Native high-performance encryption"
msgstr "Native high-performance encryption"
@@ -4247,6 +4313,10 @@ msgstr "Note copied to clipboard"
msgid "Note does not exist"
msgstr "Note does not exist"
#: src/strings.ts:2696
msgid "Note duplicated"
msgstr "Note duplicated"
#: src/strings.ts:458
msgid "Note history"
msgstr "Note history"
@@ -4403,10 +4473,6 @@ msgstr "Okay"
msgid "Old - new"
msgstr "Old - new"
#: src/strings.ts:1479
msgid "Old password"
msgstr "Old password"
#: src/strings.ts:1835
msgid "Older version"
msgstr "Older version"
@@ -4483,6 +4549,10 @@ msgstr "Open source."
msgid "Open the two-factor authentication (TOTP) app to view your authentication code."
msgstr "Open the two-factor authentication (TOTP) app to view your authentication code."
#: src/strings.ts:2685
msgid "Opening local file"
msgstr "Opening local file"
#: src/strings.ts:1857
msgid "Optional"
msgstr "Optional"
@@ -4731,6 +4801,10 @@ msgstr "Please fill all the fields to continue."
msgid "Please grant notifications permission to add new reminders."
msgstr "Please grant notifications permission to add new reminders."
#: src/strings.ts:2677
msgid "Please login to download attachments."
msgstr "Please login to download attachments."
#: src/strings.ts:873
msgid "Please make sure you have saved the recovery key. Tap one more time to confirm."
msgstr "Please make sure you have saved the recovery key. Tap one more time to confirm."
@@ -6664,6 +6738,10 @@ msgstr "Title"
msgid "Title format"
msgstr "Title format"
#: src/strings.ts:2668
msgid "Title is required"
msgstr "Title is required"
#: src/strings.ts:1188
msgid "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
msgstr "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
@@ -7481,6 +7559,16 @@ msgstr "You have unsynced notes. Take a backup or sync your notes to avoid losin
msgid "You must log out in order to change/reset server URLs."
msgstr "You must log out in order to change/reset server URLs."
#: src/strings.ts:2673
msgid ""
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
" \n"
"Continue without attachments?"
msgstr ""
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
" \n"
"Continue without attachments?"
#: src/strings.ts:836
msgid "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
msgstr "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"

View File

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -947,6 +948,10 @@ msgstr ""
msgid "Are you sure you want to logout from this device? Any unsynced changes will be lost."
msgstr ""
#: src/strings.ts:2687
msgid "Are you sure you want to open this file: {filePath}?"
msgstr ""
#: src/strings.ts:1046
msgid "Are you sure you want to remove your name?"
msgstr ""
@@ -983,6 +988,10 @@ msgstr ""
msgid "Attached files"
msgstr ""
#: src/strings.ts:2678
msgid "Attaching files"
msgstr ""
#: src/strings.ts:290
msgid "attachment"
msgstr ""
@@ -992,7 +1001,7 @@ msgstr ""
msgid "Attachment"
msgstr ""
#: src/strings.ts:2663
#: src/strings.ts:2667
msgid "Attachment deleted"
msgstr ""
@@ -1634,6 +1643,10 @@ msgstr ""
msgid "Close"
msgstr ""
#: src/strings.ts:2679
msgid "Close ({seconds})"
msgstr ""
#: src/strings.ts:2019
msgid "Close all"
msgstr ""
@@ -1741,6 +1754,14 @@ msgstr ""
#: src/strings.ts:144
msgid "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2680
msgid "Compressing"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2684
msgid "Compression failed"
msgstr ""
#: src/strings.ts:2262
@@ -1941,6 +1962,10 @@ msgstr ""
msgid "Creating a{0} backup"
msgstr ""
#: src/strings.ts:2695
msgid "Creation date cannot be after last edited date"
msgstr ""
#: src/strings.ts:2049
msgid "Credentials"
msgstr ""
@@ -1957,10 +1982,15 @@ msgstr ""
msgid "Current note"
msgstr ""
#: src/strings.ts:1479
#: src/strings.ts:1485
msgid "Current password"
msgstr ""
#: src/strings.ts:2670
msgid "Current password required"
msgstr ""
#: src/strings.ts:1229
msgid "Current path: {path}"
msgstr ""
@@ -2530,6 +2560,10 @@ msgstr ""
msgid "Encrypted, private, secure."
msgstr ""
#: src/strings.ts:2681
msgid "Encrypting"
msgstr ""
#: src/strings.ts:940
msgid "Encrypting attachment"
msgstr ""
@@ -2726,6 +2760,18 @@ msgstr ""
msgid "Expiry date"
msgstr ""
#: src/strings.ts:2692
msgid "Expiry date cannot be more than 1 year in the future"
msgstr ""
#: src/strings.ts:2690
msgid "Expiry date must be in the future"
msgstr ""
#: src/strings.ts:2693
msgid "Expiry date set"
msgstr ""
#: src/strings.ts:2550
msgid "Explore all plans"
msgstr ""
@@ -2896,10 +2942,18 @@ msgstr ""
msgid "File length mismatch. Expected {expectedSize} but got {currentSize} bytes. Please upload this file again from the attachment manager."
msgstr ""
#: src/strings.ts:2689
msgid "File links cannot be opened in browsers. Please use the Notesnook desktop app."
msgstr ""
#: src/strings.ts:949
msgid "File mismatch"
msgstr ""
#: src/strings.ts:2683
msgid "File size limit exceeded. Please upgrade your plan."
msgstr ""
#: src/strings.ts:945
msgid "File size should be less than {sizeInMB}"
msgstr ""
@@ -3764,6 +3818,10 @@ msgstr ""
msgid "Login required"
msgstr ""
#: src/strings.ts:2671
msgid "Login required to restore attachments"
msgstr ""
#: src/strings.ts:779
msgid "Login successful"
msgstr ""
@@ -3868,6 +3926,10 @@ msgstr ""
msgid "Maximize"
msgstr ""
#: src/strings.ts:2698
msgid "Maximum reminder date is {maxDate}"
msgstr ""
#: src/strings.ts:2080
msgid "Meet other privacy-minded people & talk to us directly about your concerns, issues and suggestions."
msgstr ""
@@ -4014,6 +4076,10 @@ msgstr ""
msgid "Name"
msgstr ""
#: src/strings.ts:2669
msgid "Name is required."
msgstr ""
#: src/strings.ts:1688
msgid "Native high-performance encryption"
msgstr ""
@@ -4227,6 +4293,10 @@ msgstr ""
msgid "Note does not exist"
msgstr ""
#: src/strings.ts:2696
msgid "Note duplicated"
msgstr ""
#: src/strings.ts:458
msgid "Note history"
msgstr ""
@@ -4377,10 +4447,6 @@ msgstr ""
msgid "Old - new"
msgstr ""
#: src/strings.ts:1479
msgid "Old password"
msgstr ""
#: src/strings.ts:1835
msgid "Older version"
msgstr ""
@@ -4457,6 +4523,10 @@ msgstr ""
msgid "Open the two-factor authentication (TOTP) app to view your authentication code."
msgstr ""
#: src/strings.ts:2685
msgid "Opening local file"
msgstr ""
#: src/strings.ts:1857
msgid "Optional"
msgstr ""
@@ -4705,6 +4775,10 @@ msgstr ""
msgid "Please grant notifications permission to add new reminders."
msgstr ""
#: src/strings.ts:2677
msgid "Please login to download attachments."
msgstr ""
#: src/strings.ts:873
msgid "Please make sure you have saved the recovery key. Tap one more time to confirm."
msgstr ""
@@ -6623,6 +6697,10 @@ msgstr ""
msgid "Title format"
msgstr ""
#: src/strings.ts:2668
msgid "Title is required"
msgstr ""
#: src/strings.ts:1188
msgid "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
msgstr ""
@@ -7423,6 +7501,13 @@ msgstr ""
msgid "You must log out in order to change/reset server URLs."
msgstr ""
#: src/strings.ts:2673
msgid ""
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
" \n"
"Continue without attachments?"
msgstr ""
#: src/strings.ts:836
msgid "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
msgstr ""

View File

@@ -1476,7 +1476,7 @@ $day$: Current day (eg. Monday)`,
someNotesPublished: () => t`Some notes are published`,
unpublishToDelete: () => t`Unpublish notes to delete them`,
filterAttachments: () => t`Filter attachments by filename, type or hash`,
oldPassword: () => t`Old password`,
oldPassword: () => t`Current password`,
newPassword: () => t`New password`,
email: () => t`Email`,
emailInvalid: () => t`Invalid email`,
@@ -2664,5 +2664,36 @@ Use this if changes from other devices are not appearing on this device. This wi
deleteAttachment: () => t`Delete attachment`,
deleteAttachmentConfirm: () =>
t`Are you sure you want to delete this attachment?`,
attachmentDeleted: () => t`Attachment deleted`
attachmentDeleted: () => t`Attachment deleted`,
titleIsRequired: () => t`Title is required`,
nameIsRequired: () => t`Name is required.`,
currentPasswordRequired: () => t`Current password required`,
loginToRestoreAttachments: () => t`Login required to restore attachments`,
loginToRestoreAttachmentsDesc: () =>
t`You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).
Continue without attachments?`,
pleaseLoginToDownloadAttachments: () =>
t`Please login to download attachments.`,
attachingFiles: () => t`Attaching files`,
closeCountdown: (seconds: number) => t`Close (${seconds})`,
compressing: () => t`Compressing`,
encrypting: () => t`Encrypting`,
fileSizeLimitExceededPleaseUpgrade: () =>
t`File size limit exceeded. Please upgrade your plan.`,
compressionFailed: () => t`Compression failed`,
openingLocalFile: () => t`Opening local file`,
openingLocalFileDesc: (filePath: string) =>
t`Are you sure you want to open this file: ${filePath}?`,
cantOpenFileLinksInBrowsers: () =>
t`File links cannot be opened in browsers. Please use the Notesnook desktop app.`,
expiryDateMustBeInTheFuture: () => t`Expiry date must be in the future`,
expiryDateCannotBeMoreThan1YearInTheFuture: () =>
t`Expiry date cannot be more than 1 year in the future`,
expiryDateSet: () => t`Expiry date set`,
creationDateCannotBeAfterLastEditedDate: () =>
t`Creation date cannot be after last edited date`,
noteDuplicated: () => t`Note duplicated`,
maximumReminderDate: (maxDate: string) =>
t`Maximum reminder date is ${maxDate}`
};