final feature set

This commit is contained in:
2026-02-05 22:55:24 -05:00
parent 6b00871c32
commit 168e6d5a61
115 changed files with 2401 additions and 4468 deletions

View File

@@ -0,0 +1,163 @@
/**
* App state persistence via JSON file in XDG_CONFIG_HOME
*
* Reads and writes app settings, preferences, and custom theme to a JSON file
* instead of localStorage. Provides migration from localStorage on first run.
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
import { backupConfigFile } from "./config-backup"
import type { AppState, AppSettings, UserPreferences, ThemeColors } from "../types/settings"
import { DEFAULT_THEME } from "../constants/themes"
const APP_STATE_FILE = "app-state.json"
const PROGRESS_FILE = "progress.json"
const LEGACY_APP_STATE_KEY = "podtui_app_state"
const LEGACY_PROGRESS_KEY = "podtui_progress"
// --- Defaults ---
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
}
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
}
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
}
// --- App State ---
/** Load app state from JSON file */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const filePath = getConfigFilePath(APP_STATE_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return defaultState
const raw = await file.json()
if (!raw || typeof raw !== "object") return defaultState
const parsed = raw as Partial<AppState>
return {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
} catch {
return defaultState
}
}
/** Save app state to JSON file */
export async function saveAppStateToFile(state: AppState): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(APP_STATE_FILE)
const filePath = getConfigFilePath(APP_STATE_FILE)
await Bun.write(filePath, JSON.stringify(state, null, 2))
} catch {
// Silently ignore write errors
}
}
/**
* Migrate app state from localStorage to file.
* Only runs once — if the state file already exists, it's a no-op.
*/
export async function migrateAppStateFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(APP_STATE_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem(LEGACY_APP_STATE_KEY)
if (!raw) return false
const parsed = JSON.parse(raw) as Partial<AppState>
const state: AppState = {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
await saveAppStateToFile(state)
return true
} catch {
return false
}
}
// --- Progress ---
interface ProgressEntry {
episodeId: string
position: number
duration: number
timestamp: string | Date
playbackSpeed?: number
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return {}
const raw = await file.json()
if (!raw || typeof raw !== "object") return {}
return raw as Record<string, ProgressEntry>
} catch {
return {}
}
}
/** Save progress map to JSON file */
export async function saveProgressToFile(data: Record<string, unknown>): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(PROGRESS_FILE)
const filePath = getConfigFilePath(PROGRESS_FILE)
await Bun.write(filePath, JSON.stringify(data, null, 2))
} catch {
// Silently ignore write errors
}
}
/**
* Migrate progress from localStorage to file.
* Only runs once — if the progress file already exists, it's a no-op.
*/
export async function migrateProgressFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem(LEGACY_PROGRESS_KEY)
if (!raw) return false
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") return false
await saveProgressToFile(parsed as Record<string, unknown>)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,96 @@
/**
* Config file backup utility for PodTUI
*
* Creates timestamped backups of config files before updates.
* Keeps the most recent N backups and cleans up older ones.
*/
import { readdir, unlink } from "fs/promises"
import path from "path"
import { getConfigDir, ensureConfigDir } from "./config-dir"
/** Maximum number of backup files to keep per config file */
const MAX_BACKUPS = 5
/**
* Generate a timestamped backup filename.
* Example: feeds.json -> feeds.json.2026-02-05T120000.backup
*/
function backupFilename(originalName: string): string {
const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
return `${originalName}.${ts}.backup`
}
/**
* Create a backup of a config file before overwriting it.
* No-op if the source file does not exist.
*/
export async function backupConfigFile(filename: string): Promise<boolean> {
try {
await ensureConfigDir()
const dir = getConfigDir()
const srcPath = path.join(dir, filename)
const srcFile = Bun.file(srcPath)
if (!(await srcFile.exists())) return false
const content = await srcFile.text()
if (!content || content.trim().length === 0) return false
const backupName = backupFilename(filename)
const backupPath = path.join(dir, backupName)
await Bun.write(backupPath, content)
// Clean up old backups
await pruneBackups(filename)
return true
} catch {
return false
}
}
/**
* Keep only the most recent MAX_BACKUPS backup files for a given config file.
*/
async function pruneBackups(filename: string): Promise<void> {
try {
const dir = getConfigDir()
const entries = await readdir(dir)
// Match pattern: filename.*.backup
const prefix = `${filename}.`
const suffix = ".backup"
const backups = entries
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
.sort() // Lexicographic sort works because timestamps are ISO-like
if (backups.length <= MAX_BACKUPS) return
const toRemove = backups.slice(0, backups.length - MAX_BACKUPS)
for (const name of toRemove) {
await unlink(path.join(dir, name)).catch(() => {})
}
} catch {
// Silently ignore cleanup errors
}
}
/**
* List existing backup files for a given config file, newest first.
*/
export async function listBackups(filename: string): Promise<string[]> {
try {
const dir = getConfigDir()
const entries = await readdir(dir)
const prefix = `${filename}.`
const suffix = ".backup"
return entries
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
.sort()
.reverse()
} catch {
return []
}
}

