6 Commits

Author SHA1 Message Date
Gemini Agent
9e7e2cc199 Handle email digest redirect URLs and Google News links
- Follow redirects to resolve shortened URLs (c.gle, etc.)
- Extract actual article URL from Google News redirect pages
- Fetch and extract the real article content instead of the redirect page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 04:41:35 +00:00
Gemini Agent
c6a400a04d Gracefully handle 403 blocked sites with minimal article
Instead of failing completely on 403/401, save a placeholder article
with the URL so users can still access via 'Open original' link.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:07:40 +00:00
Gemini Agent
96ece66204 Add article publish date to list and reader views
Extract publication dates from HTML meta tags when saving articles
and display them prominently in the article list and reader header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:33:16 +00:00
Gemini Agent
8151705b17 Fix JSDOM CSS parsing errors (border-width issue)
JSDOM crashes on modern CSS with variables like var(--border-width,1px).
Fix by stripping all <style> tags and inline style attributes before
parsing - Readability only needs DOM structure, not CSS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:20:03 +00:00
Gemini Agent
61e1ac4d81 Fix clipboard copy with fallback for iOS/older browsers
- Add try-catch around navigator.clipboard.writeText
- Add fallback using textarea + execCommand for older browsers
- Add final fallback using prompt() to show URL if all else fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:19:22 +00:00
Gemini Agent
1022b1ddca Add content capture bookmarklet for paywalled sites
- New "Content Capture" bookmarklet sends page HTML directly
- Works for paywalled sites (Economist, NYT, etc.) when logged in
- Works for Cloudflare-protected sites
- Added POST handler to /api/save for HTML content
- Added extractFromHtml() for processing captured content
- Improved 403 error message with bookmarklet suggestion
- Updated bookmarklet page with both options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:14:09 +00:00
14 changed files with 1081 additions and 44 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `articles` ADD `published_at` integer;

View File

