mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
web: add ui to view failed sync items
This commit is contained in:
345
apps/web/src/dialogs/failed-sync-items-dialog.tsx
Normal file
345
apps/web/src/dialogs/failed-sync-items-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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({});
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user