44
src/utils/config-dir.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* XDG_CONFIG_HOME directory setup for PodTUI
*
* Handles config directory detection and creation following the XDG Base
* Directory Specification. Falls back to ~/.config when XDG_CONFIG_HOME
* is not set.
*/
import { mkdir } from "fs/promises"
import path from "path"
/** Application config directory name */
const APP_DIR_NAME = "podtui"
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
export function getXdgConfigHome(): string {
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return xdg
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
if (!home) throw new Error("Cannot determine home directory")
return path.join(home, ".config")
}
/** Get the application-specific config directory path */
export function getConfigDir(): string {
return path.join(getXdgConfigHome(), APP_DIR_NAME)
}
/** Get the path for a specific config file */
export function getConfigFilePath(filename: string): string {
return path.join(getConfigDir(), filename)
}
/**
* Ensure the application config directory exists.
* Creates it recursively if needed.
*/
export async function ensureConfigDir(): Promise<string> {
const dir = getConfigDir()
await mkdir(dir, { recursive: true })
return dir
}

View File

@@ -0,0 +1,166 @@
/**
* Config file validation and migration for PodTUI
*
* Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single
* entry-point to migrate all localStorage data to XDG config files.
*/
import { getConfigFilePath } from "./config-dir"
import {
migrateAppStateFromLocalStorage,
migrateProgressFromLocalStorage,
} from "./app-persistence"
import {
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "./feeds-persistence"
// --- Validation helpers ---
/** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
}
/** Validate AppState JSON structure */
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] }
}
// settings
if (data.settings !== undefined) {
if (!isObject(data.settings)) {
errors.push("settings must be an object")
} else {
const s = data.settings as Record<string, unknown>
if (s.theme !== undefined && typeof s.theme !== "string") errors.push("settings.theme must be a string")
if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number")
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number")
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string")
}
}
// preferences
if (data.preferences !== undefined) {
if (!isObject(data.preferences)) {
errors.push("preferences must be an object")
} else {
const p = data.preferences as Record<string, unknown>
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") errors.push("preferences.showExplicit must be a boolean")
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean")
}
}
// customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object")
}
return { valid: errors.length === 0, errors }
}
/** Validate feeds JSON structure */
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] }
}
for (let i = 0; i < data.length; i++) {
const feed = data[i]
if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`)
continue
}
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`)
if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`)
if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`)
}
return { valid: errors.length === 0, errors }
}
/** Validate progress JSON structure */
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] }
}
for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`)
continue
}
const p = value as Record<string, unknown>
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`)
if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`)
if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`)
}
return { valid: errors.length === 0, errors }
}
// --- Safe config file reading ---
/**
* Safely read and validate a config file.
* Returns the parsed data if valid, or null if the file is missing/corrupt.
*/
export async function safeReadConfigFile<T>(
filename: string,
validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> {
try {
const filePath = getConfigFilePath(filename)
const file = Bun.file(filePath)
if (!(await file.exists())) {
return { data: null, errors: [] }
}
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return { data: null, errors: [`${filename}: invalid JSON`] }
}
const result = validator(parsed)
if (!result.valid) {
return { data: null, errors: result.errors }
}
return { data: parsed as T, errors: [] }
} catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] }
}
}
// --- Unified migration ---
/**
* Run all localStorage -> file migrations.
* Safe to call multiple times; each migration is a no-op if the target
* file already exists.
*
* Returns a summary of what was migrated.
*/
export async function migrateAllFromLocalStorage(): Promise<{
appState: boolean
progress: boolean
feeds: boolean
sources: boolean
}> {
const [appState, progress, feeds, sources] = await Promise.all([
migrateAppStateFromLocalStorage(),
migrateProgressFromLocalStorage(),
migrateFeedsFromLocalStorage(),
migrateSourcesFromLocalStorage(),
])
return { appState, progress, feeds, sources }
}

