Compare commits

..

4 Commits

Author SHA1 Message Date
dependabot[bot]
7ea146fa6a Bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.1.3 to 16.1.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.3...v16.1.5)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 19:55:25 +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
14 changed files with 811 additions and 50 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,
"tag": "0001_watery_the_santerians",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1769236358306,
"tag": "0002_modern_white_tiger",
"breakpoints": true
}
]
}

141
package-lock.json generated
View File

@@ -7,14 +7,13 @@
"": {
"name": "readlater",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@mozilla/readability": "^0.6.0",
"better-sqlite3": "^12.6.2",
"drizzle-orm": "^0.45.1",
"jsdom": "^27.4.0",
"lucide-react": "^0.562.0",
"next": "16.1.3",
"next": "16.1.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"uuid": "^13.0.0"
@@ -2143,9 +2142,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.3.tgz",
"integrity": "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.5.tgz",
"integrity": "sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2159,9 +2158,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.3.tgz",
"integrity": "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.5.tgz",
"integrity": "sha512-eK7Wdm3Hjy/SCL7TevlH0C9chrpeOYWx2iR7guJDaz4zEQKWcS1IMVfMb9UKBFMg1XgzcPTYPIp1Vcpukkjg6Q==",
"cpu": [
"arm64"
],
@@ -2175,9 +2174,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.3.tgz",
"integrity": "sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.5.tgz",
"integrity": "sha512-foQscSHD1dCuxBmGkbIr6ScAUF6pRoDZP6czajyvmXPAOFNnQUJu2Os1SGELODjKp/ULa4fulnBWoHV3XdPLfA==",
"cpu": [
"x64"
],
@@ -2191,9 +2190,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.3.tgz",
"integrity": "sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.5.tgz",
"integrity": "sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==",
"cpu": [
"arm64"
],
@@ -2207,9 +2206,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.3.tgz",
"integrity": "sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.5.tgz",
"integrity": "sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==",
"cpu": [
"arm64"
],
@@ -2223,9 +2222,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.3.tgz",
"integrity": "sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.5.tgz",
"integrity": "sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==",
"cpu": [
"x64"
],
@@ -2239,9 +2238,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.3.tgz",
"integrity": "sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.5.tgz",
"integrity": "sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==",
"cpu": [
"x64"
],
@@ -2255,9 +2254,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.3.tgz",
"integrity": "sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.5.tgz",
"integrity": "sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==",
"cpu": [
"arm64"
],
@@ -2271,9 +2270,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.3.tgz",
"integrity": "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.5.tgz",
"integrity": "sha512-7is37HJTNQGhjPpQbkKjKEboHYQnCgpVt/4rBrrln0D9nderNxZ8ZWs8w1fAtzUx7wEyYjQ+/13myFgFj6K2Ng==",
"cpu": [
"x64"
],
@@ -2573,6 +2572,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -6704,12 +6763,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.3.tgz",
"integrity": "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.5.tgz",
"integrity": "sha512-f+wE+NSbiQgh3DSAlTaw2FwY5yGdVViAtp8TotNQj4kk4Q8Bh1sC/aL9aH+Rg1YAVn18OYXsRDT7U/079jgP7w==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.3",
"@next/env": "16.1.5",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
@@ -6723,14 +6782,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.3",
"@next/swc-darwin-x64": "16.1.3",
"@next/swc-linux-arm64-gnu": "16.1.3",
"@next/swc-linux-arm64-musl": "16.1.3",
"@next/swc-linux-x64-gnu": "16.1.3",
"@next/swc-linux-x64-musl": "16.1.3",
"@next/swc-win32-arm64-msvc": "16.1.3",
"@next/swc-win32-x64-msvc": "16.1.3",
"@next/swc-darwin-arm64": "16.1.5",
"@next/swc-darwin-x64": "16.1.5",
"@next/swc-linux-arm64-gnu": "16.1.5",
"@next/swc-linux-arm64-musl": "16.1.5",
"@next/swc-linux-x64-gnu": "16.1.5",
"@next/swc-linux-x64-musl": "16.1.5",
"@next/swc-win32-arm64-msvc": "16.1.5",
"@next/swc-win32-x64-msvc": "16.1.5",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -17,7 +17,7 @@
"drizzle-orm": "^0.45.1",
"jsdom": "^27.4.0",
"lucide-react": "^0.562.0",
"next": "16.1.3",
"next": "16.1.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"uuid": "^13.0.0"

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
};

View File

@@ -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({
<Clock className="w-3 h-3" />
{Math.ceil(article.wordCount / 200)} min read
</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 && (
<span>{article.readingProgress}% read</span>
)}

View File

@@ -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({
<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.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>
</div>
</header>

View File

@@ -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

View File

@@ -19,6 +19,7 @@ export interface Article {
updatedAt: string;
readAt: string | null;
finishedAt: string | null;
publishedAt: string | null;
}
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 {
const d = new Date(date);
const now = new Date();

View File

@@ -1,5 +1,28 @@
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 {
title: string;
@@ -10,6 +33,7 @@ export interface ExtractedArticle {
textContent: string;
leadImage: string | null;
wordCount: number;
publishedAt: Date | null;
}
export async function extractArticle(url: string): Promise<ExtractedArticle> {
@@ -34,14 +58,30 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
});
if (!response.ok) {
if (response.status === 403) {
throw new Error(`This site blocks automated access (403 Forbidden). Try using the bookmarklet from the article page instead - it can capture content your browser can see.`);
// 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}`);
}
const html = await response.text();
const dom = new JSDOM(html, { url });
const cleanedHtml = stripStyles(html);
const dom = new JSDOM(cleanedHtml, {
url,
virtualConsole: createVirtualConsole(),
});
const document = dom.window.document;
// Extract using Readability
@@ -59,6 +99,34 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
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 || "";
@@ -74,6 +142,7 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
textContent,
leadImage,
wordCount,
publishedAt,
};
}
@@ -83,7 +152,11 @@ export async function extractFromHtml(
url: string,
fallbackTitle?: string
): Promise<ExtractedArticle> {
const dom = new JSDOM(html, { url });
const cleanedHtml = stripStyles(html);
const dom = new JSDOM(cleanedHtml, {
url,
virtualConsole: createVirtualConsole(),
});
const document = dom.window.document;
// Extract using Readability
@@ -101,6 +174,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 || "";
@@ -116,5 +217,6 @@ export async function extractFromHtml(
textContent,
leadImage,
wordCount,
publishedAt,
};
}