v2.0: Major feature update

New Features:
- API key authentication for external access
- Apple Shortcuts integration endpoint (/api/v1/add)
- Full-text search across all articles
- Folders for organizing articles
- Highlights and notes on articles
- Reading stats with streaks
- Reading goals (daily/weekly/monthly)
- Import from Pocket/Instapaper
- RSS feed output
- PWA support for mobile
- Auto theme scheduling (day/night)
- Settings page with all configuration

API Endpoints:
- /api/v1/add - Add articles via API key
- /api/keys - Manage API keys
- /api/search - Full-text search
- /api/folders - Folder management
- /api/highlights - Highlights/notes
- /api/stats - Reading statistics
- /api/goals - Reading goals
- /api/import - Pocket/Instapaper import
- /api/rss - RSS feed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-17 12:19:57 +00:00
parent 27963af055
commit 513576b90e
22 changed files with 2431 additions and 30 deletions

View File

@@ -1,5 +1,26 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
// API Keys for authentication
export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
name: text("name").notNull(), // e.g., "iPhone Shortcut", "Mac Safari"
key: text("key").notNull().unique(),
lastUsed: integer("last_used", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
// Folders for organizing articles
export const folders = sqliteTable("folders", {
id: text("id").primaryKey(),
name: text("name").notNull(),
color: text("color").default("#3b82f6"), // Accent color
icon: text("icon").default("folder"), // Lucide icon name
parentId: text("parent_id"), // For nested folders
sortOrder: integer("sort_order").default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
// Articles
export const articles = sqliteTable("articles", {
id: text("id").primaryKey(),
url: text("url").notNull(),
@@ -12,13 +33,83 @@ export const articles = sqliteTable("articles", {
leadImage: text("lead_image"),
wordCount: integer("word_count").default(0),
readingProgress: integer("reading_progress").default(0), // 0-100
readingTimeSeconds: integer("reading_time_seconds").default(0), // Total time spent reading
isFavorite: integer("is_favorite", { mode: "boolean" }).default(false),
isArchived: integer("is_archived", { mode: "boolean" }).default(false),
folderId: text("folder_id"), // Link to folder
tags: text("tags").default("[]"), // JSON array of tags
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
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
});
// Highlights and notes
export const highlights = sqliteTable("highlights", {
id: text("id").primaryKey(),
articleId: text("article_id").notNull(),
text: text("text").notNull(), // Highlighted text
note: text("note"), // Optional note
color: text("color").default("#fbbf24"), // Highlight color
startOffset: integer("start_offset"), // Position in textContent
endOffset: integer("end_offset"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
// Daily reading stats
export const readingStats = sqliteTable("reading_stats", {
id: text("id").primaryKey(),
date: text("date").notNull().unique(), // YYYY-MM-DD format
articlesRead: integer("articles_read").default(0),
articlesAdded: integer("articles_added").default(0),
wordsRead: integer("words_read").default(0),
timeSpentSeconds: integer("time_spent_seconds").default(0),
streak: integer("streak").default(0), // Consecutive days
});
// Reading goals
export const readingGoals = sqliteTable("reading_goals", {
id: text("id").primaryKey(),
type: text("type").notNull(), // "daily", "weekly", "monthly"
metric: text("metric").notNull(), // "articles", "words", "time"
target: integer("target").notNull(),
isActive: integer("is_active", { mode: "boolean" }).default(true),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
// App settings
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(), // JSON encoded value
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
// Email inbox config (for email-to-save)
export const emailConfig = sqliteTable("email_config", {
id: text("id").primaryKey(),
inboxEmail: text("inbox_email").unique(), // e.g., save-abc123@readlater.example.com
isActive: integer("is_active", { mode: "boolean" }).default(true),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
// Types
export type ApiKey = typeof apiKeys.$inferSelect;
export type NewApiKey = typeof apiKeys.$inferInsert;
export type Folder = typeof folders.$inferSelect;
export type NewFolder = typeof folders.$inferInsert;
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;
export type Highlight = typeof highlights.$inferSelect;
export type NewHighlight = typeof highlights.$inferInsert;
export type ReadingStats = typeof readingStats.$inferSelect;
export type NewReadingStats = typeof readingStats.$inferInsert;
export type ReadingGoal = typeof readingGoals.$inferSelect;
export type NewReadingGoal = typeof readingGoals.$inferInsert;
export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;