View File

@@ -107,6 +107,9 @@ export type AppEvents = {
"dialog.open": { dialogId: string }
"dialog.close": { dialogId?: string }
"command.execute": { command: string; args?: unknown }
"clipboard.copied": { text: string }
"selection.start": { x: number; y: number }
"selection.end": { text: string }
}
// Type-safe emit and on functions

View File

@@ -0,0 +1,132 @@
/**
* Feeds persistence via JSON file in XDG_CONFIG_HOME
*
* Reads and writes feeds to a JSON file instead of localStorage.
* Provides migration from localStorage on first run.
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
import { backupConfigFile } from "./config-backup"
import type { Feed } from "../types/feed"
const FEEDS_FILE = "feeds.json"
const SOURCES_FILE = "sources.json"
/** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed {
return {
...feed,
lastUpdated: new Date(feed.lastUpdated),
podcast: {
...feed.podcast,
lastUpdated: new Date(feed.podcast.lastUpdated),
},
episodes: feed.episodes.map((ep) => ({
...ep,
pubDate: new Date(ep.pubDate),
})),
}
}
/** Load feeds from JSON file */
export async function loadFeedsFromFile(): Promise<Feed[]> {
try {
const filePath = getConfigFilePath(FEEDS_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return []
const raw = await file.json()
if (!Array.isArray(raw)) return []
return raw.map(reviveDates)
} catch {
return []
}
}
/** Save feeds to JSON file */
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(FEEDS_FILE)
const filePath = getConfigFilePath(FEEDS_FILE)
await Bun.write(filePath, JSON.stringify(feeds, null, 2))
} catch {
// Silently ignore write errors
}
}
/** Load sources from JSON file */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const filePath = getConfigFilePath(SOURCES_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return null
const raw = await file.json()
if (!Array.isArray(raw)) return null
return raw as T[]
} catch {
return null
}
}
/** Save sources to JSON file */
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(SOURCES_FILE)
const filePath = getConfigFilePath(SOURCES_FILE)
await Bun.write(filePath, JSON.stringify(sources, null, 2))
} catch {
// Silently ignore write errors
}
}
/**
* Migrate feeds from localStorage to file.
* Only runs once — if the feeds file already exists, it's a no-op.
*/
export async function migrateFeedsFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(FEEDS_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false // Already migrated
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem("podtui_feeds")
if (!raw) return false
const feeds = JSON.parse(raw) as Feed[]
if (!Array.isArray(feeds) || feeds.length === 0) return false
await saveFeedsToFile(feeds)
return true
} catch {
return false
}
}
/**
* Migrate sources from localStorage to file.
*/
export async function migrateSourcesFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(SOURCES_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem("podtui_sources")
if (!raw) return false
const sources = JSON.parse(raw)
if (!Array.isArray(sources) || sources.length === 0) return false
await saveSourcesToFile(sources)
return true
} catch {
return false
}
}

