Sync articles marked toread in kipclip to Crosspoint Reader (Xteink X4)
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 117 lines 3.5 kB view raw
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, "&amp;") 112 .replace(/</g, "&lt;") 113 .replace(/>/g, "&gt;") 114 .replace(/"/g, "&quot;"); 115} 116 117export default app.fetch;