final feature set
This commit is contained in:
163
src/utils/app-persistence.ts
Normal file
163
src/utils/app-persistence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
96
src/utils/config-backup.ts
Normal file
96
src/utils/config-backup.ts
Normal 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
44
src/utils/config-dir.ts
Normal 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
|
||||
}
|
||||
166
src/utils/config-validation.ts
Normal file
166
src/utils/config-validation.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
132
src/utils/feeds-persistence.ts
Normal file
132
src/utils/feeds-persistence.ts
Normal 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
111
src/utils/html-to-text.ts
Normal 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/—/g, "\u2014")
|
||||
.replace(/–/g, "\u2013")
|
||||
.replace(/…/g, "\u2026")
|
||||
.replace(/«/g, "\u00AB")
|
||||
.replace(/»/g, "\u00BB")
|
||||
.replace(/“/g, "\u201C")
|
||||
.replace(/”/g, "\u201D")
|
||||
.replace(/‘/g, "\u2018")
|
||||
.replace(/’/g, "\u2019")
|
||||
.replace(/•/g, "\u2022")
|
||||
.replace(/©/g, "\u00A9")
|
||||
.replace(/®/g, "\u00AE")
|
||||
.replace(/™/g, "\u2122")
|
||||
.replace(/°/g, "\u00B0")
|
||||
.replace(/×/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) : ""
|
||||
})
|
||||
}
|
||||
40
src/utils/rss-content-detector.ts
Normal file
40
src/utils/rss-content-detector.ts
Normal 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 (& 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 & / < / 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
|
||||
}
|
||||
Reference in New Issue
Block a user