111
src/utils/html-to-text.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* HTML-to-text conversion for PodTUI
*
* Converts HTML content from RSS feed descriptions into clean plain text
* suitable for display in the terminal. Preserves paragraph structure,
* converts lists to bulleted text, and strips all tags.
*/
/**
* Convert HTML content to readable plain text.
*
* - Block elements (<p>, <div>, <br>, headings, <li>) become line breaks
* - <li> items get a bullet prefix
* - <a href="...">text</a> becomes "text (url)"
* - All other tags are stripped
* - HTML entities are decoded
* - Excessive whitespace is collapsed
*/
export function htmlToText(html: string): string {
if (!html) return ""
let text = html
// Strip CDATA wrappers
text = text.replace(/<!\[CDATA\[([\s\S]*?)]]>/gi, "$1")
// Replace <br> / <br/> with newline
text = text.replace(/<br\s*\/?>/gi, "\n")
// Replace <hr> with a separator line
text = text.replace(/<hr\s*\/?>/gi, "\n---\n")
// Block-level elements get newlines before/after
text = text.replace(/<\/?(p|div|blockquote|pre|h[1-6]|table|tr|section|article|header|footer)[\s>][^>]*>/gi, "\n")
// List items get bullet prefix
text = text.replace(/<li[^>]*>/gi, "\n - ")
text = text.replace(/<\/li>/gi, "")
// Strip list wrappers
text = text.replace(/<\/?(ul|ol|dl|dt|dd)[^>]*>/gi, "\n")
// Convert links: <a href="url">text</a> -> text (url)
text = text.replace(/<a\s[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, linkText) => {
const cleanText = stripTags(linkText).trim()
if (!cleanText) return href
// Don't duplicate if the link text IS the URL
if (cleanText === href || cleanText === href.replace(/^https?:\/\//, "")) return cleanText
return `${cleanText} (${href})`
})
// Strip all remaining tags
text = stripTags(text)
// Decode HTML entities
text = decodeHtmlEntities(text)
// Collapse multiple blank lines into at most two newlines
text = text.replace(/\n{3,}/g, "\n\n")
// Collapse runs of spaces/tabs (but not newlines) on each line
text = text
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.join("\n")
return text.trim()
}
/** Strip all HTML/XML tags from a string */
function stripTags(html: string): string {
return html.replace(/<[^>]*>/g, "")
}
/** Decode common HTML entities */
function decodeHtmlEntities(text: string): string {
return text
// Named entities
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&mdash;/g, "\u2014")
.replace(/&ndash;/g, "\u2013")
.replace(/&hellip;/g, "\u2026")
.replace(/&laquo;/g, "\u00AB")
.replace(/&raquo;/g, "\u00BB")
.replace(/&ldquo;/g, "\u201C")
.replace(/&rdquo;/g, "\u201D")
.replace(/&lsquo;/g, "\u2018")
.replace(/&rsquo;/g, "\u2019")
.replace(/&bull;/g, "\u2022")
.replace(/&copy;/g, "\u00A9")
.replace(/&reg;/g, "\u00AE")
.replace(/&trade;/g, "\u2122")
.replace(/&deg;/g, "\u00B0")
.replace(/&times;/g, "\u00D7")
// Numeric entities (decimal)
.replace(/&#(\d+);/g, (_, code) => {
const n = parseInt(code, 10)
return n > 0 && n < 0x10ffff ? String.fromCodePoint(n) : ""
})
// Numeric entities (hex)
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const n = parseInt(hex, 16)
return n > 0 && n < 0x10ffff ? String.fromCodePoint(n) : ""
})
}

View File

@@ -0,0 +1,40 @@
/**
* RSS content type detection for PodTUI
*
* Determines whether RSS feed content (description, etc.) is HTML or plain
* text so the appropriate parsing path can be selected.
*/
export enum ContentType {
HTML = "html",
PLAIN_TEXT = "plain_text",
UNKNOWN = "unknown",
}
/** Common HTML tags found in RSS descriptions */
const HTML_TAG_RE = /<\s*\/?\s*(div|p|br|a|b|i|em|strong|ul|ol|li|span|h[1-6]|img|table|tr|td|blockquote|pre|code|hr)\b[^>]*\/?>/i
/** HTML entity patterns beyond the basic five (&amp; etc.) */
const HTML_ENTITY_RE = /&(nbsp|mdash|ndash|hellip|laquo|raquo|ldquo|rdquo|lsquo|rsquo|bull|#\d{2,5}|#x[0-9a-fA-F]{2,4});/
/** CDATA wrapper — content inside is almost always HTML */
const CDATA_RE = /^\s*<!\[CDATA\[/
/**
* Detect whether a string contains HTML markup or is plain text.
*/
export function detectContentType(content: string): ContentType {
if (!content || content.trim().length === 0) return ContentType.UNKNOWN
// CDATA-wrapped content is nearly always HTML
if (CDATA_RE.test(content)) return ContentType.HTML
// Check for standard HTML tags
if (HTML_TAG_RE.test(content)) return ContentType.HTML
// Check for extended HTML entities (basic &amp; / &lt; / etc. can appear in
// plain text too, so we only look for the less common ones)
if (HTML_ENTITY_RE.test(content)) return ContentType.HTML
return ContentType.PLAIN_TEXT
}