diff --git a/apps/web/src/dialogs/failed-sync-items-dialog.tsx b/apps/web/src/dialogs/failed-sync-items-dialog.tsx
new file mode 100644
index 000000000..186ec4e57
--- /dev/null
+++ b/apps/web/src/dialogs/failed-sync-items-dialog.tsx
@@ -0,0 +1,345 @@
+/*
+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 .
+*/
+
+import { useState } from "react";
+import { getFormattedDate, usePromise } from "@notesnook/common";
+import { FailedSyncItem } from "@notesnook/core";
+import { SerializedKey } from "@notesnook/crypto";
+import { Box, Button, Flex, Text } from "@theme-ui/components";
+import { db } from "../common/db";
+import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
+import Dialog from "../components/dialog";
+import { FlexScrollContainer } from "../components/scroll-container";
+import { strings } from "@notesnook/intl";
+import { showToast } from "../utils/toast";
+import { ConfirmDialog } from "./confirm";
+import { showPasswordDialog } from "./password-dialog";
+
+type FailedSyncItemsDialogProps = BaseDialogProps;
+
+const COLUMNS = [
+ { title: strings.dateSynced(), width: "150px" },
+ { title: strings.dataTypesCamelCase.item(), width: "110px" },
+ { title: strings.itemId(), width: "140px" },
+ // { title: strings.keyVersion(), width: "90px" },
+ { title: strings.error(), width: "1fr" }
+];
+
+function TypeBadge({ type }: { type: string }) {
+ return (
+
+ {type}
+
+ );
+}
+
+async function resolveKey(input: {
+ password?: string;
+ key?: string;
+}): Promise {
+ const user = await db.user.getUser();
+ if (!user?.salt) throw new Error("User salt not found. Please relogin.");
+
+ if (input.key) {
+ return { key: input.key, salt: user.salt };
+ }
+
+ if (!input.password) throw new Error("Password or encryption key required.");
+
+ try {
+ return await db.storage().generateCryptoKey(input.password, user.salt);
+ } catch {
+ return await db
+ .storage()
+ .generateCryptoKeyFallback(input.password, user.salt);
+ }
+}
+
+async function retryWithKey(
+ ids: string[],
+ key: SerializedKey
+): Promise {
+ const result = await db.syncer.sync.retryFailedItems(ids, key);
+ if (result.succeeded.length > 0 && result.failed.length === 0) {
+ showToast("success", strings.decryptionSucceeded(result.succeeded.length));
+ return true;
+ }
+ if (result.succeeded.length > 0 && result.failed.length > 0) {
+ showToast(
+ "error",
+ strings.decryptionPartialSuccess(
+ result.succeeded.length,
+ result.failed.length
+ )
+ );
+ // Still close the password dialog so the table can refresh.
+ return true;
+ }
+ const firstError = result.failed[0]?.error;
+ throw new Error(
+ firstError
+ ? `${strings.decryptionFailed()}: ${firstError}`
+ : strings.decryptionFailed()
+ );
+}
+
+export const FailedSyncItemsDialog = DialogManager.register(
+ function FailedSyncItemsDialog(props: FailedSyncItemsDialogProps) {
+ const result = usePromise(() => db.failedSyncItems.all.items());
+ const [busyIds, setBusyIds] = useState([]);
+
+ function refresh() {
+ if (result.status !== "pending") result.refresh();
+ }
+
+ async function deleteAll() {
+ const ok = await ConfirmDialog.show({
+ title: strings.deleteAll(),
+ subtitle: strings.deleteAllFailedSyncItemsDesc(),
+ positiveButtonText: strings.yes(),
+ negativeButtonText: strings.no()
+ });
+ if (!ok) return;
+
+ await db.failedSyncItems.clear();
+ showToast("success", strings.allItemsDeleted());
+ refresh();
+ }
+
+ async function retryWithCurrentKeys(ids: string[]) {
+ setBusyIds((prev) => [...prev, ...ids]);
+ try {
+ const keys = await db.user.getDataEncryptionKeys();
+ if (!keys?.length) {
+ showToast("error", strings.decryptionFailed());
+ return;
+ }
+
+ let succeeded = 0;
+ let remaining = [...ids];
+ for (const keyInfo of keys) {
+ if (remaining.length === 0) break;
+ const result = await db.syncer.sync.retryFailedItems(
+ remaining,
+ keyInfo.key
+ );
+ succeeded += result.succeeded.length;
+ remaining = result.failed.map((f) => f.id);
+ }
+
+ if (succeeded > 0 && remaining.length === 0) {
+ showToast("success", strings.decryptionSucceeded(succeeded));
+ } else if (succeeded > 0) {
+ showToast(
+ "error",
+ strings.decryptionPartialSuccess(succeeded, remaining.length)
+ );
+ } else {
+ showToast("error", strings.decryptionFailed());
+ }
+ refresh();
+ } finally {
+ setBusyIds((prev) => prev.filter((id) => !ids.includes(id)));
+ }
+ }
+
+ async function retryWithCustomKey(ids: string[]) {
+ const ok = await showPasswordDialog({
+ title: strings.retryWithCustomKey(),
+ inputs: {
+ key: {
+ label: strings.encryptionKey(),
+ type: "password",
+ required: true
+ }
+ },
+ async validate({ key }) {
+ if (!key) return false;
+ setBusyIds((prev) => [...prev, ...ids]);
+ try {
+ const resolved = await resolveKey({ key });
+ return await retryWithKey(ids, resolved);
+ } finally {
+ setBusyIds((prev) => prev.filter((id) => !ids.includes(id)));
+ }
+ }
+ });
+ if (ok) refresh();
+ }
+
+ const items: FailedSyncItem[] =
+ result.status === "fulfilled" ? result.value : [];
+ const allIds = items.map((item) => item.id);
+ const hasItems = items.length > 0;
+
+ return (
+
+ );
+ }
+);
diff --git a/apps/web/src/dialogs/settings/sync-settings.ts b/apps/web/src/dialogs/settings/sync-settings.ts
index 1a00a013c..2a651b9f8 100644
--- a/apps/web/src/dialogs/settings/sync-settings.ts
+++ b/apps/web/src/dialogs/settings/sync-settings.ts
@@ -22,6 +22,7 @@ import { useStore as useAppStore } from "../../stores/app-store";
import { useStore as useSettingStore } from "../../stores/setting-store";
import { ConfirmDialog } from "../confirm";
import { strings } from "@notesnook/intl";
+import { FailedSyncItemsDialog } from "../failed-sync-items-dialog";
export const SyncSettings: SettingsGroup[] = [
{
@@ -92,7 +93,14 @@ export const SyncSettings: SettingsGroup[] = [
toggle: () => useSettingStore.getState().toggleFullOfflineMode()
}
]
- },
+ }
+ ]
+ },
+ {
+ key: "troubleshoot-sync",
+ section: "sync",
+ header: strings.troubleshoot(),
+ settings: [
{
key: "force-sync",
title: strings.havingProblemsWithSync(),
@@ -140,6 +148,28 @@ export const SyncSettings: SettingsGroup[] = [
})
}
]
+ },
+ {
+ key: "failed-sync-items",
+ title: strings.failedSyncItems(),
+ description: strings.failedSyncItemsDesc(),
+ keywords: [
+ "failed",
+ "decrypt",
+ "sync error",
+ "missing key",
+ "ciphertext"
+ ],
+ components: [
+ {
+ type: "button",
+ title: strings.show(),
+ variant: "secondary",
+ action: () => {
+ FailedSyncItemsDialog.show({});
+ }
+ }
+ ]
}
]
}
diff --git a/packages/core/src/api/sync/__tests__/process-chunk.test.js b/packages/core/src/api/sync/__tests__/process-chunk.test.js
index 4338f5d66..9d532cb2b 100644
--- a/packages/core/src/api/sync/__tests__/process-chunk.test.js
+++ b/packages/core/src/api/sync/__tests__/process-chunk.test.js
@@ -278,6 +278,100 @@ test("failedSyncItems.clear removes all failed items", () =>
expect(await isItemDeleted(db, "notes", noteId2)).toBeTruthy();
}));
+test("failedSyncItems.remove deletes the failed row without soft-deleting the underlying item", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+
+ const noteId = await db.notes.add(TEST_NOTE);
+ const failedId = await db.failedSyncItems.add(
+ failedItemPayload(noteId, "note")
+ );
+
+ await db.failedSyncItems.remove([failedId]);
+
+ expect(await db.failedSyncItems.all.items()).toHaveLength(0);
+ expect(await isItemDeleted(db, "notes", noteId)).toBe(false);
+ expect(await db.notes.note(noteId)).toBeDefined();
+ }));
+
+test("retryFailedItems decrypts with a custom key, merges the item, and removes the failed row", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+
+ const keys = await db.user.getDataEncryptionKeys();
+ const key = keys[0].key;
+ const notePayload = {
+ id: "retry-note-1",
+ type: "note",
+ title: "Recovered Note",
+ dateModified: Date.now(),
+ dateCreated: Date.now()
+ };
+ const cipher = await db.storage().encrypt(key, JSON.stringify(notePayload));
+ const failedId = await db.failedSyncItems.add({
+ itemId: notePayload.id,
+ itemType: "note",
+ cipher: {
+ ...cipher,
+ id: notePayload.id,
+ v: CURRENT_DATABASE_VERSION,
+ keyVersion: keys[0].version
+ },
+ errors: ["previous failure"],
+ dateSynced: Date.now()
+ });
+
+ const result = await db.syncer.sync.retryFailedItems([failedId], key);
+
+ expect(result.succeeded).toEqual([failedId]);
+ expect(result.failed).toHaveLength(0);
+ expect(await db.failedSyncItems.all.items()).toHaveLength(0);
+
+ const note = await db.notes.note(notePayload.id);
+ expect(note).toBeDefined();
+ expect(note.title).toBe("Recovered Note");
+ }));
+
+test("retryFailedItems records a failure and keeps the failed row when the key is wrong", () =>
+ databaseTest().then(async (db) => {
+ await loginFakeUser(db);
+
+ const keys = await db.user.getDataEncryptionKeys();
+ const key = keys[0].key;
+ const wrongKey = await db.crypto().generateRandomKey();
+ const notePayload = {
+ id: "retry-note-2",
+ type: "note",
+ title: "Still Locked",
+ dateModified: Date.now(),
+ dateCreated: Date.now()
+ };
+ const cipher = await db.storage().encrypt(key, JSON.stringify(notePayload));
+ const failedId = await db.failedSyncItems.add({
+ itemId: notePayload.id,
+ itemType: "note",
+ cipher: {
+ ...cipher,
+ id: notePayload.id,
+ v: CURRENT_DATABASE_VERSION,
+ keyVersion: keys[0].version
+ },
+ errors: ["previous failure"],
+ dateSynced: Date.now()
+ });
+
+ const result = await db.syncer.sync.retryFailedItems([failedId], wrongKey);
+
+ expect(result.succeeded).toHaveLength(0);
+ expect(result.failed).toHaveLength(1);
+ expect(result.failed[0].id).toBe(failedId);
+
+ const failedItems = await db.failedSyncItems.all.items();
+ expect(failedItems).toHaveLength(1);
+ expect(failedItems[0].errors.length).toBeGreaterThan(1);
+ expect(await db.notes.note(notePayload.id)).toBeUndefined();
+ }));
+
async function isItemDeleted(db, collectionName, itemId) {
const item = await db
.sql()
diff --git a/packages/core/src/api/sync/index.ts b/packages/core/src/api/sync/index.ts
index 573395b4a..5be308b6d 100644
--- a/packages/core/src/api/sync/index.ts
+++ b/packages/core/src/api/sync/index.ts
@@ -367,11 +367,10 @@ export class Sync {
options: SyncOptions
) {
const itemType = chunk.type;
- const decrypted: string[] = [];
+ const decrypted: { data: string; version: number }[] = [];
// Pre-group items by keyVersion for O(1) lookups
const itemsByKeyVersion = new Map();
- const versionMap = new Map();
for (const item of chunk.items) {
const keyVersion = item.keyVersion ?? KEY_VERSION.LEGACY;
@@ -381,7 +380,6 @@ export class Sync {
} else {
itemsByKeyVersion.set(keyVersion, [item]);
}
- versionMap.set(item.id, item.v);
}
const failedItems: SyncItem[] = [];
@@ -405,7 +403,9 @@ export class Sync {
const decryptedItems = await this.db
.storage()
.decryptMulti(keyInfo.key, items);
- decrypted.push(...decryptedItems);
+ for (let i = 0; i < decryptedItems.length; ++i) {
+ decrypted.push({ data: decryptedItems[i], version: items[i].v });
+ }
} catch (error) {
this.logger.error(
error,
@@ -427,11 +427,7 @@ export class Sync {
.decrypt(keyInfo.key, item)
.catch((error) => {
decryptionErrors.push(
- `Failed to decrypt item ${item.id} with key version ${
- keyInfo.version
- }. Error: ${
- error instanceof Error ? error.message : JSON.stringify(error)
- }`
+ error instanceof Error ? error.message : JSON.stringify(error)
);
this.logger.error(
error,
@@ -440,7 +436,7 @@ export class Sync {
return false;
});
if (typeof decryptedItem === "string") {
- decrypted.push(decryptedItem);
+ decrypted.push({ data: decryptedItem, version: item.v });
break;
}
}
@@ -456,18 +452,84 @@ export class Sync {
}
}
- const deserialized: MaybeDeletedItem- [] = [];
- for (let i = 0; i < decrypted.length; ++i) {
- const decryptedItem = JSON.parse(decrypted[i]) as MaybeDeletedItem
- ;
- const version = versionMap.get(decryptedItem.id);
- if (version === undefined) {
+ await this.mergeDecryptedItems(itemType, decrypted, options);
+ }
+
+ /**
+ * Attempt to decrypt and merge failed sync items using the provided key.
+ * Successfully recovered items are removed from failedSyncItems without
+ * soft-deleting the underlying item. Failures append to the stored error list.
+ */
+ async retryFailedItems(
+ ids: string[],
+ key: SerializedKey
+ ): Promise<{
+ succeeded: string[];
+ failed: { id: string; error: string }[];
+ }> {
+ const result: {
+ succeeded: string[];
+ failed: { id: string; error: string }[];
+ } = { succeeded: [], failed: [] };
+ if (ids.length <= 0) return result;
+
+ const items = await this.db.failedSyncItems.all.items(ids);
+ for (const failedItem of items) {
+ try {
+ await this.retryFailedItem(failedItem, key);
+ result.succeeded.push(failedItem.id);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : JSON.stringify(error);
this.logger.error(
- new Error(
- `Version not found for item ${decryptedItem.id}. Skipping item.`
- )
+ error,
+ `Failed to retry decryption for item ${failedItem.itemId}.`
);
- continue;
+ result.failed.push({ id: failedItem.id, error: message });
+ await this.db.failedSyncItems.collection.upsert({
+ ...failedItem,
+ errors: [...(failedItem.errors ?? []), message],
+ dateModified: Date.now()
+ });
}
+ }
+ return result;
+ }
+
+ private async retryFailedItem(
+ failedItem: {
+ id: string;
+ itemId: string;
+ itemType: SyncableItemType;
+ cipher: SyncItem;
+ },
+ key: SerializedKey
+ ) {
+ const { cipher, itemType, itemId } = failedItem;
+ const decrypted = await this.db.storage().decrypt(key, cipher);
+ const merged = await this.mergeDecryptedItems(itemType, [
+ { data: decrypted, version: cipher.v }
+ ]);
+ if (merged.length === 0) {
+ throw new Error(`Failed to deserialize item ${itemId}.`);
+ }
+ await this.db.failedSyncItems.remove([failedItem.id]);
+ }
+
+ /**
+ * Deserialize decrypted payloads, merge with local items, and persist.
+ * Shared by processChunk and retryFailedItems.
+ */
+ private async mergeDecryptedItems(
+ itemType: SyncableItemType,
+ decrypted: { data: string; version: number }[],
+ options: Pick = {}
+ ): Promise<(MaybeDeletedItem
- | undefined)[]> {
+ if (decrypted.length === 0) return [];
+
+ const deserialized: MaybeDeletedItem
- [] = [];
+ for (const { data, version } of decrypted) {
+ const decryptedItem = JSON.parse(data) as MaybeDeletedItem
- ;
const item = await deserializeItem(
decryptedItem,
itemType,
@@ -477,38 +539,38 @@ export class Sync {
if (item) deserialized.push(item);
}
- const collectionType = SYNC_COLLECTIONS_MAP[itemType];
+ if (deserialized.length === 0) return [];
+ const collectionType = SYNC_COLLECTIONS_MAP[itemType];
if (!collectionType) {
this.logger.error(
new Error(
- `Unknown collection type for item type ${itemType}. Skipping chunk.`
+ `Unknown collection type for item type ${itemType}. Skipping items.`
)
);
- return;
+ return [];
}
const collection = this.db[collectionType].collection;
- const localItems = await collection.records(chunk.items.map((i) => i.id));
+ const localItems = await collection.records(deserialized.map((i) => i.id));
let items: (MaybeDeletedItem
- | undefined)[] = [];
if (itemType === "content") {
items = deserialized.map((item) =>
this.merger.mergeContent(item, localItems[item.id])
);
+ } else if (itemType === "attachment") {
+ items = await Promise.all(
+ deserialized.map((item) =>
+ this.merger.mergeAttachment(
+ item as MaybeDeletedItem,
+ localItems[item.id] as MaybeDeletedItem
+ )
+ )
+ );
} else {
- items =
- itemType === "attachment"
- ? await Promise.all(
- deserialized.map((item) =>
- this.merger.mergeAttachment(
- item as MaybeDeletedItem,
- localItems[item.id] as MaybeDeletedItem
- )
- )
- )
- : deserialized.map((item) =>
- this.merger.mergeItem(item, localItems[item.id])
- );
+ items = deserialized.map((item) =>
+ this.merger.mergeItem(item, localItems[item.id])
+ );
}
if (itemType === "note" || itemType === "content") {
@@ -533,6 +595,7 @@ export class Sync {
ids: items.map((i) => i?.id)
});
await collection.put(items as any);
+ return items;
}
private async pushItem(deviceId: string, item: SyncTransferItem) {
diff --git a/packages/core/src/collections/failed-sync-items.ts b/packages/core/src/collections/failed-sync-items.ts
index 6011d596f..a7a5e2e9b 100644
--- a/packages/core/src/collections/failed-sync-items.ts
+++ b/packages/core/src/collections/failed-sync-items.ts
@@ -87,6 +87,11 @@ export class FailedSyncItems implements ICollection {
await this.collection.delete(ids);
}
+ async remove(ids: string[]) {
+ if (ids.length <= 0) return;
+ await this.collection.delete(ids);
+ }
+
async clear() {
const ids = await this.all.ids();
await this.delete(ids);
diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po
index dd465c6b1..3a6aa926b 100644
--- a/packages/intl/locale/en.po
+++ b/packages/intl/locale/en.po
@@ -512,6 +512,10 @@ msgstr "{count, plural, one {Version deleted} other {# versions deleted}}"
msgid "{count} characters"
msgstr "{count} characters"
+#: src/strings.ts:2823
+msgid "{count} item(s) decrypted successfully"
+msgstr "{count} item(s) decrypted successfully"
+
#: src/strings.ts:1566
msgid "{days, plural, one {1 day} other {# days}}"
msgstr "{days, plural, one {1 day} other {# days}}"
@@ -596,6 +600,10 @@ msgstr "{platform, select, android {Backup file saved in \"Notesnook backups\" f
msgid "{selected} selected"
msgstr "{selected} selected"
+#: src/strings.ts:2826
+msgid "{succeeded} item(s) recovered, {failed} still failed"
+msgstr "{succeeded} item(s) recovered, {failed} still failed"
+
#: src/strings.ts:382
msgid "{type, select, github {v{version} has been released on GitHub} other {v{version} has been released}}"
msgstr "{type, select, github {v{version} has been released on GitHub} other {v{version} has been released}}"
@@ -995,6 +1003,10 @@ msgstr "Are you sure you want to clear trash?"
msgid "Are you sure you want to delete all failed inbox items?"
msgstr "Are you sure you want to delete all failed inbox items?"
+#: src/strings.ts:2819
+msgid "Are you sure you want to delete all failed sync items? This will also remove the corresponding local items."
+msgstr "Are you sure you want to delete all failed sync items? This will also remove the corresponding local items."
+
#: src/strings.ts:2734
msgid "Are you sure you want to delete this attachment?"
msgstr "Are you sure you want to delete this attachment?"
@@ -2258,6 +2270,10 @@ msgstr "Debugging"
msgid "Decrease {title}"
msgstr "Decrease {title}"
+#: src/strings.ts:2824
+msgid "Decryption failed"
+msgstr "Decryption failed"
+
#: src/strings.ts:660
#: src/strings.ts:2010
msgid "Default"
@@ -3027,6 +3043,10 @@ msgstr "Failed"
msgid "Failed inbox items"
msgstr "Failed inbox items"
+#: src/strings.ts:2814
+msgid "Failed sync items"
+msgstr "Failed sync items"
+
#: src/strings.ts:2653
msgid "Failed to attach file"
msgstr "Failed to attach file"
@@ -3792,6 +3812,10 @@ msgstr "Item"
msgid "Item deleted"
msgstr "Item deleted"
+#: src/strings.ts:2827
+msgid "Item ID"
+msgstr "Item ID"
+
#: src/strings.ts:311
#: src/strings.ts:1705
msgid "items"
@@ -4500,6 +4524,10 @@ msgstr "No encryption key found"
msgid "No failed inbox items"
msgstr "No failed inbox items"
+#: src/strings.ts:2817
+msgid "No failed sync items"
+msgstr "No failed sync items"
+
#: src/strings.ts:1667
msgid "No headings found"
msgstr "No headings found"
@@ -5846,6 +5874,14 @@ msgstr "Resubscribe to Pro"
msgid "Retry"
msgstr "Retry"
+#: src/strings.ts:2821
+msgid "Retry with current keys"
+msgstr "Retry with current keys"
+
+#: src/strings.ts:2820
+msgid "Retry with custom key"
+msgstr "Retry with custom key"
+
#: src/strings.ts:513
msgid "Reupload"
msgstr "Reupload"
@@ -7127,6 +7163,10 @@ msgstr "Trash gets automatically cleaned up after {days} days"
msgid "Trash gets automatically cleaned up daily"
msgstr "Trash gets automatically cleaned up daily"
+#: src/strings.ts:2813
+msgid "Troubleshoot"
+msgstr "Troubleshoot"
+
#: src/strings.ts:2553
msgid "Try {plan} for free"
msgstr "Try {plan} for free"
@@ -7593,6 +7633,10 @@ msgstr "View and share debug logs"
msgid "View failed inbox items and error contexts"
msgstr "View failed inbox items and error contexts"
+#: src/strings.ts:2816
+msgid "View items that failed to decrypt during sync and retry them with a different key"
+msgstr "View items that failed to decrypt during sync and retry them with a different key"
+
#: src/strings.ts:1749
msgid "View receipt"
msgstr "View receipt"
diff --git a/packages/intl/locale/pseudo-LOCALE.po b/packages/intl/locale/pseudo-LOCALE.po
index 47cb81373..62deb2959 100644
--- a/packages/intl/locale/pseudo-LOCALE.po
+++ b/packages/intl/locale/pseudo-LOCALE.po
@@ -512,6 +512,10 @@ msgstr ""
msgid "{count} characters"
msgstr ""
+#: src/strings.ts:2823
+msgid "{count} item(s) decrypted successfully"
+msgstr ""
+
#: src/strings.ts:1566
msgid "{days, plural, one {1 day} other {# days}}"
msgstr ""
@@ -596,6 +600,10 @@ msgstr ""
msgid "{selected} selected"
msgstr ""
+#: src/strings.ts:2826
+msgid "{succeeded} item(s) recovered, {failed} still failed"
+msgstr ""
+
#: src/strings.ts:382
msgid "{type, select, github {v{version} has been released on GitHub} other {v{version} has been released}}"
msgstr ""
@@ -995,6 +1003,10 @@ msgstr ""
msgid "Are you sure you want to delete all failed inbox items?"
msgstr ""
+#: src/strings.ts:2819
+msgid "Are you sure you want to delete all failed sync items? This will also remove the corresponding local items."
+msgstr ""
+
#: src/strings.ts:2734
msgid "Are you sure you want to delete this attachment?"
msgstr ""
@@ -2247,6 +2259,10 @@ msgstr ""
msgid "Decrease {title}"
msgstr ""
+#: src/strings.ts:2824
+msgid "Decryption failed"
+msgstr ""
+
#: src/strings.ts:660
#: src/strings.ts:2010
msgid "Default"
@@ -3016,6 +3032,10 @@ msgstr ""
msgid "Failed inbox items"
msgstr ""
+#: src/strings.ts:2814
+msgid "Failed sync items"
+msgstr ""
+
#: src/strings.ts:2653
msgid "Failed to attach file"
msgstr ""
@@ -3772,6 +3792,10 @@ msgstr ""
msgid "Item deleted"
msgstr ""
+#: src/strings.ts:2827
+msgid "Item ID"
+msgstr ""
+
#: src/strings.ts:311
#: src/strings.ts:1705
msgid "items"
@@ -4480,6 +4504,10 @@ msgstr ""
msgid "No failed inbox items"
msgstr ""
+#: src/strings.ts:2817
+msgid "No failed sync items"
+msgstr ""
+
#: src/strings.ts:1667
msgid "No headings found"
msgstr ""
@@ -5820,6 +5848,14 @@ msgstr ""
msgid "Retry"
msgstr ""
+#: src/strings.ts:2821
+msgid "Retry with current keys"
+msgstr ""
+
+#: src/strings.ts:2820
+msgid "Retry with custom key"
+msgstr ""
+
#: src/strings.ts:513
msgid "Reupload"
msgstr ""
@@ -7086,6 +7122,10 @@ msgstr ""
msgid "Trash gets automatically cleaned up daily"
msgstr ""
+#: src/strings.ts:2813
+msgid "Troubleshoot"
+msgstr ""
+
#: src/strings.ts:2553
msgid "Try {plan} for free"
msgstr ""
@@ -7543,6 +7583,10 @@ msgstr ""
msgid "View failed inbox items and error contexts"
msgstr ""
+#: src/strings.ts:2816
+msgid "View items that failed to decrypt during sync and retry them with a different key"
+msgstr ""
+
#: src/strings.ts:1749
msgid "View receipt"
msgstr ""
diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts
index e148b2196..992087ca1 100644
--- a/packages/intl/src/strings.ts
+++ b/packages/intl/src/strings.ts
@@ -2809,5 +2809,20 @@ Continue without attachments?`,
versionDeleted: () => actions.deleted.version(1),
offlineMode: () => t`Offline mode`,
offlineModeDesc: () =>
- t`Using Notesnook without an account will **NOT** sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly.`
+ t`Using Notesnook without an account will **NOT** sync your notes across devices and could result in data loss if you lose access to your device or uninstall the app. Make sure to backup your notes regularly.`,
+ troubleshoot: () => t`Troubleshoot`,
+ failedSyncItems: () => t`Failed sync items`,
+ failedSyncItemsDesc: () =>
+ t`View items that failed to decrypt during sync and retry them with a different key`,
+ noFailedSyncItems: () => t`No failed sync items`,
+ deleteAllFailedSyncItemsDesc: () =>
+ t`Are you sure you want to delete all failed sync items? This will also remove the corresponding local items.`,
+ retryWithCustomKey: () => t`Retry with custom key`,
+ retryWithCurrentKeys: () => t`Retry with current keys`,
+ decryptionSucceeded: (count: number) =>
+ t`${count} item(s) decrypted successfully`,
+ decryptionFailed: () => t`Decryption failed`,
+ decryptionPartialSuccess: (succeeded: number, failed: number) =>
+ t`${succeeded} item(s) recovered, ${failed} still failed`,
+ itemId: () => t`Item ID`
};