Compare commits

...

34 Commits

Author SHA1 Message Date
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
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
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
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
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
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
48 changed files with 1059 additions and 456 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.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.3.17",
"version": "3.3.20",
"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.20",
"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

@@ -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

@@ -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,27 +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) => {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
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

@@ -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

@@ -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.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.3.17",
"version": "3.3.20",
"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.20",
"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 }
@@ -52,6 +54,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 +86,7 @@ export function DayPicker(props: DayPickerProps) {
<Button
variant="icon"
sx={{ p: 0 }}
{...subtractOffset({ months: 1 })}
{...subtractOffset({ months: 1 }, { disabled: isPrevMonthBeforeMin })}
>
<ChevronLeft />
</Button>
@@ -83,9 +103,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 +116,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 +135,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 +148,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 +230,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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";
@@ -89,7 +96,19 @@ const features: Record<FeatureKeys, Feature> = {
)
}
]
: [],
: [
{
icon: File,
title: "Improved attachments UX",
subtitle:
"We've improved the UI/UX of attaching multiple files into the editor. The entire process is now handled in a unified dialog."
},
{
icon: InternalLink,
title: "Opening file links on desktop",
subtitle: "The NN Desktop app can now open file links (file:///)."
}
],
cta: {
title: strings.gotIt(),
icon: Checkmark,

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

@@ -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

@@ -437,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={{

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 });

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

@@ -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

@@ -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

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-05-08 10:43+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"
@@ -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"
@@ -2546,6 +2567,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"
@@ -2912,10 +2937,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}"
@@ -3789,6 +3822,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"
@@ -4488,6 +4525,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"
@@ -4736,6 +4777,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."
@@ -7490,6 +7535,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-05-08 10:43+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 ""
@@ -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
@@ -2535,6 +2556,10 @@ msgstr ""
msgid "Encrypted, private, secure."
msgstr ""
#: src/strings.ts:2681
msgid "Encrypting"
msgstr ""
#: src/strings.ts:940
msgid "Encrypting attachment"
msgstr ""
@@ -2901,10 +2926,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 ""
@@ -3769,6 +3802,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 ""
@@ -4462,6 +4499,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 ""
@@ -4710,6 +4751,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 ""
@@ -7432,6 +7477,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

@@ -2667,5 +2667,24 @@ Use this if changes from other devices are not appearing on this device. This wi
attachmentDeleted: () => t`Attachment deleted`,
titleIsRequired: () => t`Title is required`,
nameIsRequired: () => t`Name is required.`,
currentPasswordRequired: () => t`Current password 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.`
};