From 96ece662040b57ce809e130fe0758aa642b7fc81 Mon Sep 17 00:00:00 2001 From: Gemini Agent Date: Sat, 24 Jan 2026 06:33:16 +0000 Subject: [PATCH] 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 --- drizzle/0002_modern_white_tiger.sql | 1 + drizzle/meta/0002_snapshot.json | 566 ++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/articles/route.ts | 2 + src/app/api/save/route.ts | 2 + src/app/api/v1/add/route.ts | 1 + src/components/ArticleList.tsx | 7 +- src/components/Reader.tsx | 9 +- src/lib/db/schema.ts | 1 + src/lib/types.ts | 1 + src/lib/utils/date.ts | 9 + src/lib/utils/extract.ts | 59 +++ 12 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 drizzle/0002_modern_white_tiger.sql create mode 100644 drizzle/meta/0002_snapshot.json diff --git a/drizzle/0002_modern_white_tiger.sql b/drizzle/0002_modern_white_tiger.sql new file mode 100644 index 0000000..c0f75d1 --- /dev/null +++ b/drizzle/0002_modern_white_tiger.sql @@ -0,0 +1 @@ +ALTER TABLE `articles` ADD `published_at` integer; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..8570fd6 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d48004a..19e7b1d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1768638242044, "tag": "0001_watery_the_santerians", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1769236358306, + "tag": "0002_modern_white_tiger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index 492f50e..0dd2a8b 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -30,6 +30,7 @@ export async function GET(request: NextRequest) { updatedAt: schema.articles.updatedAt, readAt: schema.articles.readAt, finishedAt: schema.articles.finishedAt, + publishedAt: schema.articles.publishedAt, }; let query = db.select(listFields).from(schema.articles); @@ -137,6 +138,7 @@ export async function POST(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, }; await db.insert(schema.articles).values(newArticle); diff --git a/src/app/api/save/route.ts b/src/app/api/save/route.ts index 374693a..b6bbabe 100644 --- a/src/app/api/save/route.ts +++ b/src/app/api/save/route.ts @@ -125,6 +125,7 @@ export async function GET(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, }; await db.insert(schema.articles).values(newArticle); @@ -261,6 +262,7 @@ export async function POST(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, }; await db.insert(schema.articles).values(newArticle); diff --git a/src/app/api/v1/add/route.ts b/src/app/api/v1/add/route.ts index ea17a9e..f5aaa0c 100644 --- a/src/app/api/v1/add/route.ts +++ b/src/app/api/v1/add/route.ts @@ -72,6 +72,7 @@ export async function POST(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, tags: tags ? JSON.stringify(tags) : "[]", folderId: folderId || null, }; diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index 66cae2a..b32cb58 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -2,7 +2,7 @@ import { Article } from "@/lib/types"; 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 { articles: Article[]; @@ -106,7 +106,10 @@ export function ArticleList({ {Math.ceil(article.wordCount / 200)} min read - {formatDistanceToNow(article.createdAt)} + {article.publishedAt && ( + {formatDate(article.publishedAt)} + )} + {formatDistanceToNow(article.createdAt)} {article.readingProgress > 0 && article.readingProgress < 100 && ( {article.readingProgress}% read )} diff --git a/src/components/Reader.tsx b/src/components/Reader.tsx index 8172287..e51a243 100644 --- a/src/components/Reader.tsx +++ b/src/components/Reader.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; 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 { article: Article; @@ -141,6 +142,12 @@ export function Reader({
{article.siteName && {article.siteName}} {article.author && By {article.author}} + {article.publishedAt && ( + + + {formatDate(article.publishedAt)} + + )} {Math.ceil(article.wordCount / 200)} min read
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index ce41e3c..56814df 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -42,6 +42,7 @@ export const articles = sqliteTable("articles", { updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()), readAt: integer("read_at", { mode: "timestamp" }), finishedAt: integer("finished_at", { mode: "timestamp" }), // When reading was completed + publishedAt: integer("published_at", { mode: "timestamp" }), // Original article publish date }); // Highlights and notes diff --git a/src/lib/types.ts b/src/lib/types.ts index b0b982a..e24f880 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,7 @@ export interface Article { updatedAt: string; readAt: string | null; finishedAt: string | null; + publishedAt: string | null; } export interface Folder { diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts index 4a10348..be35e2e 100644 --- a/src/lib/utils/date.ts +++ b/src/lib/utils/date.ts @@ -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 { const d = new Date(date); const now = new Date(); diff --git a/src/lib/utils/extract.ts b/src/lib/utils/extract.ts index 2f59e7f..d6e3ec3 100644 --- a/src/lib/utils/extract.ts +++ b/src/lib/utils/extract.ts @@ -33,6 +33,7 @@ export interface ExtractedArticle { textContent: string; leadImage: string | null; wordCount: number; + publishedAt: Date | null; } export async function extractArticle(url: string): Promise { @@ -86,6 +87,34 @@ export async function extractArticle(url: string): Promise { 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 || ""; @@ -101,6 +130,7 @@ export async function extractArticle(url: string): Promise { textContent, leadImage, wordCount, + publishedAt, }; } @@ -132,6 +162,34 @@ export async function extractFromHtml( 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 || ""; @@ -147,5 +205,6 @@ export async function extractFromHtml( textContent, leadImage, wordCount, + publishedAt, }; }