@@ -0,0 +1,566 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2817f3e4-6ce5-4d64-80e2-fb5fa10c8fa2",
"prevId": "d3369a08-d474-468e-a003-df32d5f2c61d",
"tables": {
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used": {
"name": "last_used",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"articles": {
"name": "articles",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"site_name": {
"name": "site_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"text_content": {
"name": "text_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lead_image": {
"name": "lead_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"word_count": {
"name": "word_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"reading_progress": {
"name": "reading_progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"reading_time_seconds": {
"name": "reading_time_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"is_archived": {
"name": "is_archived",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"folder_id": {
"name": "folder_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read_at": {
"name": "read_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_config": {
"name": "email_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"inbox_email": {
"name": "inbox_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"email_config_inbox_email_unique": {
"name": "email_config_inbox_email_unique",
"columns": [
"inbox_email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"folders": {
"name": "folders",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'#3b82f6'"
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'folder'"
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"highlights": {
"name": "highlights",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"article_id": {
"name": "article_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'#fbbf24'"
},
"start_offset": {
"name": "start_offset",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"end_offset": {
"name": "end_offset",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reading_goals": {
"name": "reading_goals",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metric": {
"name": "metric",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target": {
"name": "target",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reading_stats": {
"name": "reading_stats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"articles_read": {
"name": "articles_read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"articles_added": {
"name": "articles_added",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"words_read": {
"name": "words_read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"time_spent_seconds": {
"name": "time_spent_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"streak": {
"name": "streak",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"reading_stats_date_unique": {
"name": "reading_stats_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1768638242044, "when": 1768638242044,
"tag": "0001_watery_the_santerians", "tag": "0001_watery_the_santerians",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1769236358306,
"tag": "0002_modern_white_tiger",
"breakpoints": true
} }
] ]
} }

View File

@@ -30,6 +30,7 @@ export async function GET(request: NextRequest) {
updatedAt: schema.articles.updatedAt, updatedAt: schema.articles.updatedAt,
readAt: schema.articles.readAt, readAt: schema.articles.readAt,
finishedAt: schema.articles.finishedAt, finishedAt: schema.articles.finishedAt,
publishedAt: schema.articles.publishedAt,
}; };
let query = db.select(listFields).from(schema.articles); let query = db.select(listFields).from(schema.articles);
@@ -137,6 +138,7 @@ export async function POST(request: NextRequest) {
textContent: extracted.textContent, textContent: extracted.textContent,
leadImage: extracted.leadImage, leadImage: extracted.leadImage,
wordCount: extracted.wordCount, wordCount: extracted.wordCount,
publishedAt: extracted.publishedAt,
}; };
await db.insert(schema.articles).values(newArticle); await db.insert(schema.articles).values(newArticle);

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db"; import { db, schema } from "@/lib/db";
import { extractArticle } from "@/lib/utils/extract"; import { extractArticle, extractFromHtml } from "@/lib/utils/extract";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -125,6 +125,7 @@ export async function GET(request: NextRequest) {
textContent: extracted.textContent, textContent: extracted.textContent,
leadImage: extracted.leadImage, leadImage: extracted.leadImage,
wordCount: extracted.wordCount, wordCount: extracted.wordCount,
publishedAt: extracted.publishedAt,
}; };
await db.insert(schema.articles).values(newArticle); await db.insert(schema.articles).values(newArticle);
@@ -138,3 +139,140 @@ export async function GET(request: NextRequest) {
); );
} }
} }
// POST /api/save - Save article with HTML content from bookmarklet
export async function POST(request: NextRequest) {
const htmlResponse = (status: "success" | "error" | "exists", message: string) => {
const bgColor = status === "success" ? "#22c55e" : status === "exists" ? "#eab308" : "#ef4444";
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ReadLater</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #000;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
text-align: center;
max-width: 300px;
}
.icon {
width: 60px;
height: 60px;
border-radius: 50%;
background: ${bgColor};
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.icon svg {
width: 30px;
height: 30px;
fill: white;
}
h1 {
font-size: 18px;
margin-bottom: 10px;
}
p {
color: #888;
font-size: 14px;
line-height: 1.5;
}
.close {
margin-top: 20px;
padding: 10px 20px;
background: #333;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 14px;
}
.close:hover {
background: #444;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
${status === "success" ? '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>' : status === "exists" ? '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>' : '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>'}
</div>
<h1>${status === "success" ? "Saved!" : status === "exists" ? "Already Saved" : "Error"}</h1>
<p>${message}</p>
<button class="close" onclick="window.close()">Close</button>
</div>
<script>
setTimeout(() => window.close(), 3000);
</script>
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
};
try {
// Parse form data from bookmarklet
const formData = await request.formData();
const url = formData.get("url") as string;
const html = formData.get("html") as string;
const title = formData.get("title") as string;
if (!url) {
return htmlResponse("error", "No URL provided");
}
// Check if article already exists
const existing = await db
.select()
.from(schema.articles)
.where(eq(schema.articles.url, url))
.limit(1);
if (existing.length > 0) {
return htmlResponse("exists", `"${existing[0].title}" is already in your reading list`);
}
// Extract article from provided HTML content
const extracted = await extractFromHtml(html, url, title);
const id = uuidv4();
const newArticle: schema.NewArticle = {
id,
url,
title: extracted.title,
author: extracted.author,
siteName: extracted.siteName,
excerpt: extracted.excerpt,
content: extracted.content,
textContent: extracted.textContent,
leadImage: extracted.leadImage,
wordCount: extracted.wordCount,
publishedAt: extracted.publishedAt,
};
await db.insert(schema.articles).values(newArticle);
return htmlResponse("success", `"${extracted.title}" has been added to your reading list`);
} catch (error) {
console.error("Error saving article from HTML:", error);
return htmlResponse(
"error",
error instanceof Error ? error.message : "Failed to save article"
);
}
}

View File

@@ -72,6 +72,7 @@ export async function POST(request: NextRequest) {
textContent: extracted.textContent, textContent: extracted.textContent,
leadImage: extracted.leadImage, leadImage: extracted.leadImage,
wordCount: extracted.wordCount, wordCount: extracted.wordCount,
publishedAt: extracted.publishedAt,
tags: tags ? JSON.stringify(tags) : "[]", tags: tags ? JSON.stringify(tags) : "[]",
folderId: folderId || null, folderId: folderId || null,
}; };

