···11+You are an advanced assistant specialized in generating Val Town code.
22+33+## Core Guidelines
44+55+- Ask clarifying questions when requirements are ambiguous
66+- Provide complete, functional solutions rather than skeleton implementations
77+- Test your logic against edge cases before presenting the final solution
88+- Ensure all code follows Val Town's specific platform requirements
99+- If a section of code that you're working on is getting too complex, consider refactoring it into subcomponents
1010+1111+## Code Standards
1212+1313+- Generate code in TypeScript or TSX
1414+- Add appropriate TypeScript types and interfaces for all data structures
1515+- Prefer official SDKs or libraries than writing API calls directly
1616+- Ask the user to supply API or library documentation if you are at all unsure about it
1717+- **Never bake in secrets into the code** - always use environment variables
1818+- Include comments explaining complex logic (avoid commenting obvious operations)
1919+- Follow modern ES6+ conventions and functional programming practices if possible
2020+2121+## Types of triggers
2222+2323+### 1. HTTP Trigger
2424+2525+- Create web APIs and endpoints
2626+- Handle HTTP requests and responses
2727+- Example structure:
2828+2929+```ts
3030+export default async function (req: Request) {
3131+ return new Response("Hello World");
3232+}
3333+```
3434+3535+Files that are HTTP triggers have http in their name like `foobar.http.tsx`
3636+3737+### 2. Cron Triggers
3838+3939+- Run on a schedule
4040+- Use cron expressions for timing
4141+- Example structure:
4242+4343+```ts
4444+export default async function () {
4545+ // Scheduled task code
4646+}
4747+```
4848+4949+Files that are Cron triggers have cron in their name like `foobar.cron.tsx`
5050+5151+### 3. Email Triggers
5252+5353+- Process incoming emails
5454+- Handle email-based workflows
5555+- Example structure:
5656+5757+```ts
5858+export default async function (email: Email) {
5959+ // Process email
6060+}
6161+```
6262+6363+Files that are Email triggers have email in their name like `foobar.email.tsx`
6464+6565+6666+## Val Town Standard Libraries
6767+6868+Val Town provides several hosted services and utility functions.
6969+7070+### Blob Storage
7171+7272+```ts
7373+import { blob } from "https://esm.town/v/std/blob";
7474+await blob.setJSON("myKey", { hello: "world" });
7575+let blobDemo = await blob.getJSON("myKey");
7676+let appKeys = await blob.list("app_");
7777+await blob.delete("myKey");
7878+```
7979+8080+### SQLite
8181+8282+```ts
8383+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite";
8484+const TABLE_NAME = 'todo_app_users_2';
8585+// Create table - do this before usage and change table name when modifying schema
8686+await sqlite.execute(`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
8787+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8888+ name TEXT NOT NULL
8989+)`);
9090+// Query data
9191+const result = await sqlite.execute(`SELECT * FROM ${TABLE_NAME} WHERE id = ?`, [1]);
9292+```
9393+9494+Note: When changing a SQLite table's schema, change the table's name (e.g., add _2 or _3) to create a fresh table.
9595+9696+### OpenAI
9797+9898+```ts
9999+import { OpenAI } from "https://esm.town/v/std/openai";
100100+const openai = new OpenAI();
101101+const completion = await openai.chat.completions.create({
102102+ messages: [
103103+ { role: "user", content: "Say hello in a creative way" },
104104+ ],
105105+ model: "gpt-4o-mini",
106106+ max_tokens: 30,
107107+});
108108+```
109109+110110+### Email
111111+112112+```ts
113113+import { email } from "https://esm.town/v/std/email";
114114+// By default emails the owner of the val
115115+await email({
116116+ subject: "Hi",
117117+ text: "Hi",
118118+ html: "<h1>Hi</h1>"
119119+});
120120+```
121121+122122+## Val Town Utility Functions
123123+124124+Val Town provides several utility functions to help with common project tasks.
125125+126126+### Importing Utilities
127127+128128+Always import utilities with version pins to avoid breaking changes:
129129+130130+```ts
131131+import { parseProject, readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts";
132132+```
133133+134134+### Available Utilities
135135+136136+137137+#### **serveFile** - Serve project files with proper content types
138138+139139+For example, in Hono:
140140+141141+```ts
142142+// serve all files in frontend/ and shared/
143143+app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url));
144144+app.get("/shared/*", c => serveFile(c.req.path, import.meta.url));
145145+```
146146+147147+#### **readFile** - Read files from within the project:
148148+149149+```ts
150150+// Read a file from the project
151151+const fileContent = await readFile("/frontend/index.html", import.meta.url);
152152+```
153153+154154+#### **listFiles** - List all files in the project
155155+156156+```ts
157157+const files = await listFiles(import.meta.url);
158158+```
159159+160160+#### **parseProject** - Extract information about the current project from import.meta.url
161161+162162+This is useful for including info for linking back to a val, ie in "view source" urls:
163163+164164+```ts
165165+const projectVal = parseProject(import.meta.url);
166166+console.log(projectVal.username); // Owner of the project
167167+console.log(projectVal.name); // Project name
168168+console.log(projectVal.version); // Version number
169169+console.log(projectVal.branch); // Branch name
170170+console.log(projectVal.links.self.project); // URL to the project page
171171+```
172172+173173+However, it's *extremely importing* to note that `parseProject` and other Standard Library utilities ONLY RUN ON THE SERVER.
174174+If you need access to this data on the client, run it in the server and pass it to the client by splicing it into the HTML page
175175+or by making an API request for it.
176176+177177+## Val Town Platform Specifics
178178+179179+- **Redirects:** Use `return new Response(null, { status: 302, headers: { Location: "/place/to/redirect" }})` instead of `Response.redirect` which is broken
180180+- **Images:** Avoid external images or base64 images. Use emojis, unicode symbols, or icon fonts/libraries instead
181181+- **AI Image:** To inline generate an AI image use: `<img src="https://maxm-imggenurl.web.val.run/the-description-of-your-image" />`
182182+- **Storage:** DO NOT use the Deno KV module for storage
183183+- **Browser APIs:** DO NOT use the `alert()`, `prompt()`, or `confirm()` methods
184184+- **Weather Data:** Use open-meteo for weather data (doesn't require API keys) unless otherwise specified
185185+- **View Source:** Add a view source link by importing & using `import.meta.url.replace("ems.sh", "val.town)"` (or passing this data to the client) and include `target="_top"` attribute
186186+- **Error Debugging:** Add `<script src="https://esm.town/v/std/catch"></script>` to HTML to capture client-side errors
187187+- **Error Handling:** Only use try...catch when there's a clear local resolution; Avoid catches that merely log or return 500s. Let errors bubble up with full context
188188+- **Environment Variables:** Use `Deno.env.get('keyname')` when you need to, but generally prefer APIs that don't require keys
189189+- **Imports:** Use `https://esm.sh` for npm and Deno dependencies to ensure compatibility on server and browser
190190+- **Storage Strategy:** Only use backend storage if explicitly required; prefer simple static client-side sites
191191+- **React Configuration:** When using React libraries, pin versions with `?deps=react@18.2.0,react-dom@18.2.0` and start the file with `/** @jsxImportSource https://esm.sh/react@18.2.0 */`
192192+- Ensure all React dependencies and sub-dependencies are pinned to the same version
193193+- **Styling:** Default to using TailwindCSS via `<script src="https://cdn.twind.style" crossorigin></script>` unless otherwise specified
194194+195195+## Project Structure and Design Patterns
196196+197197+### Recommended Directory Structure
198198+```
199199+├── backend/
200200+│ ├── database/
201201+│ │ ├── migrations.ts # Schema definitions
202202+│ │ ├── queries.ts # DB query functions
203203+│ │ └── README.md
204204+│ └── routes/ # Route modules
205205+│ ├── [route].ts
206206+│ └── static.ts # Static file serving
207207+│ ├── index.ts # Main entry point
208208+│ └── README.md
209209+├── frontend/
210210+│ ├── components/
211211+│ │ ├── App.tsx
212212+│ │ └── [Component].tsx
213213+│ ├── favicon.svg
214214+│ ├── index.html # Main HTML template
215215+│ ├── index.tsx # Frontend JS entry point
216216+│ ├── README.md
217217+│ └── style.css
218218+├── README.md
219219+└── shared/
220220+ ├── README.md
221221+ └── utils.ts # Shared types and functions
222222+```
223223+224224+### Backend (Hono) Best Practices
225225+226226+- Hono is the recommended API framework
227227+- Main entry point should be `backend/index.ts`
228228+- **Static asset serving:** Use the utility functions to read and serve project files:
229229+ ```ts
230230+ import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts";
231231+232232+ // serve all files in frontend/ and shared/
233233+ app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url));
234234+ app.get("/shared/*", c => serveFile(c.req.path, import.meta.url));
235235+236236+ // For index.html, often you'll want to bootstrap with initial data
237237+ app.get("/", async c => {
238238+ let html = await readFile("/frontend/index.html", import.meta.url);
239239+240240+ // Inject data to avoid extra round-trips
241241+ const initialData = await fetchInitialData();
242242+ const dataScript = `<script>
243243+ window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
244244+ </script>`;
245245+246246+ html = html.replace("</head>", `${dataScript}</head>`);
247247+ return c.html(html);
248248+ });
249249+ ```
250250+- Create RESTful API routes for CRUD operations
251251+- Always include this snippet at the top-level Hono app to re-throwing errors to see full stack traces:
252252+ ```ts
253253+ // Unwrap Hono errors to see original error details
254254+ app.onError((err, c) => {
255255+ throw err;
256256+ });
257257+ ```
258258+259259+### Database Patterns
260260+- Run migrations on startup or comment out for performance
261261+- Change table names when modifying schemas rather than altering
262262+- Export clear query functions with proper TypeScript typing
263263+264264+## Common Gotchas and Solutions
265265+266266+1. **Environment Limitations:**
267267+ - Val Town runs on Deno in a serverless context, not Node.js
268268+ - Code in `shared/` must work in both frontend and backend environments
269269+ - Cannot use `Deno` keyword in shared code
270270+ - Use `https://esm.sh` for imports that work in both environments
271271+272272+2. **SQLite Peculiarities:**
273273+ - Limited support for ALTER TABLE operations
274274+ - Create new tables with updated schemas and copy data when needed
275275+ - Always run table creation before querying
276276+277277+3. **React Configuration:**
278278+ - All React dependencies must be pinned to 18.2.0
279279+ - Always include `@jsxImportSource https://esm.sh/react@18.2.0` at the top of React files
280280+ - Rendering issues often come from mismatched React versions
281281+282282+4. **File Handling:**
283283+ - Val Town only supports text files, not binary
284284+ - Use the provided utilities to read files across branches and forks
285285+ - For files in the project, use `readFile` helpers
286286+287287+5. **API Design:**
288288+ - `fetch` handler is the entry point for HTTP vals
289289+ - Run the Hono app with `export default app.fetch // This is the entry point for HTTP vals`
290290+
+60
README.md
···11+# CrossPoint Articles
22+33+Val Town service that fetches bookmarked articles tagged "toread" from your Kipclip (AT Protocol) account, converts them to EPUBs, and serves them to the CrossPoint e-reader.
44+55+## How it works
66+77+1. A cron job runs every hour, reading `community.lexicon.bookmarks.bookmark` records from your AT Proto repo
88+2. Filters for bookmarks with the configured tag (default: "toread")
99+3. Fetches article HTML and cleans it with Mozilla Readability
1010+4. Generates minimal EPUB files, stored in Val Town Blob storage
1111+5. The e-reader fetches the article list and downloads EPUBs via HTTP
1212+1313+## API
1414+1515+Base URL: https://crosspoint.val.run/
1616+1717+| Endpoint | Description |
1818+|---|---|
1919+| `GET /` | HTML page listing articles with EPUB download links |
2020+| `GET /health` | Service health check (includes last sync time) |
2121+| `GET /sync` | Returns JSON list of available articles |
2222+| `GET /article/:filename` | Downloads an EPUB file |
2323+2424+### GET /sync response
2525+2626+```json
2727+[
2828+ {
2929+ "id": "3abc...",
3030+ "filename": "kipclip-3abc.epub",
3131+ "title": "Article Title",
3232+ "url": "https://example.com/article",
3333+ "createdAt": "2026-04-09T12:00:00Z"
3434+ }
3535+]
3636+```
3737+3838+## Environment Variables
3939+4040+Set these in the Val Town secrets UI:
4141+4242+| Variable | Required | Default | Description |
4343+|---|---|---|---|
4444+| `ATPROTO_HANDLE` | Yes | — | Your AT Protocol handle (e.g. `tijs.org`) |
4545+| `TAG_FILTER` | No | `toread` | Bookmark tag to filter by |
4646+| `MAX_ARTICLES` | No | `50` | Maximum articles returned by /sync |
4747+4848+## Deploy
4949+5050+```bash
5151+deno task deploy
5252+```
5353+5454+## Device Configuration
5555+5656+On the CrossPoint e-reader, set the backend URL in Settings:
5757+5858+```
5959+https://crosspoint.val.run
6060+```
+94
backend/article-processor.ts
···11+import { Readability } from "npm:@mozilla/readability@0.5.0";
22+import { parseHTML } from "npm:linkedom@0.18.9";
33+import { extractImageUrls, replaceImageUrls, sanitizeForXhtml } from "./html-sanitizer.ts";
44+import { type ProcessedImage, processImage } from "./image-processor.ts";
55+66+export interface ProcessedArticle {
77+ title: string;
88+ content: string; // sanitized XHTML with local image paths
99+ excerpt?: string;
1010+ byline?: string;
1111+ images: ProcessedImage[];
1212+}
1313+1414+/** Fetch and clean an article URL using Mozilla Readability */
1515+export async function processArticle(
1616+ url: string,
1717+ fallbackTitle?: string,
1818+ options?: { includeImages?: boolean },
1919+): Promise<ProcessedArticle | null> {
2020+ const html = await fetchArticleHtml(url);
2121+ if (!html) return null;
2222+2323+ const { document } = parseHTML(html);
2424+ // Set documentURI for Readability's URL resolution
2525+ Object.defineProperty(document, "documentURI", { value: url });
2626+ const reader = new Readability(document);
2727+ const article = reader.parse();
2828+2929+ if (!article || !article.content) return null;
3030+3131+ const includeImages = options?.includeImages ?? false;
3232+3333+ // Sanitize HTML to valid XHTML (fix <picture>, void elements, unescaped &)
3434+ let content = sanitizeForXhtml(article.content, {
3535+ stripRemoteImages: !includeImages,
3636+ });
3737+3838+ // Optionally extract, download, and process images (grayscale + dither for e-ink)
3939+ const images: ProcessedImage[] = [];
4040+ if (options?.includeImages) {
4141+ const imageUrls = extractImageUrls(content);
4242+ const urlToPath = new Map<string, string>();
4343+4444+ for (let i = 0; i < imageUrls.length; i++) {
4545+ const processed = await processImage(imageUrls[i], i);
4646+ if (processed) {
4747+ images.push(processed);
4848+ urlToPath.set(imageUrls[i], `images/${processed.filename}`);
4949+ }
5050+ }
5151+5252+ // Replace remote URLs with local EPUB paths
5353+ if (urlToPath.size > 0) {
5454+ content = replaceImageUrls(content, urlToPath);
5555+ }
5656+ }
5757+5858+ return {
5959+ title: article.title || fallbackTitle || new URL(url).hostname,
6060+ content,
6161+ excerpt: article.excerpt || undefined,
6262+ byline: article.byline || undefined,
6363+ images,
6464+ };
6565+}
6666+6767+async function fetchArticleHtml(url: string): Promise<string | null> {
6868+ const controller = new AbortController();
6969+ const timeout = setTimeout(() => controller.abort(), 15_000);
7070+7171+ try {
7272+ const res = await fetch(url, {
7373+ signal: controller.signal,
7474+ headers: {
7575+ "User-Agent": "Mozilla/5.0 (compatible; KipclipSync/1.0; +https://kipclip.com)",
7676+ Accept: "text/html,application/xhtml+xml",
7777+ },
7878+ });
7979+ if (!res.ok) return null;
8080+8181+ const contentType = res.headers.get("content-type") || "";
8282+ if (!contentType.includes("html") && !contentType.includes("xhtml")) {
8383+ return null;
8484+ }
8585+8686+ const text = await res.text();
8787+ // Cap at 500KB to avoid processing enormous pages
8888+ return text.length > 512_000 ? text.slice(0, 512_000) : text;
8989+ } catch {
9090+ return null;
9191+ } finally {
9292+ clearTimeout(timeout);
9393+ }
9494+}
+143
backend/atproto-client.ts
···11+import type { AnnotationRecord, BookmarkRecord, ListRecordsResponse } from "../shared/types.ts";
22+33+const PUBLIC_API = "https://public.api.bsky.app";
44+55+/** Resolve an AT Protocol handle to a DID */
66+export async function resolveHandle(handle: string): Promise<string> {
77+ const res = await fetch(
88+ `${PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
99+ );
1010+ if (!res.ok) {
1111+ throw new Error(`Failed to resolve handle ${handle}: ${res.status}`);
1212+ }
1313+ const data = await res.json();
1414+ return data.did;
1515+}
1616+1717+/** Resolve a DID to its PDS endpoint from the DID document */
1818+async function resolvePds(did: string): Promise<string> {
1919+ const plcUrl = did.startsWith("did:plc:")
2020+ ? `https://plc.directory/${did}`
2121+ : `https://${did.replace("did:web:", "")}/.well-known/did.json`;
2222+2323+ const res = await fetch(plcUrl);
2424+ if (!res.ok) {
2525+ throw new Error(`Failed to resolve DID document for ${did}: ${res.status}`);
2626+ }
2727+2828+ const doc = await res.json();
2929+ const pdsService = doc.service?.find(
3030+ (s: { id: string; type: string }) =>
3131+ s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
3232+ );
3333+ if (!pdsService?.serviceEndpoint) {
3434+ throw new Error(`No PDS found in DID document for ${did}`);
3535+ }
3636+ return pdsService.serviceEndpoint;
3737+}
3838+3939+/**
4040+ * Fetch bookmarks with a specific tag, stopping early when we hit known records.
4141+ *
4242+ * On first run (no known IDs): full scan of all bookmarks (~33 pages).
4343+ * On subsequent runs: pages newest-first via reverse=true, stops after
4444+ * a full page of already-known records (typically 1-2 API calls).
4545+ */
4646+export async function fetchNewBookmarksWithTag(
4747+ handle: string,
4848+ tag: string,
4949+ knownIds: Set<string>,
5050+): Promise<BookmarkRecord[]> {
5151+ const did = await resolveHandle(handle);
5252+ const pdsUrl = await resolvePds(did);
5353+ const isIncremental = knownIds.size > 0;
5454+5555+ const matched: BookmarkRecord[] = [];
5656+ let cursor: string | undefined;
5757+ let consecutiveKnownPages = 0;
5858+5959+ do {
6060+ const params = new URLSearchParams({
6161+ repo: did,
6262+ collection: "community.lexicon.bookmarks.bookmark",
6363+ limit: "100",
6464+ });
6565+ if (isIncremental) params.set("reverse", "true");
6666+ if (cursor) params.set("cursor", cursor);
6767+6868+ const res = await fetch(
6969+ `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`,
7070+ );
7171+ if (!res.ok) {
7272+ throw new Error(
7373+ `Failed to list bookmarks: ${res.status} ${await res.text()}`,
7474+ );
7575+ }
7676+7777+ const data: ListRecordsResponse = await res.json();
7878+ let newOnThisPage = 0;
7979+8080+ for (const r of data.records) {
8181+ const rkey = extractRkey(r.uri);
8282+ const val = r.value as BookmarkRecord["value"];
8383+8484+ if (!val.tags?.includes(tag)) continue;
8585+ if (knownIds.has(rkey)) continue;
8686+8787+ newOnThisPage++;
8888+ matched.push({ uri: r.uri, cid: r.cid, value: val });
8989+ }
9090+9191+ cursor = data.cursor;
9292+9393+ // In incremental mode, stop if a full page had no new tagged bookmarks
9494+ if (isIncremental) {
9595+ if (newOnThisPage === 0) consecutiveKnownPages++;
9696+ else consecutiveKnownPages = 0;
9797+9898+ if (consecutiveKnownPages >= 2) break;
9999+ }
100100+ } while (cursor);
101101+102102+ return matched;
103103+}
104104+105105+/** Fetch a single Kipclip annotation by looking up recent annotations */
106106+export async function fetchAnnotationForBookmark(
107107+ handle: string,
108108+ bookmarkUri: string,
109109+): Promise<AnnotationRecord | null> {
110110+ const did = await resolveHandle(handle);
111111+ const pdsUrl = await resolvePds(did);
112112+113113+ // Annotations are typically recent, fetch newest first
114114+ const params = new URLSearchParams({
115115+ repo: did,
116116+ collection: "com.kipclip.annotation",
117117+ limit: "100",
118118+ reverse: "true",
119119+ });
120120+121121+ const res = await fetch(
122122+ `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`,
123123+ );
124124+ if (!res.ok) return null;
125125+126126+ const data: ListRecordsResponse = await res.json();
127127+ const match = data.records.find(
128128+ (r) => (r.value as AnnotationRecord["value"]).subject === bookmarkUri,
129129+ );
130130+131131+ if (!match) return null;
132132+ return {
133133+ uri: match.uri,
134134+ cid: match.cid,
135135+ value: match.value as AnnotationRecord["value"],
136136+ };
137137+}
138138+139139+/** Extract the rkey (record key) from an AT URI */
140140+export function extractRkey(atUri: string): string {
141141+ const parts = atUri.split("/");
142142+ return parts[parts.length - 1];
143143+}
+24
backend/database/migrations.ts
···11+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite";
22+33+const ARTICLES_TABLE = "kipclip_articles_1";
44+55+export async function runMigrations() {
66+ await sqlite.execute(`CREATE TABLE IF NOT EXISTS ${ARTICLES_TABLE} (
77+ id TEXT PRIMARY KEY,
88+ url TEXT NOT NULL,
99+ title TEXT NOT NULL,
1010+ description TEXT,
1111+ filename TEXT NOT NULL UNIQUE,
1212+ blob_key TEXT NOT NULL,
1313+ bookmark_uri TEXT NOT NULL,
1414+ created_at TEXT NOT NULL,
1515+ synced_at TEXT NOT NULL
1616+ )`);
1717+1818+ await sqlite.execute(`CREATE TABLE IF NOT EXISTS kipclip_sync_state_1 (
1919+ key TEXT PRIMARY KEY,
2020+ value TEXT NOT NULL
2121+ )`);
2222+}
2323+2424+export { ARTICLES_TABLE };
+78
backend/database/queries.ts
···11+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite";
22+import type { ArticleEntry } from "../../shared/types.ts";
33+import { ARTICLES_TABLE } from "./migrations.ts";
44+55+export async function getArticles(limit: number): Promise<ArticleEntry[]> {
66+ const { rows } = await sqlite.execute(
77+ `SELECT id, filename, title, url, description, created_at as createdAt
88+ FROM ${ARTICLES_TABLE}
99+ ORDER BY created_at DESC
1010+ LIMIT ?`,
1111+ [limit],
1212+ );
1313+ return rows.map((r) => ({
1414+ id: r[0] as string,
1515+ filename: r[1] as string,
1616+ title: r[2] as string,
1717+ url: r[3] as string,
1818+ description: (r[4] as string) || undefined,
1919+ createdAt: r[5] as string,
2020+ }));
2121+}
2222+2323+export async function articleExists(id: string): Promise<boolean> {
2424+ const { rows } = await sqlite.execute(
2525+ `SELECT 1 FROM ${ARTICLES_TABLE} WHERE id = ? LIMIT 1`,
2626+ [id],
2727+ );
2828+ return rows.length > 0;
2929+}
3030+3131+export async function getAllArticleIds(): Promise<Set<string>> {
3232+ const { rows } = await sqlite.execute(
3333+ `SELECT id FROM ${ARTICLES_TABLE}`,
3434+ );
3535+ return new Set(rows.map((r) => r[0] as string));
3636+}
3737+3838+export async function insertArticle(article: {
3939+ id: string;
4040+ url: string;
4141+ title: string;
4242+ description?: string;
4343+ filename: string;
4444+ blobKey: string;
4545+ bookmarkUri: string;
4646+ createdAt: string;
4747+}): Promise<void> {
4848+ await sqlite.execute(
4949+ `INSERT OR IGNORE INTO ${ARTICLES_TABLE}
5050+ (id, url, title, description, filename, blob_key, bookmark_uri, created_at, synced_at)
5151+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
5252+ [
5353+ article.id,
5454+ article.url,
5555+ article.title,
5656+ article.description || null,
5757+ article.filename,
5858+ article.blobKey,
5959+ article.bookmarkUri,
6060+ article.createdAt,
6161+ new Date().toISOString(),
6262+ ],
6363+ );
6464+}
6565+6666+export async function getLastSyncTime(): Promise<string | null> {
6767+ const { rows } = await sqlite.execute(
6868+ `SELECT value FROM kipclip_sync_state_1 WHERE key = 'last_sync'`,
6969+ );
7070+ return rows.length > 0 ? (rows[0][0] as string) : null;
7171+}
7272+7373+export async function setLastSyncTime(time: string): Promise<void> {
7474+ await sqlite.execute(
7575+ `INSERT OR REPLACE INTO kipclip_sync_state_1 (key, value) VALUES ('last_sync', ?)`,
7676+ [time],
7777+ );
7878+}