final feature set
This commit is contained in:
18
src/App.tsx
18
src/App.tsx
@@ -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();
|
||||
|
||||
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/'/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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
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