Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 2026-09-19

- Added an app-level event log (IndexedDB, last 500 entries) that records failed article downloads, image/thumbnail download failures, and summarization failures
- The log is viewable, filterable, and copyable from the Diagnostics screen — useful on mobile where the browser console isn't available

## 2026-09-09

- Site-specific bookmarklets: a bookmarklet can now extract content itself and hand it to Savr as raw content (`savr-raw` postMessage), bypassing Readability — for dynamic pages that a static fetch can't see (#63)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ You can learn more about CORS on [Wikipedia](https://en.wikipedia.org/wiki/Cross

If you need to setup a simple free CORS proxy, [follow these instructions](https://gist.github.com/DankTechnologies/8dd399de6a588085eea26517048b7366).

## How do I find out why an article failed to save?

Savr keeps an on-device app log of failures — article downloads, image and thumbnail downloads, and summarization errors. Open **Preferences → Diagnostics** to see the most recent 500 entries, filter them, and copy them as text. The log never leaves your device, and it is handy on a phone where there's no JavaScript console to check.

## What is a PWA

A PWA (Progressive Web App) is a web application that can be installed on your device like a native app. PWAs work offline, can send notifications, and provide an app-like experience while running in your browser. You can "install" them from your browser's menu, and they'll appear in your app launcher alongside other apps.
Expand Down
33 changes: 29 additions & 4 deletions lib/src/ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getApiKeyForProvider,
} from "~/utils/cookies";
import { convertToHtml, detectContentType } from "./contentType";
import { recordLog, errorMessage } from "~/utils/logging";

export const maxDimensionImage = 1024;
export const maxDimensionThumb = 256;
Expand Down Expand Up @@ -259,6 +260,10 @@ async function downloadAndResizeImages(
}
} catch (e) {
console.error("THUMB error downloading and saving image", e);
recordLog("warn", "image", `Failed to download image: ${url}`, {
slug: article.slug,
error: errorMessage(e),
});
}
}

Expand Down Expand Up @@ -762,6 +767,11 @@ async function persistArticle(
}
} catch (error) {
console.error("Failed to generate summary during ingestion:", error);
recordLog("error", "summary", "Failed to generate summary during ingestion", {
slug: article.slug,
provider,
error: errorMessage(error),
});
sendMessageWithLog(null, "summary generation skipped (API error)");
}
}
Expand Down Expand Up @@ -1021,15 +1031,26 @@ export async function ingestUrl(
sendMessage(10, "fetching article");

// TODO: create error if fetch times out. this happens if you are offline
const response = await fetchWithTimeout(url);

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
let response: Response;
try {
response = await fetchWithTimeout(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
// The article never got far enough to have a slug, so the per-article
// fetch.log cannot capture this — record it in the app log instead.
recordLog("error", "ingest", `Failed to download article`, {
url,
error: errorMessage(error),
});
throw error;
}

const contentTypeHeader = response.headers.get("content-type");

if (!contentTypeHeader) {
recordLog("error", "ingest", `Failed to determine content type`, { url });
throw new Error("cant determine content type");
}

Expand Down Expand Up @@ -1087,6 +1108,10 @@ export async function ingestUrl(
article.mimeType = mimeType;
} catch (error) {
console.error(error);
recordLog("error", "ingest", `Failed to process article`, {
url,
error: errorMessage(error),
});
// Preserve the original error so the UI can show an actionable message
// (unsupported content type vs. proxy down vs. parse failure, etc.)
throw error instanceof Error ? error : new Error(String(error));
Expand Down
7 changes: 7 additions & 0 deletions src/components/ArticleScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { recordReadingSession, useReadingWpm } from "../utils/readingSpeed";
import { formatReadTime } from "../../lib/src/lib";
import { calculateArticleStorageSize, formatBytes } from "~/utils/sync/storage";
import { ingestUrl } from "../../lib/src/ingestion";
import { recordLog, errorMessage } from "~/utils/logging";
import {
summarizeText,
buildSummarySettings,
Expand Down Expand Up @@ -297,6 +298,12 @@ export default function ArticleScreen(_props: Props) {
enqueueSnackbar(successMessage);
} catch (error) {
console.error("Summarization failed:", error);
recordLog("error", "summary", "Manual summarization failed", {
slug: article.slug,
provider,
model,
error: errorMessage(error),
});
const detail = error instanceof Error ? error.message : "Unknown error";
enqueueSnackbar(`Failed to generate summary: ${detail}`, { variant: "error" });
} finally {
Expand Down
154 changes: 153 additions & 1 deletion src/components/DiagnosticsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
CloudOff as CloudOffIcon,
Cloud as CloudIcon,
Refresh as RefreshIcon,
ContentCopy as ContentCopyIcon,
} from "@mui/icons-material";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "~/utils/db";
import { db, type LogEntry } from "~/utils/db";
import { clearLogs, formatLogsForCopy } from "~/utils/logging";
import { useRemoteStorage } from "~/components/RemoteStorageProvider";
import { useSyncStatus } from "~/components/SyncStatusProvider";
import { useSyncProgress } from "~/hooks/useSyncProgress";
Expand Down Expand Up @@ -72,6 +74,28 @@
backend: null,
});

// App-level failure/event log, newest first.
const logs = useLiveQuery(() => db.logs.orderBy("id").reverse().limit(500).toArray(), []);
const [logFilter, setLogFilter] = React.useState<string>("all");

const filteredLogs = React.useMemo(() => {
if (!logs) return [];
if (logFilter === "all") return logs;
if (logFilter === "error" || logFilter === "warn") {
return logs.filter((entry) => entry.level === logFilter);
}
return logs.filter((entry) => entry.category === logFilter);
}, [logs, logFilter]);

const handleCopyLogs = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(formatLogsForCopy(filteredLogs));
alert(`Copied ${filteredLogs.length} log entries to clipboard`);
} catch {
alert("Failed to copy logs to clipboard");
}
}, [filteredLogs]);

