From b615dfcd87c413ff537119a931a5f298b497af95 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Thu, 7 May 2026 16:01:43 +0500 Subject: [PATCH] web: handle & show failed inbox items Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- apps/web/src/dialogs/InboxHistoryDialog.tsx | 267 ++++++++++++++++++ .../src/dialogs/settings/inbox-settings.ts | 20 ++ packages/core/src/api/index.ts | 4 + packages/core/src/api/sync/merger.ts | 79 +++++- packages/core/src/api/sync/types.ts | 3 +- .../src/collections/inbox-items-history.ts | 77 +++++ packages/core/src/database/index.ts | 2 + packages/core/src/database/migrations.ts | 13 + packages/core/src/database/sql-collection.ts | 3 +- packages/core/src/types.ts | 28 ++ packages/intl/locale/en.po | 24 ++ packages/intl/locale/pseudo-LOCALE.po | 24 ++ packages/intl/src/strings.ts | 8 +- 13 files changed, 540 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/dialogs/InboxHistoryDialog.tsx create mode 100644 packages/core/src/collections/inbox-items-history.ts diff --git a/apps/web/src/dialogs/InboxHistoryDialog.tsx b/apps/web/src/dialogs/InboxHistoryDialog.tsx new file mode 100644 index 000000000..f2fc7a780 --- /dev/null +++ b/apps/web/src/dialogs/InboxHistoryDialog.tsx @@ -0,0 +1,267 @@ +/* +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 { usePromise } from "@notesnook/common"; +import { formatDate, InboxItemsHistoryErrorContext } from "@notesnook/core"; +import { Box, Button, Text } from "@theme-ui/components"; +import { db } from "../common/db"; +import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; +import Dialog from "../components/dialog"; +import { Check, Copy, Trash } from "../components/icons"; +import ScrollContainer, { + FlexScrollContainer +} from "../components/scroll-container"; +import { strings } from "@notesnook/intl"; +import { writeText } from "clipboard-polyfill"; + +type InboxHistoryDialogProps = BaseDialogProps; + +const COLUMNS = [ + { title: strings.dateSynced(), width: "160px" }, + { title: strings.error(), width: "120px" }, + { title: strings.description(), width: "1fr" }, + { title: strings.details(), width: "1fr" }, + { title: "", width: "40px" } +]; + +const ERROR_CHIP_STYLES: Record< + InboxItemsHistoryErrorContext["message"], + { bg: string; color: string } +> = { + "Decryption failed": { bg: "background-error", color: "accent-error" }, + "Invalid JSON": { bg: "rgba(255, 152, 0, 0.15)", color: "#e65100" }, + "Validation failed": { bg: "rgba(255, 193, 7, 0.15)", color: "#8a6000" } +}; + +function ErrorBadge({ + message +}: { + message: InboxItemsHistoryErrorContext["message"] | undefined; +}) { + if (!message) { + return ( + + — + + ); + } + + const style = ERROR_CHIP_STYLES[message] ?? { + bg: "background-error", + color: "accent-error" + }; + return ( + + {message} + + ); +} + +function DetailsCell({ value }: { value: string }) { + const [hovered, setHovered] = useState(false); + const [copied, setCopied] = useState(false); + + function handleCopy() { + writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + } + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + {value} + + + {(hovered || copied) && ( + + )} + + ); +} + +function parseErrorContext( + raw: string | undefined +): InboxItemsHistoryErrorContext | null { + if (!raw) return null; + + try { + return JSON.parse(raw) as InboxItemsHistoryErrorContext; + } catch { + return null; + } +} + +export const InboxHistoryDialog = DialogManager.register( + function InboxHistoryDialog(props: InboxHistoryDialogProps) { + const result = usePromise(() => db.inboxItemsHistory.failed.items()); + + async function deleteItem(id: string) { + await db.inboxItemsHistory.delete(id); + if (result.status !== "pending") { + result.refresh(); + } + } + + return ( + props.onClose(false)} + negativeButton={{ + text: strings.close(), + onClick: () => props.onClose(false) + }} + noScroll + width="80%" + > + {result.status === "pending" ? ( + {strings.loading()} + ) : result.status === "rejected" ? ( + {strings.failed()} + ) : result.value.length === 0 ? ( + {strings.noFailedInboxItems()} + ) : ( + + + + + + {COLUMNS.map((col) => ( + + {col.title} + + ))} + + + + {result.value.map((item) => { + const ctx = parseErrorContext(item.errorContext); + const { message, description, ...rest } = + ctx ?? + ({} as Partial & + Record); + const hasExtra = Object.keys(rest).length > 0; + return ( + + + + {formatDate(item.dateSynced)} + + + + + + + + {(description as string) ?? "—"} + + + + {hasExtra ? ( + + ) : ( + + — + + )} + + + + + + ); + })} + + + + + )} + + ); + } +); diff --git a/apps/web/src/dialogs/settings/inbox-settings.ts b/apps/web/src/dialogs/settings/inbox-settings.ts index bdec99565..34288e302 100644 --- a/apps/web/src/dialogs/settings/inbox-settings.ts +++ b/apps/web/src/dialogs/settings/inbox-settings.ts @@ -24,6 +24,7 @@ import { InboxPGPKeysDialog } from "../inbox-pgp-keys-dialog"; import { db } from "../../common/db"; import { showPasswordDialog } from "../password-dialog"; import { strings } from "@notesnook/intl"; +import { InboxHistoryDialog } from "../InboxHistoryDialog"; export const InboxSettings: SettingsGroup[] = [ { @@ -81,6 +82,25 @@ export const InboxSettings: SettingsGroup[] = [ } ] }, + { + key: "failed-inbox-items", + title: strings.failedInboxItems(), + description: strings.failedInboxItemsDesc(), + keywords: ["inbox", "failed", "items"], + onStateChange: (listener) => + useSettingStore.subscribe((s) => s.isInboxEnabled, listener), + isHidden: () => !useSettingStore.getState().isInboxEnabled, + components: [ + { + type: "button", + title: strings.show(), + variant: "secondary", + action: () => { + InboxHistoryDialog.show({}); + } + } + ] + }, { key: "inbox-api-keys", title: "", diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 979a2f0ee..fea4fd6f6 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -50,6 +50,7 @@ import { Shortcuts } from "../collections/shortcuts.js"; import { Reminders } from "../collections/reminders.js"; import { Relations } from "../collections/relations.js"; import Subscriptions from "./subscriptions.js"; +import { InboxItemsHistory } from "../collections/inbox-items-history.js"; import { CompressorAccessor, ConfigStorageAccessor, @@ -228,6 +229,7 @@ class Database { settings = new Settings(this); inboxApiKeys = new InboxApiKeys(this, this.tokenManager); + inboxItemsHistory = new InboxItemsHistory(this); wrapped = new Wrapped(this); @@ -350,6 +352,8 @@ class Database { await this.vaults.init(); await this.monographsCollection.init(); + await this.inboxItemsHistory.init(); + await this.trash.init(); // legacy collections diff --git a/packages/core/src/api/sync/merger.ts b/packages/core/src/api/sync/merger.ts index 16be28c63..60cbb4366 100644 --- a/packages/core/src/api/sync/merger.ts +++ b/packages/core/src/api/sync/merger.ts @@ -29,6 +29,7 @@ import { isDeleted } from "../../types.js"; import { SyncInboxItem } from "./types.js"; +import { InboxItemsHistoryErrorContext } from "../../types.js"; import { z } from "zod"; import { sanitizeHtml } from "../../utils/html-parser.js"; @@ -201,24 +202,76 @@ export async function handleInboxItems( for (const item of inboxItems) { try { - if (await db.notes.exists(item.id)) { - logger.info("Inbox item already exists, skipping.", { + if (await db.inboxItemsHistory.exists(item.id)) { + logger.info("Inbox item already processed, skipping.", { inboxItemId: item.id }); continue; } - const decryptedItem = await db - .storage() - .decryptPGPMessage(inboxKeys.privateKey, item.cipher); - const validation = RawInboxItemSchema.safeParse( - JSON.parse(decryptedItem) - ); + let decryptedItem: string; + try { + decryptedItem = await db + .storage() + .decryptPGPMessage(inboxKeys.privateKey, item.cipher); + } catch (e) { + logger.error(e, "Failed to decrypt inbox item.", { + inboxItemId: item.id + }); + await db.inboxItemsHistory.add({ + id: item.id, + status: "failed", + errorContext: JSON.stringify({ + message: "Decryption failed", + description: (e as Error).message, + inboxItem: { id: item.id, v: item.v, alg: item.alg } + } satisfies InboxItemsHistoryErrorContext) + }); + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(decryptedItem); + } catch (e) { + logger.error(e, "Failed to parse inbox item JSON.", { + inboxItemId: item.id + }); + await db.inboxItemsHistory.add({ + id: item.id, + status: "failed", + errorContext: JSON.stringify({ + message: "Invalid JSON", + description: (e as Error).message, + inboxItem: { id: item.id, v: item.v, alg: item.alg }, + decryptedItem + } satisfies InboxItemsHistoryErrorContext) + }); + continue; + } + + const validation = RawInboxItemSchema.safeParse(parsed); if (!validation.success) { logger.warn("Failed to validate inbox item.", { inboxItem: item, errors: validation.error.issues }); + const { content: _content, ...parsedWithoutContent } = parsed as Record< + string, + unknown + >; + await db.inboxItemsHistory.add({ + id: item.id, + status: "failed", + errorContext: JSON.stringify({ + message: "Validation failed", + description: validation.error.issues + .map((i) => i.message) + .join("; "), + inboxItem: { id: item.id, v: item.v, alg: item.alg }, + parsedItem: parsedWithoutContent + } satisfies InboxItemsHistoryErrorContext) + }); continue; } @@ -248,11 +301,19 @@ export async function handleInboxItems( { type: "note", id: item.id } ); } + await db.inboxItemsHistory.add({ + id: item.id, + status: "success", + source: data.source + }); + await db.relations.add( + { type: "inboxitemhistory", id: item.id }, + { type: "note", id: item.id } + ); } catch (e) { logger.error(e, "Failed to process inbox item.", { inboxItem: item }); - continue; } } } diff --git a/packages/core/src/api/sync/types.ts b/packages/core/src/api/sync/types.ts index efba6eb92..e8dde136f 100644 --- a/packages/core/src/api/sync/types.ts +++ b/packages/core/src/api/sync/types.ts @@ -45,7 +45,8 @@ export const SYNC_COLLECTIONS_MAP = { tag: "tags", color: "colors", note: "notes", - vault: "vaults" + vault: "vaults", + inboxitemhistory: "inboxItemsHistory" } as const; export const SYNC_ITEM_TYPES = Object.keys( diff --git a/packages/core/src/collections/inbox-items-history.ts b/packages/core/src/collections/inbox-items-history.ts new file mode 100644 index 000000000..c93cc5b3a --- /dev/null +++ b/packages/core/src/collections/inbox-items-history.ts @@ -0,0 +1,77 @@ +/* +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 { InboxItemHistory } from "../types.js"; +import Database from "../api/index.js"; +import { ICollection } from "./collection.js"; +import { SQLCollection } from "../database/sql-collection.js"; +import { isFalse } from "../database/index.js"; + +export class InboxItemsHistory implements ICollection { + name = "inboxitemshistory"; + readonly collection: SQLCollection<"inboxitemshistory", InboxItemHistory>; + constructor(private readonly db: Database) { + this.collection = new SQLCollection( + db.sql, + db.transaction, + "inboxitemshistory", + db.eventManager, + db.sanitizer + ); + } + + init() { + return this.collection.init(); + } + + async add(item: { + id: string; + status: "failed" | "success"; + source?: string; + errorContext?: string; + }) { + const now = Date.now(); + await this.collection.upsert({ + id: item.id, + type: "inboxitemhistory", + dateCreated: now, + dateModified: now, + dateSynced: now, + status: item.status, + source: item.source, + errorContext: item.errorContext + }); + return item.id; + } + + get failed() { + return this.collection.createFilter( + (qb) => qb.where(isFalse("deleted")).where("status", "==", "failed"), + this.db.options?.batchSize + ); + } + + async delete(id: string) { + await this.collection.softDelete([id]); + } + + exists(id: string) { + return this.collection.exists(id); + } +} diff --git a/packages/core/src/database/index.ts b/packages/core/src/database/index.ts index f9e03c45a..550bc2bab 100644 --- a/packages/core/src/database/index.ts +++ b/packages/core/src/database/index.ts @@ -42,6 +42,7 @@ import { Color, ContentItem, HistorySession, + InboxItemHistory, ItemReference, ItemReferences, ItemType, @@ -92,6 +93,7 @@ export interface DatabaseSchema { shortcuts: SQLiteItem; vaults: SQLiteItem; monographs: SQLiteItem; + inboxitemshistory: SQLiteItem; } export type RawDatabaseSchema = DatabaseSchema & { diff --git a/packages/core/src/database/migrations.ts b/packages/core/src/database/migrations.ts index e1fc4e5c3..6fa22a5c5 100644 --- a/packages/core/src/database/migrations.ts +++ b/packages/core/src/database/migrations.ts @@ -441,6 +441,19 @@ export class NNMigrationProvider implements MigrationProvider { .addColumn("spellcheck", "boolean", (c) => c.defaultTo(true)) .execute(); } + }, + "a-2026-05-07": { + async up(db) { + await db.schema + .createTable("inboxitemshistory") + .modifyEnd(sql`without rowid`) + .$call(addBaseColumns) + .addColumn("dateSynced", "integer") + .addColumn("status", "text") + .addColumn("source", "text") + .addColumn("errorContext", "text") + .execute(); + } } }; } diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts index b13f0f6a6..4c3e11da1 100644 --- a/packages/core/src/database/sql-collection.ts +++ b/packages/core/src/database/sql-collection.ts @@ -702,7 +702,8 @@ const VALID_SORT_OPTIONS: Record< settings: [], shortcuts: [], vaults: [], - monographs: [] + monographs: [], + inboxitemshistory: [] }; function sanitizeSortOptions(type: keyof DatabaseSchema, options: SortOptions) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eba45d003..4f988823f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -83,6 +83,7 @@ export type Collections = { settingsv2: "settingitem"; vaults: "vault"; monographs: "monograph"; + inboxitemshistory: "inboxitemhistory"; /** * @deprecated only kept here for migration purposes @@ -114,6 +115,7 @@ export type GroupableItem = ValueOf< | "settingitem" | "vault" | "monograph" + | "inboxitemhistory" > >; @@ -136,6 +138,7 @@ export type ItemMap = { vault: Vault; searchResult: HighlightedResult; monograph: Monograph; + inboxitemhistory: InboxItemHistory; /** * @deprecated only kept here for migration purposes @@ -512,6 +515,31 @@ export interface Monograph extends BaseItem<"monograph"> { publishUrl?: string; } +type InboxItemHistoryErrorContextBase = { + description: string; + inboxItem: { id: string; v: number; alg: string }; +}; + +export type InboxItemsHistoryErrorContext = + | (InboxItemHistoryErrorContextBase & { + message: "Decryption failed"; + }) + | (InboxItemHistoryErrorContextBase & { + message: "Invalid JSON"; + decryptedItem: string; + }) + | (InboxItemHistoryErrorContextBase & { + message: "Validation failed"; + parsedItem: Record; + }); + +export interface InboxItemHistory extends BaseItem<"inboxitemhistory"> { + dateSynced: number; + status: "failed" | "success"; + source?: string; + errorContext?: string; +} + export type Match = { prefix: string; match: string; diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po index daa93beaf..3dd7a2fb3 100644 --- a/packages/intl/locale/en.po +++ b/packages/intl/locale/en.po @@ -2154,6 +2154,10 @@ msgstr "Date format" msgid "Date modified" msgstr "Date modified" +#: src/strings.ts:2747 +msgid "Date synced" +msgstr "Date synced" + #: src/strings.ts:2044 msgid "Date uploaded" msgstr "Date uploaded" @@ -2307,6 +2311,10 @@ msgstr "Desktop app" msgid "Desktop integration" msgstr "Desktop integration" +#: src/strings.ts:2746 +msgid "Details" +msgstr "Details" + #: src/strings.ts:871 msgid "Did you save recovery key?" msgstr "Did you save recovery key?" @@ -2911,6 +2919,10 @@ msgstr "Faced an issue or have a suggestion? Click here to create a bug report" msgid "Failed" msgstr "Failed" +#: src/strings.ts:2748 +msgid "Failed inbox items" +msgstr "Failed inbox items" + #: src/strings.ts:2653 msgid "Failed to attach file" msgstr "Failed to attach file" @@ -4360,6 +4372,10 @@ msgstr "No downloads in progress." msgid "No encryption key found" msgstr "No encryption key found" +#: src/strings.ts:2750 +msgid "No failed inbox items" +msgstr "No failed inbox items" + #: src/strings.ts:1667 msgid "No headings found" msgstr "No headings found" @@ -6281,6 +6297,10 @@ msgstr "shortcuts" msgid "Shortcuts" msgstr "Shortcuts" +#: src/strings.ts:2751 +msgid "Show" +msgstr "Show" + #: src/strings.ts:96 msgid "Sign up" msgstr "Sign up" @@ -7405,6 +7425,10 @@ msgstr "View and manage inbox API keys" msgid "View and share debug logs" msgstr "View and share debug logs" +#: src/strings.ts:2749 +msgid "View failed inbox items and error contexts" +msgstr "View failed inbox items and error contexts" + #: 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 aba1e728f..ae4c03c68 100644 --- a/packages/intl/locale/pseudo-LOCALE.po +++ b/packages/intl/locale/pseudo-LOCALE.po @@ -2143,6 +2143,10 @@ msgstr "" msgid "Date modified" msgstr "" +#: src/strings.ts:2747 +msgid "Date synced" +msgstr "" + #: src/strings.ts:2044 msgid "Date uploaded" msgstr "" @@ -2296,6 +2300,10 @@ msgstr "" msgid "Desktop integration" msgstr "" +#: src/strings.ts:2746 +msgid "Details" +msgstr "" + #: src/strings.ts:871 msgid "Did you save recovery key?" msgstr "" @@ -2900,6 +2908,10 @@ msgstr "" msgid "Failed" msgstr "" +#: src/strings.ts:2748 +msgid "Failed inbox items" +msgstr "" + #: src/strings.ts:2653 msgid "Failed to attach file" msgstr "" @@ -4340,6 +4352,10 @@ msgstr "" msgid "No encryption key found" msgstr "" +#: src/strings.ts:2750 +msgid "No failed inbox items" +msgstr "" + #: src/strings.ts:1667 msgid "No headings found" msgstr "" @@ -6247,6 +6263,10 @@ msgstr "" msgid "Shortcuts" msgstr "" +#: src/strings.ts:2751 +msgid "Show" +msgstr "" + #: src/strings.ts:96 msgid "Sign up" msgstr "" @@ -7355,6 +7375,10 @@ msgstr "" msgid "View and share debug logs" msgstr "" +#: src/strings.ts:2749 +msgid "View failed inbox items and error contexts" +msgstr "" + #: src/strings.ts:1749 msgid "View receipt" msgstr "" diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts index 67722ff2b..eced6fba2 100644 --- a/packages/intl/src/strings.ts +++ b/packages/intl/src/strings.ts @@ -2744,5 +2744,11 @@ Continue without attachments?`, pleaseLoginToDownloadAttachments: () => t`Please login to download attachments.`, publicKeyRequired: () => t`Public key required`, - privateKeyRequired: () => t`Private key required` + privateKeyRequired: () => t`Private key required`, + details: () => t`Details`, + dateSynced: () => t`Date synced`, + failedInboxItems: () => t`Failed inbox items`, + failedInboxItemsDesc: () => t`View failed inbox items and error contexts`, + noFailedInboxItems: () => t`No failed inbox items`, + show: () => t`Show` };