web: add ui to view failed sync items

This commit is contained in:
Abdullah Atta
2026-08-11 12:22:01 +05:00
parent 0c95359e1d
commit 945fd9c091
8 changed files with 678 additions and 38 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<boolean>;
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 (
<Text
sx={{
bg: "background-secondary",
color: "paragraph",
borderRadius: "default",
px: "6px",
py: "2px",
fontSize: "0.65em",
fontWeight: 600,
textTransform: "uppercase",
whiteSpace: "nowrap"
}}
>
{type}
</Text>
);
}
async function resolveKey(input: {
password?: string;
key?: string;
}): Promise<SerializedKey> {
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<boolean> {
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<string[]>([]);
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 (
<Dialog
isOpen={true}
title={strings.failedSyncItems()}
description={strings.failedSyncItemsDesc()}
titleAction={
hasItems ? (
<Flex sx={{ gap: 1 }}>
<Button
variant="secondary"
onClick={() => retryWithCurrentKeys(allIds)}
disabled={busyIds.length > 0}
>
{strings.retryWithCurrentKeys()}
</Button>
<Button
variant="secondary"
onClick={() => retryWithCustomKey(allIds)}
disabled={busyIds.length > 0}
>
{strings.retryWithCustomKey()}
</Button>
<Button variant="errorSecondary" onClick={deleteAll}>
{strings.deleteAll()}
</Button>
</Flex>
) : undefined
}
onClose={() => props.onClose(false)}
negativeButton={{
text: strings.close(),
onClick: () => props.onClose(false)
}}
noScroll
width="900px"
>
{result.status === "pending" ? (
<Text sx={{ p: 3 }} variant="body">
{strings.loading()}
</Text>
) : result.status === "rejected" ? (
<Text sx={{ p: 3 }} variant="body">
{strings.failed()}
</Text>
) : !hasItems ? (
<Text sx={{ p: 3 }} variant="body">
{strings.noFailedSyncItems()}
</Text>
) : (
<FlexScrollContainer style={{ maxHeight: "70vh" }}>
<Box sx={{ p: 2 }}>
<Box
as="table"
sx={{
width: "100%",
borderCollapse: "collapse",
tableLayout: "fixed",
"th, td": {
px: 2,
py: 1,
textAlign: "left",
verticalAlign: "top",
borderBottom: "1px solid var(--separator)"
}
}}
>
<Box as="thead">
<Box as="tr">
{COLUMNS.map((col) => (
<Box
key={col.title || "actions"}
as="th"
sx={{ width: col.width, whiteSpace: "nowrap" }}
>
<Text variant="subtitle">{col.title}</Text>
</Box>
))}
</Box>
</Box>
<Box as="tbody">
{items.map((item) => {
const isBusy = busyIds.includes(item.id);
const errors = item.errors ?? [];
return (
<Box as="tr" key={item.id}>
<Box as="td">
<Text variant="body" sx={{ whiteSpace: "nowrap" }}>
{getFormattedDate(item.dateSynced)}
</Text>
</Box>
<Box as="td">
<TypeBadge type={item.itemType} />
</Box>
<Box as="td">
<Text
variant="body"
className="selectable"
sx={{
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-all"
}}
title={item.itemId}
>
{item.itemId}
</Text>
</Box>
<Box as="td">
{errors.length > 0 ? (
<Box
as="pre"
className="selectable"
sx={{
color: "paragraph",
m: 0,
fontSize: "0.72em",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}
>
{errors.join("\n")}
</Box>
) : (
<Text
variant="body"
sx={{ color: "paragraph-secondary" }}
>
</Text>
)}
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
</FlexScrollContainer>
)}
</Dialog>
);
}
);

View File

@@ -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({});
}
}
]
}
]
}

View File

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

View File

@@ -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<KeyVersion, typeof chunk.items>();
const versionMap = new Map<string, number>();
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<Item>[] = [];
for (let i = 0; i < decrypted.length; ++i) {
const decryptedItem = JSON.parse(decrypted[i]) as MaybeDeletedItem<Item>;
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<SyncOptions, "offlineMode"> = {}
): Promise<(MaybeDeletedItem<Item> | undefined)[]> {
if (decrypted.length === 0) return [];
const deserialized: MaybeDeletedItem<Item>[] = [];
for (const { data, version } of decrypted) {
const decryptedItem = JSON.parse(data) as MaybeDeletedItem<Item>;
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<Item> | 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<Attachment>,
localItems[item.id] as MaybeDeletedItem<Attachment>
)
)
);
} else {
items =
itemType === "attachment"
? await Promise.all(
deserialized.map((item) =>
this.merger.mergeAttachment(
item as MaybeDeletedItem<Attachment>,
localItems[item.id] as MaybeDeletedItem<Attachment>
)
)
)
: 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) {

View File

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

View File

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

View File

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

View File

@@ -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`
};