const { client: remoteStorageClient, remoteStorage } = useRemoteStorage();

// Scan for dangling remote storage items (directories without corresponding database entries)
Expand Down Expand Up @@ -163,7 +187,7 @@
} finally {
setIsScanningOrphaned(false);
}
}, [remoteStorageClient, articles?.length]);

Check warning on line 190 in src/components/DiagnosticsScreen.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook React.useCallback has a missing dependency: 'articles'. Either include it or remove the dependency array

React.useEffect(() => {
if (remoteStorageClient && articles) {
Expand All @@ -180,7 +204,7 @@
articlesLength: articles?.length,
});
}
}, [remoteStorageClient, articles?.length]);

Check warning on line 207 in src/components/DiagnosticsScreen.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook React.useEffect has missing dependencies: 'articles', 'scanDanglingItems', and 'scanOrphanedArticles'. Either include them or remove the dependency array

// Calculate IndexedDB diagnostics
React.useEffect(() => {
Expand Down Expand Up @@ -219,7 +243,7 @@
if (articles) {
calculateIndexedDbStats();
}
}, [articles?.length]);

Check warning on line 246 in src/components/DiagnosticsScreen.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook React.useEffect has a missing dependency: 'articles'. Either include it or remove the dependency array

// Set up RemoteStorage event listeners
React.useEffect(() => {
Expand Down Expand Up @@ -541,6 +565,134 @@
</Box>
</Box>

<Box sx={{ mt: 4, p: 3, backgroundColor: "background.default", borderRadius: 1 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexWrap: "wrap",
gap: 1,
}}
>
<Typography variant="h6" component="h3">
App Log ({filteredLogs.length})
</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
size="small"
startIcon={<ContentCopyIcon />}
onClick={handleCopyLogs}
disabled={filteredLogs.length === 0}
>
Copy
</Button>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={() => {
if (confirm("Clear all app log entries?")) {
void clearLogs();
}
}}
>
Clear
</Button>
</Box>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Failures recorded by the app — article downloads, image/thumbnail downloads, and
summaries. Copy this to share when something goes wrong on a phone.
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
{[
{ value: "all", label: "All" },
{ value: "error", label: "Errors" },
{ value: "warn", label: "Warnings" },
{ value: "ingest", label: "Ingest" },
{ value: "image", label: "Images" },
{ value: "thumbnail", label: "Thumbnails" },
{ value: "summary", label: "Summaries" },
].map((option) => (
<Chip
key={option.value}
label={option.label}
size="small"
color={logFilter === option.value ? "primary" : "default"}
variant={logFilter === option.value ? "filled" : "outlined"}
onClick={() => setLogFilter(option.value)}
/>
))}
</Box>
{filteredLogs.length > 0 ? (
<Box
sx={{
maxHeight: 300,
overflowY: "auto",
backgroundColor: "grey.50",
borderRadius: 1,
p: 1,
}}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: "bold", width: "180px" }}>Time</TableCell>
<TableCell sx={{ fontWeight: "bold", width: "80px" }}>Level</TableCell>
<TableCell sx={{ fontWeight: "bold", width: "100px" }}>Category</TableCell>
<TableCell sx={{ fontWeight: "bold" }}>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredLogs.map((entry: LogEntry) => (
<TableRow key={entry.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.75rem" }}>
{new Date(entry.timestamp).toLocaleTimeString()}
</TableCell>
<TableCell>
<Chip
label={entry.level}
size="small"
color={entry.level === "error" ? "error" : "warning"}
/>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: "monospace" }}>
{entry.category}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
sx={{ fontFamily: "monospace", fontSize: "0.75rem", wordBreak: "break-word" }}
>
{entry.message}
{entry.meta && Object.keys(entry.meta).length > 0
? ` ${JSON.stringify(entry.meta)}`
: ""}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
) : (
<Box
sx={{
p: 3,
backgroundColor: "grey.100",
borderRadius: 1,
textAlign: "center",
}}
>
<Typography variant="body1" color="text.secondary">
No log entries{logFilter === "all" ? "" : " for this filter"}.
</Typography>
</Box>
)}
</Box>

<Box sx={{ mt: 4, p: 3, backgroundColor: "background.default", borderRadius: 1 }}>
<Typography variant="h6" component="h3" gutterBottom>
Page & Session Information
Expand Down
12 changes: 12 additions & 0 deletions src/components/SubmitScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { useSnackbar } from "notistack";
import { ingestHtml, ingestPdf, ingestImage } from "lib/src/ingestion";
import { trackCapture } from "~/utils/telemetry";
import type { Article } from "lib/src/models";
import { recordLog, errorMessage } from "~/utils/logging";
import {
detectContentType,
convertToHtml,
Expand Down Expand Up @@ -202,6 +203,9 @@ export default function SubmitScreen() {
}, 1500);
} catch (error) {
console.error(error);
recordLog("error", "ingest", "Failed to save pasted content", {
error: errorMessage(error),
});
enqueueSnackbar("Error requesting article", { variant: "error" });
setIngestStatus(null);
setIngestPercent(0);
Expand Down Expand Up @@ -262,6 +266,10 @@ export default function SubmitScreen() {
}, 1500);
} catch (error) {
console.error("Error ingesting PDF:", error);
recordLog("error", "ingest", "Failed to ingest PDF file", {
fileName: file.name,
error: errorMessage(error),
});
enqueueSnackbar("Error uploading PDF file", { variant: "error" });
setIngestStatus(null);
setIngestPercent(0);
Expand Down Expand Up @@ -308,6 +316,10 @@ export default function SubmitScreen() {
}, 1500);
} catch (error) {
console.error("Error ingesting image:", error);
recordLog("error", "ingest", "Failed to ingest image file", {
fileName: file.name,
error: errorMessage(error),
});
enqueueSnackbar("Error uploading image file", { variant: "error" });
setIngestStatus(null);
setIngestPercent(0);
Expand Down
5 changes: 5 additions & 0 deletions src/utils/article/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getCorsProxyFromCookie, setCorsProxyInCookie } from "../cookies";
import { getFilePathMetadata, getFilePathThumbnail } from "../../../lib/src/lib";
import { resizeImage } from "../../../lib/src/ingestion";
import { markDirty } from "./publicExport";
import { recordLog, errorMessage } from "../logging";

