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

@@ -1,4 +1,5 @@
import { createSignal, ErrorBoundary } from "solid-js";
import { useSelectionHandler } from "@opentui/solid";
import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation";
import { TabNavigation } from "./components/TabNavigation";
@@ -18,6 +19,8 @@ import { useAppStore } from "./stores/app";
import { useAudio } from "./hooks/useAudio";
import { FeedVisibility } from "./types/feed";
import { useAppKeyboard } from "./hooks/useAppKeyboard";
import { Clipboard } from "./utils/clipboard";
import { emit } from "./utils/event-bus";
import type { TabId } from "./components/Tab";
import type { AuthScreen } from "./types/auth";
import type { Episode } from "./types/episode";
@@ -80,6 +83,21 @@ export function App() {
},
});
// Copy selected text to clipboard when selection ends (mouse release)
useSelectionHandler((selection: any) => {
if (!selection) return
const text = selection.getSelectedText?.()
if (!text || text.trim().length === 0) return
Clipboard.copy(text).then(() => {
emit("toast.show", {
message: "Copied to clipboard",
variant: "info",
duration: 1500,
})
}).catch(() => {})
})
const getPanels = () => {
const tab = activeTab();

View File

@@ -1,5 +1,7 @@
import type { Podcast } from "../types/podcast"
import type { Episode, EpisodeType } from "../types/episode"
import { detectContentType, ContentType } from "../utils/rss-content-detector"
import { htmlToText } from "../utils/html-to-text"
const getTagValue = (xml: string, tag: string): string => {
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"))
@@ -22,6 +24,20 @@ const decodeEntities = (value: string) =>
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
/**
* Clean a description field: detect HTML vs plain text, and convert
* HTML to readable plain text. Plain text just gets entity decoding.
*/
const cleanDescription = (raw: string): string => {
if (!raw) return ""
const decoded = decodeEntities(raw)
const type = detectContentType(decoded)
if (type === ContentType.HTML) {
return htmlToText(decoded)
}
return decoded
}
/**
* Parse an itunes:duration value which can be:
* - "HH:MM:SS"
@@ -61,14 +77,14 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = decodeEntities(getTagValue(channel, "title")) || "Untitled Podcast"
const description = decodeEntities(getTagValue(channel, "description"))
const description = cleanDescription(getTagValue(channel, "description"))
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, index) => {
const epTitle = decodeEntities(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = decodeEntities(getTagValue(item, "description"))
const epDescription = cleanDescription(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>

View File

@@ -3,8 +3,11 @@ import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
import { resolveTheme } from "../utils/theme-resolver"
import type { ThemeJson } from "../types/theme-schema"
const STORAGE_KEY = "podtui_app_state"
import {
loadAppStateFromFile,
saveAppStateToFile,
migrateAppStateFromLocalStorage,
} from "../utils/app-persistence"
const defaultSettings: AppSettings = {
theme: "system",
@@ -24,33 +27,21 @@ const defaultState: AppState = {
customTheme: DEFAULT_THEME,
}
const loadState = (): AppState => {
if (typeof localStorage === "undefined") return defaultState
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultState
const parsed = JSON.parse(raw) as Partial<AppState>
return {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
} catch {
return defaultState
}
}
const saveState = (state: AppState) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {
// ignore storage errors
}
}
export function createAppStore() {
const [state, setState] = createSignal<AppState>(loadState())
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState)
// Fire-and-forget async initialisation
const init = async () => {
await migrateAppStateFromLocalStorage()
const loaded = await loadAppStateFromFile()
setState(loaded)
}
init()
const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {})
}
const updateState = (next: AppState) => {
setState(next)

View File

@@ -11,6 +11,14 @@ import type { Episode, EpisodeStatus } from "../types/episode"
import type { PodcastSource, SourceType } from "../types/source"
import { DEFAULT_SOURCES } from "../types/source"
import { parseRSSFeed } from "../api/rss-parser"
import {
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile,
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "../utils/feeds-persistence"
/** Max episodes to fetch on refresh */
const MAX_EPISODES_REFRESH = 50
@@ -18,85 +26,30 @@ const MAX_EPISODES_REFRESH = 50
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20
/** Storage keys */
const STORAGE_KEYS = {
feeds: "podtui_feeds",
sources: "podtui_sources",
}
/** Load feeds from localStorage */
function loadFeeds(): Feed[] {
if (typeof localStorage === "undefined") {
return []
}
try {
const stored = localStorage.getItem(STORAGE_KEYS.feeds)
if (stored) {
const parsed = JSON.parse(stored)
// Convert date strings
return parsed.map((feed: Feed) => ({
...feed,
lastUpdated: new Date(feed.lastUpdated),
podcast: {
...feed.podcast,
lastUpdated: new Date(feed.podcast.lastUpdated),
},
episodes: feed.episodes.map((ep: Episode) => ({
...ep,
pubDate: new Date(ep.pubDate),
})),
}))
}
} catch {
// Ignore errors
}
return []
}
/** Save feeds to localStorage */
/** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
} catch {
// Ignore errors
}
saveFeedsToFile(feeds).catch(() => {})
}
/** Load sources from localStorage */
function loadSources(): PodcastSource[] {
if (typeof localStorage === "undefined") {
return [...DEFAULT_SOURCES]
}
try {
const stored = localStorage.getItem(STORAGE_KEYS.sources)
if (stored) {
return JSON.parse(stored)
}
} catch {
// Ignore errors
}
return [...DEFAULT_SOURCES]
}
/** Save sources to localStorage */
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.sources, JSON.stringify(sources))
} catch {
// Ignore errors
}
saveSourcesToFile(sources).catch(() => {})
}
/** Create feed store */
export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>(loadFeeds())
const [sources, setSources] = createSignal<PodcastSource[]>(loadSources())
const [feeds, setFeeds] = createSignal<Feed[]>([])
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES])
// Async initialization: migrate from localStorage, then load from file
;(async () => {
await migrateFeedsFromLocalStorage()
await migrateSourcesFromLocalStorage()
const loadedFeeds = await loadFeedsFromFile()
if (loadedFeeds.length > 0) setFeeds(loadedFeeds)
const loadedSources = await loadSourcesFromFile<PodcastSource>()
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
})()
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,

