web: handle & show failed inbox items

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2026-05-07 16:01:43 +05:00
committed by Ammar Ahmed
parent 833368e225
commit b615dfcd87
13 changed files with 540 additions and 12 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<boolean>;
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 (
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
</Text>
);
}
const style = ERROR_CHIP_STYLES[message] ?? {
bg: "background-error",
color: "accent-error"
};
return (
<Text
sx={{
bg: style.bg,
color: style.color,
borderRadius: "default",
px: "6px",
py: "2px",
fontSize: "0.65em",
fontWeight: 600
}}
>
{message}
</Text>
);
}
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 (
<Box
sx={{ position: "relative" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<ScrollContainer suppressScrollX style={{ maxHeight: 100 }}>
<Box
as="pre"
sx={{
m: 0,
fontSize: "0.72em",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}
>
{value}
</Box>
</ScrollContainer>
{(hovered || copied) && (
<Button
variant="icon"
title="Copy"
onClick={handleCopy}
sx={{
position: "absolute",
top: 0,
left: 0,
p: "2px",
bg: "background",
color: copied ? "accent" : undefined
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</Button>
)}
</Box>
);
}
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 (
<Dialog
isOpen={true}
title={strings.failedInboxItems()}
onClose={() => props.onClose(false)}
negativeButton={{
text: strings.close(),
onClick: () => props.onClose(false)
}}
noScroll
width="80%"
>
{result.status === "pending" ? (
<Text sx={{ p: 3 }}>{strings.loading()}</Text>
) : result.status === "rejected" ? (
<Text sx={{ p: 3 }}>{strings.failed()}</Text>
) : result.value.length === 0 ? (
<Text sx={{ p: 3 }}>{strings.noFailedInboxItems()}</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}
as="th"
sx={{ width: col.width, whiteSpace: "nowrap" }}
>
<Text variant="subtitle">{col.title}</Text>
</Box>
))}
</Box>
</Box>
<Box as="tbody">
{result.value.map((item) => {
const ctx = parseErrorContext(item.errorContext);
const { message, description, ...rest } =
ctx ??
({} as Partial<InboxItemsHistoryErrorContext> &
Record<string, unknown>);
const hasExtra = Object.keys(rest).length > 0;
return (
<Box as="tr" key={item.id}>
<Box as="td">
<Text variant="body" sx={{ whiteSpace: "nowrap" }}>
{formatDate(item.dateSynced)}
</Text>
</Box>
<Box as="td">
<ErrorBadge message={message} />
</Box>
<Box as="td">
<Text variant="body">
{(description as string) ?? "—"}
</Text>
</Box>
<Box as="td">
{hasExtra ? (
<DetailsCell
value={JSON.stringify(rest, null, 2)}
/>
) : (
<Text
variant="body"
sx={{ color: "paragraph-secondary" }}
>
</Text>
)}
</Box>
<Box as="td" sx={{ textAlign: "center" }}>
<Button
variant="icon"
title={strings.delete()}
onClick={() => deleteItem(item.id)}
sx={{ color: "accent-error", p: "2px" }}
>
<Trash size={16} />
</Button>
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
</FlexScrollContainer>
)}
</Dialog>
);
}
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<InboxItemHistory>(
(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);
}
}

View File

@@ -42,6 +42,7 @@ import {
Color,
ContentItem,
HistorySession,
InboxItemHistory,
ItemReference,
ItemReferences,
ItemType,
@@ -92,6 +93,7 @@ export interface DatabaseSchema {
shortcuts: SQLiteItem<Shortcut>;
vaults: SQLiteItem<Vault>;
monographs: SQLiteItem<Monograph>;
inboxitemshistory: SQLiteItem<InboxItemHistory>;
}
export type RawDatabaseSchema = DatabaseSchema & {

View File

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

View File

@@ -702,7 +702,8 @@ const VALID_SORT_OPTIONS: Record<
settings: [],
shortcuts: [],
vaults: [],
monographs: []
monographs: [],
inboxitemshistory: []
};
function sanitizeSortOptions(type: keyof DatabaseSchema, options: SortOptions) {

View File

@@ -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<string, unknown>;
});
export interface InboxItemHistory extends BaseItem<"inboxitemhistory"> {
dateSynced: number;
status: "failed" | "success";
source?: string;
errorContext?: string;
}
export type Match = {
prefix: string;
match: string;

View File

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

View File

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

View File

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