View File

@@ -1,23 +1,34 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { BookOpen, Copy, Check } from "lucide-react"; import { BookOpen, Copy, Check, Zap, Link2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
export default function BookmarkletPage() { export default function BookmarkletPage() {
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
const [copied, setCopied] = useState(false); const [copiedSimple, setCopiedSimple] = useState(false);
const [copiedAdvanced, setCopiedAdvanced] = useState(false);
useEffect(() => { useEffect(() => {
setBaseUrl(window.location.origin); setBaseUrl(window.location.origin);
}, []); }, []);
const bookmarkletCode = `javascript:(function(){var url=encodeURIComponent(window.location.href);window.open('${baseUrl}/api/save?url='+url,'_blank','width=400,height=300');})();`; // Simple bookmarklet - just sends URL (works for most sites)
const simpleBookmarklet = `javascript:(function(){var url=encodeURIComponent(window.location.href);window.open('${baseUrl}/api/save?url='+url,'_blank','width=400,height=300');})();`;
const handleCopy = async () => { // Advanced bookmarklet - captures page content directly (works for paywalled/protected sites)
await navigator.clipboard.writeText(bookmarkletCode); const advancedBookmarklet = `javascript:(function(){var d=document,b=d.body,t=d.title,u=location.href,h=d.documentElement.outerHTML;var f=d.createElement('form');f.method='POST';f.action='${baseUrl}/api/save';f.target='_blank';var addField=function(n,v){var i=d.createElement('input');i.type='hidden';i.name=n;i.value=v;f.appendChild(i);};addField('url',u);addField('title',t);addField('html',h);b.appendChild(f);f.submit();b.removeChild(f);})();`;
setCopied(true);
setTimeout(() => setCopied(false), 2000); const handleCopySimple = async () => {
await navigator.clipboard.writeText(simpleBookmarklet);
setCopiedSimple(true);
setTimeout(() => setCopiedSimple(false), 2000);
};
const handleCopyAdvanced = async () => {
await navigator.clipboard.writeText(advancedBookmarklet);
setCopiedAdvanced(true);
setTimeout(() => setCopiedAdvanced(false), 2000);
}; };
return ( return (
@@ -31,31 +42,76 @@ export default function BookmarkletPage() {
Back to ReadLater Back to ReadLater
</Link> </Link>
<h1 className="text-3xl font-bold mb-6">Bookmarklet</h1> <h1 className="text-3xl font-bold mb-6">Bookmarklets</h1>
<div className="bg-[var(--surface)] rounded-lg p-6 mb-8"> {/* Advanced Bookmarklet - Recommended */}
<h2 className="text-xl font-semibold mb-4">Quick Save Bookmarklet</h2> <div className="bg-[var(--surface)] rounded-lg p-6 mb-6 border-2 border-[var(--accent)]">
<p className="text-[var(--muted)] mb-6"> <div className="flex items-center gap-2 mb-4">
Drag this button to your bookmarks bar, or right-click and &quot;Add to Bookmarks&quot;. <Zap className="w-5 h-5 text-[var(--accent)]" />
Then click it on any page to save the article to ReadLater. <h2 className="text-xl font-semibold">Content Capture (Recommended)</h2>
</div>
<p className="text-[var(--muted)] mb-4">
Captures the actual page content from your browser. <strong>Works with paywalled sites</strong> (Economist, NYT, etc.)
and sites with bot protection - as long as you can see the article, it can save it.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 items-start"> <div className="flex flex-col sm:flex-row gap-4 items-start">
<a <a
href={bookmarkletCode} href={advancedBookmarklet}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
className="inline-flex items-center gap-2 px-6 py-3 bg-[var(--accent)] text-white rounded-lg font-medium cursor-move" className="inline-flex items-center gap-2 px-6 py-3 bg-[var(--accent)] text-white rounded-lg font-medium cursor-move"
title="Drag to bookmarks bar" title="Drag to bookmarks bar"
> >
<BookOpen className="w-5 h-5" /> <Zap className="w-5 h-5" />
Save to ReadLater Save to ReadLater
</a> </a>
<button <button
onClick={handleCopy} onClick={handleCopyAdvanced}
className="inline-flex items-center gap-2 px-4 py-3 border border-[var(--border)] rounded-lg hover:bg-[var(--surface)] transition-colors" className="inline-flex items-center gap-2 px-4 py-3 border border-[var(--border)] rounded-lg hover:bg-[var(--background)] transition-colors"
> >
{copied ? ( {copiedAdvanced ? (
<>
<Check className="w-5 h-5 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="w-5 h-5" />
Copy code
</>
)}
</button>
</div>
</div>
{/* Simple Bookmarklet */}
<div className="bg-[var(--surface)] rounded-lg p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<Link2 className="w-5 h-5" />
<h2 className="text-xl font-semibold">URL Only (Lightweight)</h2>
</div>
<p className="text-[var(--muted)] mb-4">
Just sends the URL - our server fetches the content. Smaller bookmarklet, but won&apos;t work
for paywalled or bot-protected sites.
</p>
<div className="flex flex-col sm:flex-row gap-4 items-start">
<a
href={simpleBookmarklet}
onClick={(e) => e.preventDefault()}
className="inline-flex items-center gap-2 px-6 py-3 bg-[var(--muted)] text-white rounded-lg font-medium cursor-move"
title="Drag to bookmarks bar"
>
<Link2 className="w-5 h-5" />
Save URL
</a>
<button
onClick={handleCopySimple}
className="inline-flex items-center gap-2 px-4 py-3 border border-[var(--border)] rounded-lg hover:bg-[var(--background)] transition-colors"
>
{copiedSimple ? (
<> <>
<Check className="w-5 h-5 text-green-500" /> <Check className="w-5 h-5 text-green-500" />
Copied! Copied!
@@ -71,22 +127,21 @@ export default function BookmarkletPage() {
</div> </div>
<div className="bg-[var(--surface)] rounded-lg p-6"> <div className="bg-[var(--surface)] rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Manual Installation</h2> <h2 className="text-xl font-semibold mb-4">Installation</h2>
<p className="text-[var(--muted)] mb-4"> <ol className="list-decimal list-inside space-y-2 text-[var(--muted)]">
If dragging doesn&apos;t work, create a new bookmark and paste this as the URL: <li><strong>Drag</strong> the button above to your bookmarks bar</li>
</p> <li>Or <strong>right-click</strong> &quot;Add to Bookmarks&quot;</li>
<pre className="bg-[var(--background)] p-4 rounded-lg overflow-x-auto text-sm"> <li>Or <strong>copy the code</strong> and create a bookmark manually</li>
<code className="text-[var(--muted)]">{bookmarkletCode}</code> </ol>
</pre>
</div> </div>
<div className="mt-8 text-[var(--muted)] text-sm"> <div className="mt-8 text-[var(--muted)] text-sm">
<h3 className="font-semibold mb-2">How it works:</h3> <h3 className="font-semibold mb-2">Tips:</h3>
<ol className="list-decimal list-inside space-y-1"> <ul className="list-disc list-inside space-y-1">
<li>Click the bookmarklet on any article page</li> <li>Use the <strong>Content Capture</strong> bookmarklet for paywalled sites you&apos;re subscribed to</li>
<li>A popup will confirm the article was saved</li> <li>Make sure you&apos;re logged in to see the full article before clicking</li>
<li>The article appears in your ReadLater list</li> <li>The bookmarklet sends the visible page content directly to ReadLater</li>
</ol> </ul>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -91,9 +91,28 @@ export default function SettingsPage() {
}; };
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text); try {
setCopied(true); await navigator.clipboard.writeText(text);
setTimeout(() => setCopied(false), 2000); setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers or permission denied
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// If all else fails, show the URL in a prompt
window.prompt("Copy this URL:", text);
}
document.body.removeChild(textArea);
}
}; };
const handleImport = async () => { const handleImport = async () => {

View File

@@ -2,7 +2,7 @@
import { Article } from "@/lib/types"; import { Article } from "@/lib/types";
import { Star, Archive, Trash2, ExternalLink, Clock, CheckSquare, Square } from "lucide-react"; import { Star, Archive, Trash2, ExternalLink, Clock, CheckSquare, Square } from "lucide-react";
import { formatDistanceToNow } from "@/lib/utils/date"; import { formatDistanceToNow, formatDate } from "@/lib/utils/date";
interface ArticleListProps { interface ArticleListProps {
articles: Article[]; articles: Article[];
@@ -106,7 +106,10 @@ export function ArticleList({
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{Math.ceil(article.wordCount / 200)} min read {Math.ceil(article.wordCount / 200)} min read
</span> </span>
<span>{formatDistanceToNow(article.createdAt)}</span> {article.publishedAt && (
<span title="Published date">{formatDate(article.publishedAt)}</span>
)}
<span title="Added to library">{formatDistanceToNow(article.createdAt)}</span>
{article.readingProgress > 0 && article.readingProgress < 100 && ( {article.readingProgress > 0 && article.readingProgress < 100 && (
<span>{article.readingProgress}% read</span> <span>{article.readingProgress}% read</span>
)} )}

View File

@@ -2,7 +2,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Article, ReaderSettings } from "@/lib/types"; import { Article, ReaderSettings } from "@/lib/types";
import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink } from "lucide-react"; import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink, Calendar } from "lucide-react";
import { formatDate } from "@/lib/utils/date";
interface ReaderProps { interface ReaderProps {
article: Article; article: Article;
@@ -141,6 +142,12 @@ export function Reader({
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--muted)] text-sm"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--muted)] text-sm">
{article.siteName && <span>{article.siteName}</span>} {article.siteName && <span>{article.siteName}</span>}
{article.author && <span>By {article.author}</span>} {article.author && <span>By {article.author}</span>}
{article.publishedAt && (
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{formatDate(article.publishedAt)}
</span>
)}
<span>{Math.ceil(article.wordCount / 200)} min read</span> <span>{Math.ceil(article.wordCount / 200)} min read</span>
</div> </div>
</header> </header>

View File

@@ -42,6 +42,7 @@ export const articles = sqliteTable("articles", {
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()), updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
readAt: integer("read_at", { mode: "timestamp" }), readAt: integer("read_at", { mode: "timestamp" }),
finishedAt: integer("finished_at", { mode: "timestamp" }), // When reading was completed finishedAt: integer("finished_at", { mode: "timestamp" }), // When reading was completed
publishedAt: integer("published_at", { mode: "timestamp" }), // Original article publish date
}); });
// Highlights and notes // Highlights and notes

View File

@@ -19,6 +19,7 @@ export interface Article {
updatedAt: string; updatedAt: string;
readAt: string | null; readAt: string | null;
finishedAt: string | null; finishedAt: string | null;
publishedAt: string | null;
} }
export interface Folder { export interface Folder {

View File

@@ -1,3 +1,12 @@
export function formatDate(date: string | Date): string {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function formatDistanceToNow(date: string | Date): string { export function formatDistanceToNow(date: string | Date): string {
const d = new Date(date); const d = new Date(date);
const now = new Date(); const now = new Date();

View File

@@ -1,5 +1,28 @@
import { Readability } from "@mozilla/readability"; import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom"; import { JSDOM, VirtualConsole } from "jsdom";
// Create a virtual console that suppresses CSS parsing errors
// JSDOM has issues with modern CSS (variables, etc.) that don't affect Readability
function createVirtualConsole() {
const virtualConsole = new VirtualConsole();
virtualConsole.on("error", () => {
// Suppress CSS parsing errors
});
virtualConsole.on("warn", () => {
// Suppress warnings
});
return virtualConsole;
}
// Strip style tags and inline styles from HTML to prevent JSDOM CSS parsing errors
// Readability doesn't need CSS - it only needs the DOM structure
function stripStyles(html: string): string {
// Remove <style> tags and their contents
let cleaned = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// Remove style attributes (but keep the rest of the tag)
cleaned = cleaned.replace(/\s+style\s*=\s*["'][^"']*["']/gi, "");
return cleaned;
}
export interface ExtractedArticle { export interface ExtractedArticle {
title: string; title: string;
@@ -10,22 +33,122 @@ export interface ExtractedArticle {
textContent: string; textContent: string;
leadImage: string | null; leadImage: string | null;
wordCount: number; wordCount: number;
publishedAt: Date | null;
}
// Try to extract actual article URL from Google News redirect page
function extractGoogleNewsUrl(html: string): string | null {
// Google News embeds the real URL in various ways
// Look for data-n-au attribute (article URL)
const dataMatch = html.match(/data-n-au="([^"]+)"/);
if (dataMatch) return decodeURIComponent(dataMatch[1]);
// Look for canonical link
const canonicalMatch = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
if (canonicalMatch && !canonicalMatch[1].includes('news.google.com')) {
return canonicalMatch[1];
}
// Look for og:url that's not Google News
const ogMatch = html.match(/<meta[^>]+property=["']og:url["'][^>]+content=["']([^"']+)["']/i);
if (ogMatch && !ogMatch[1].includes('news.google.com')) {
return ogMatch[1];
}
// Look for article link in jsdata or similar
const jsMatch = html.match(/jsdata="[^"]*https?:\/\/(?!news\.google\.com)[^"&\s]+/);
if (jsMatch) {
const urlMatch = jsMatch[0].match(/https?:\/\/[^"&\s]+/);
if (urlMatch) return urlMatch[0];
}
return null;
} }
export async function extractArticle(url: string): Promise<ExtractedArticle> { export async function extractArticle(url: string): Promise<ExtractedArticle> {
// Fetch the page // Resolve shortened/redirect URLs first
const response = await fetch(url, { let resolvedUrl = url;
// Follow redirects to get final URL
try {
const headResponse = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});
resolvedUrl = headResponse.url;
} catch {
// If HEAD fails, continue with original URL
}
// Fetch the page with browser-like headers to avoid bot detection
const response = await fetch(resolvedUrl, {
headers: { headers: {
"User-Agent": "Mozilla/5.0 (compatible; ReadLater/1.0)", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}, },
}); });
if (!response.ok) { if (!response.ok) {
// On 403/blocked, return minimal article with just URL info
if (response.status === 403 || response.status === 401) {
const hostname = new URL(url).hostname.replace(/^www\./, "");
return {
title: `Article from ${hostname}`,
author: null,
siteName: hostname,
excerpt: "This site blocked automated access. Use 'Open original' to read, or the Content Capture bookmarklet to save the full article.",
content: `<p>This site blocked automated access. <a href="${url}" target="_blank">Open original article</a> to read.</p><p>Tip: Use the Content Capture bookmarklet from the article page to save the full content.</p>`,
textContent: "This site blocked automated access. Open original article to read.",
leadImage: null,
wordCount: 0,
publishedAt: null,
};
}
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
} }
const html = await response.text(); let html = await response.text();
const dom = new JSDOM(html, { url }); let finalUrl = resolvedUrl;
// Check if we landed on Google News - need to extract actual article URL
if (resolvedUrl.includes('news.google.com')) {
const realUrl = extractGoogleNewsUrl(html);
if (realUrl) {
// Fetch the actual article
const articleResponse = await fetch(realUrl, {
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
});
if (articleResponse.ok) {
html = await articleResponse.text();
finalUrl = realUrl;
}
}
}
const cleanedHtml = stripStyles(html);
const dom = new JSDOM(cleanedHtml, {
url: finalUrl,
virtualConsole: createVirtualConsole(),
});
const document = dom.window.document; const document = dom.window.document;
// Extract using Readability // Extract using Readability
@@ -43,6 +166,34 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
leadImage = ogImage.getAttribute("content"); leadImage = ogImage.getAttribute("content");
} }
// Try to find publish date from various meta tags
let publishedAt: Date | null = null;
const dateSelectors = [
'meta[property="article:published_time"]',
'meta[name="article:published_time"]',
'meta[property="og:published_time"]',
'meta[name="pubdate"]',
'meta[name="publishdate"]',
'meta[name="date"]',
'meta[itemprop="datePublished"]',
'time[datetime]',
'time[pubdate]',
];
for (const selector of dateSelectors) {
const el = document.querySelector(selector);
if (el) {
const dateStr = el.getAttribute("content") || el.getAttribute("datetime");
if (dateStr) {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
publishedAt = parsed;
break;
}
}
}
}
const textContent = article.textContent || ""; const textContent = article.textContent || "";
const content = article.content || ""; const content = article.content || "";
@@ -52,11 +203,87 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
return { return {
title: article.title || "Untitled", title: article.title || "Untitled",
author: article.byline || null, author: article.byline || null,
siteName: article.siteName || new URL(finalUrl).hostname,
excerpt: article.excerpt || null,
content,
textContent,
leadImage,
wordCount,
publishedAt,
};
}
// Extract article from provided HTML content (for bookmarklet with content capture)
export async function extractFromHtml(
html: string,
url: string,
fallbackTitle?: string
): Promise<ExtractedArticle> {
const cleanedHtml = stripStyles(html);
const dom = new JSDOM(cleanedHtml, {
url,
virtualConsole: createVirtualConsole(),
});
const document = dom.window.document;
// Extract using Readability
const reader = new Readability(document);
const article = reader.parse();
if (!article) {
throw new Error("Could not extract article content from provided HTML");
}
// Try to find lead image
let leadImage: string | null = null;
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogImage) {
leadImage = ogImage.getAttribute("content");
}
// Try to find publish date from various meta tags
let publishedAt: Date | null = null;
const dateSelectors = [
'meta[property="article:published_time"]',
'meta[name="article:published_time"]',
'meta[property="og:published_time"]',
'meta[name="pubdate"]',
'meta[name="publishdate"]',
'meta[name="date"]',
'meta[itemprop="datePublished"]',
'time[datetime]',
'time[pubdate]',
];
for (const selector of dateSelectors) {
const el = document.querySelector(selector);
if (el) {
const dateStr = el.getAttribute("content") || el.getAttribute("datetime");
if (dateStr) {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
publishedAt = parsed;
break;
}
}
}
}
const textContent = article.textContent || "";
const content = article.content || "";
// Calculate word count
const wordCount = textContent.split(/\s+/).filter(Boolean).length;
return {
title: article.title || fallbackTitle || "Untitled",
author: article.byline || null,
siteName: article.siteName || new URL(url).hostname, siteName: article.siteName || new URL(url).hostname,
excerpt: article.excerpt || null, excerpt: article.excerpt || null,
content, content,
textContent, textContent,
leadImage, leadImage,
wordCount, wordCount,
publishedAt,
}; };
} }