View File

@@ -1,14 +1,17 @@
/**
* Episode progress store for PodTUI
*
* Persists per-episode playback progress to localStorage.
* Persists per-episode playback progress to a JSON file in XDG_CONFIG_HOME.
* Tracks position, duration, completion, and last-played timestamp.
*/
import { createSignal } from "solid-js"
import type { Progress } from "../types/episode"
const STORAGE_KEY = "podtui_progress"
import {
loadProgressFromFile,
saveProgressToFile,
migrateProgressFromLocalStorage,
} from "../utils/app-persistence"
/** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95
@@ -16,48 +19,42 @@ const COMPLETION_THRESHOLD = 0.95
/** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5
// --- localStorage helpers ---
function loadProgress(): Record<string, Progress> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
const result: Record<string, Progress> = {}
for (const [key, value] of Object.entries(parsed)) {
const p = value as Record<string, unknown>
result[key] = {
episodeId: p.episodeId as string,
position: p.position as number,
duration: p.duration as number,
timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined,
}
}
return result
} catch {
return {}
}
}
function saveProgress(data: Record<string, Progress>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {
// Quota exceeded or unavailable — silently ignore
}
}
// --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
loadProgress(),
)
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({})
/** Persist current progress map to file (fire-and-forget) */
function persist(): void {
saveProgress(progressMap())
saveProgressToFile(progressMap()).catch(() => {})
}
/** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> {
const result: Record<string, Progress> = {}
for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown>
result[key] = {
episodeId: p.episodeId as string,
position: p.position as number,
duration: p.duration as number,
timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined,
}
}
return result
}
/** Async initialisation — migrate from localStorage then load from file */
async function initProgress(): Promise<void> {
await migrateProgressFromLocalStorage()
const raw = await loadProgressFromFile()
const parsed = parseProgressEntries(raw as Record<string, unknown>)
setProgressMap(parsed)
}
// Fire-and-forget init
initProgress()
function createProgressStore() {
return {
/**

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
}