// Cookie-based CORS proxy functions
export const getCorsProxyValue = (): string => {
Expand Down Expand Up @@ -160,6 +161,10 @@ export async function loadThumbnail(slug: string): Promise<string> {
}
} catch (error) {
console.warn(`Failed to load thumbnail for ${slug}:`, error);
recordLog("warn", "thumbnail", `Failed to load thumbnail for ${slug}`, {
slug,
error: errorMessage(error),
});
}

// Fallback to static image
Expand Down
21 changes: 21 additions & 0 deletions src/utils/db.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import Dexie, { type EntityTable } from "dexie";
import { Article } from "../../lib/src/models";

// A single app-level event, recorded when something fails or is worth
// remembering. Kept in a bounded ring buffer (see utils/logging.ts) so it
// survives reloads and can be inspected from the Diagnostics screen — which
// matters on mobile, where the JS console isn't available.
export interface LogEntry {
id?: number;
timestamp: string;
level: "error" | "warn" | "info";
category: string;
message: string;
meta?: Record<string, unknown>;
}

type DbType = Dexie & {
articles: EntityTable<Article, "slug">;
logs: EntityTable<LogEntry, "id">;
};

const db: DbType = new Dexie("savrDb") as Dexie & {
articles: EntityTable<Article, "slug">;
logs: EntityTable<LogEntry, "id">;
};

// Schema declaration:
Expand Down Expand Up @@ -42,5 +57,11 @@ db.version(4).stores({}).upgrade((tx) =>
})
);

// v5: persist app-level failure/event log for the Diagnostics screen.
db.version(5).stores({
articles: "slug, ingestDate, state",
logs: "++id, timestamp, level, category",
});

export { db };
export type { DbType };
Loading
Loading