Sync articles marked toread in kipclip to Crosspoint Reader (Xteink X4)
1import { Hono } from "https://esm.sh/hono@4.6.0";
2import { runMigrations } from "./database/migrations.ts";
3import { getArticles, getLastSyncTime } from "./database/queries.ts";
4import { getEpubFromBlob } from "./epub-generator.ts";
5
6const app = new Hono();
7
8// Unwrap Hono errors to see original error details
9app.onError((err) => {
10 throw err;
11});
12
13// Run migrations on first request
14let migrated = false;
15app.use(async (_c, next) => {
16 if (!migrated) {
17 await runMigrations();
18 migrated = true;
19 }
20 await next();
21});
22
23// --- Routes ---
24
25app.get("/", async (c) => {
26 const maxArticles = parseInt(Deno.env.get("MAX_ARTICLES") || "50", 10);
27 const articles = await getArticles(maxArticles);
28 const lastSync = await getLastSyncTime();
29
30 const rows = articles.map((a) =>
31 `<tr>
32 <td><a href="${escapeHtml(a.url)}">${escapeHtml(a.title)}</a></td>
33 <td>${escapeHtml(a.description || "")}</td>
34 <td>${a.createdAt.slice(0, 10)}</td>
35 <td><a href="/article/${escapeHtml(a.filename)}">epub</a></td>
36 </tr>`
37 ).join("\n");
38
39 const html = `<!DOCTYPE html>
40<html lang="en">
41<head>
42 <meta charset="utf-8"/>
43 <meta name="viewport" content="width=device-width, initial-scale=1"/>
44 <title>CrossPoint Articles</title>
45 <style>
46 body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
47 table { width: 100%; border-collapse: collapse; }
48 th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #ddd; }
49 th { font-size: 0.85em; color: #666; }
50 td:nth-child(3), td:nth-child(4) { white-space: nowrap; }
51 a { color: #1a6; }
52 .meta { color: #888; font-size: 0.85em; margin-bottom: 1.5rem; }
53 </style>
54</head>
55<body>
56 <h1>CrossPoint Articles</h1>
57 <p class="meta">${articles.length} articles · last sync: ${lastSync || "never"}</p>
58 ${
59 articles.length === 0
60 ? "<p>No articles yet. The sync cron will pick up new bookmarks shortly.</p>"
61 : `<table>
62 <thead><tr><th>Title</th><th>Description</th><th>Date</th><th>EPUB</th></tr></thead>
63 <tbody>${rows}</tbody>
64 </table>`
65 }
66</body>
67</html>`;
68
69 return c.html(html);
70});
71
72app.get("/health", async (c) => {
73 const lastSync = await getLastSyncTime();
74 return c.json({ status: "ok", service: "crosspoint-articles", lastSync });
75});
76
77app.get("/sync", async (c) => {
78 const maxArticles = parseInt(Deno.env.get("MAX_ARTICLES") || "50", 10);
79 const articles = await getArticles(maxArticles);
80 // Only send fields the firmware needs — saves RAM on ESP32-C3
81 const slim = articles.map(({ filename, title }) => ({ filename, title }));
82 return c.json({ articles: slim });
83});
84
85app.get("/article/:filename", async (c) => {
86 const filename = c.req.param("filename");
87
88 if (!filename.startsWith("kipclip-") || !filename.endsWith(".epub")) {
89 return c.json({ error: "invalid_filename", message: "Invalid filename format" }, 400);
90 }
91
92 const data = await getEpubFromBlob(filename);
93 if (!data) {
94 return c.json(
95 { error: "not_found", message: "Article not found" },
96 404,
97 );
98 }
99
100 return new Response(data, {
101 headers: {
102 "Content-Type": "application/epub+zip",
103 "Content-Length": String(data.byteLength),
104 "Content-Disposition": `attachment; filename="${filename}"`,
105 },
106 });
107});
108
109function escapeHtml(str: string): string {
110 return str
111 .replace(/&/g, "&")
112 .replace(/</g, "<")
113 .replace(/>/g, ">")
114 .replace(/"/g, """);
115}
116
117export default app.fetch;