drop oauth plan (copy config)

This commit is contained in:
2026-08-08 07:10:59 -04:00
parent 13a31aabdc
commit 3ef19f80b8
20 changed files with 530 additions and 1592 deletions

View File

@@ -1,158 +1,151 @@
/**
* App state persistence via JSON file in XDG_CONFIG_HOME
* App state persistence — settings, preferences, and custom theme are stored
* in the centralized `config.json` (see utils/config.ts). Playback progress
* and audio-nav state stay in separate files (they change on every seek and
* would thrash config.json).
*
* Reads and writes app settings, preferences, and custom theme to a JSON file
* No backups — writes always overwrite.
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup";
import { loadConfig, updateConfig } from "./config";
import type {
AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes";
const APP_STATE_FILE = "app-state.json";
const PROGRESS_FILE = "progress.json";
const AUDIO_NAV_FILE = "audio-nav.json";
// --- Defaults ---
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
};
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
showExplicit: false,
autoDownload: false,
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
};
// --- App State ---
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from JSON file */
/** Load app state from config.json */
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;
}
try {
const cfg = await loadConfig();
if (!cfg || typeof cfg !== "object") return defaultState;
return {
settings: { ...defaultSettings, ...cfg.settings },
preferences: { ...defaultPreferences, ...cfg.preferences },
customTheme: { ...DEFAULT_THEME, ...cfg.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
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
preferences: state.preferences,
customTheme: state.customTheme,
});
}
// ── Playback Progress (separate file — changes on every seek) ───────────────
const PROGRESS_FILE = "progress.json";
interface ProgressEntry {
episodeId: string;
position: number;
duration: number;
timestamp: string | Date;
playbackSpeed?: number;
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>
Record<string, ProgressEntry>
> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return {};
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 {};
}
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
}
/** Save progress map to JSON file (overwrite, no backup) */
export function saveProgressToFile(data: Record<string, unknown>): void {
(async () => {
try {
await ensureConfigDir();
await Bun.write(
getConfigFilePath(PROGRESS_FILE),
JSON.stringify(data, null, 2),
);
} catch {
// Silently ignore write errors
}
})();
}
interface AudioNavEntry {
source: string;
currentIndex: number;
podcastId?: string;
lastUpdated: string;
}
// ── Audio Nav State (separate file — changes on every track change) ──────────
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return null;
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
if (!(await file.exists())) return null;
const raw = await file.json();
if (!raw || typeof raw !== "object") return null;
const raw = await file.json();
if (!raw || typeof raw !== "object") return null;
return raw as T;
} catch {
return null;
}
return raw as T;
} catch {
return null;
}
}
/** Save audio navigation state to JSON file */
export async function saveAudioNavToFile<T>(
data: T,
): Promise<void> {
try {
await ensureConfigDir();
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
await Bun.write(filePath, JSON.stringify(data, null, 2));
} catch {
// Silently ignore write errors
}
/** Save audio navigation state to JSON file (overwrite, no backup) */
export function saveAudioNavToFile<T>(data: T): void {
(async () => {
try {
await ensureConfigDir();
await Bun.write(
getConfigFilePath(AUDIO_NAV_FILE),
JSON.stringify(data, null, 2),
);
} catch {
// Silently ignore write errors
}
})();
}

View File

@@ -1,96 +0,0 @@
/**
* 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 []
}
}

187
src/utils/config.ts Normal file
View File

@@ -0,0 +1,187 @@
/**
* Centralized PodTui configuration — a single `config.json` holding every
* user-facing bit needed to migrate to a new machine by copying one file.
*
* Contains: settings, preferences, custom theme, feeds (subscriptions), and
* sources (podcast search/RSS sources).
*
* Runtime state that changes on every playback action (progress, downloads,
* audio-nav) stays in separate files to avoid rewriting this file on every
* seek. Keybinds remain in `keybinds.jsonc` (user-editable JSONC).
*
* Writes are serialized to avoid concurrent read-modify-write races, and
* always overwrite — no backup files are created.
*/
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
import type {
AppSettings,
UserPreferences,
ThemeColors,
} from "../types/settings";
import type { Feed } from "../types/feed";
import type { PodcastSource } from "../types/source";
/** Everything a user needs to migrate, in one file. */
export interface PodTuiConfig {
settings?: AppSettings;
preferences?: UserPreferences;
customTheme?: ThemeColors;
feeds?: Feed[];
sources?: PodcastSource[];
}
const CONFIG_FILE = "config.json";
/** Legacy per-section files, migrated into config.json on first load. */
const LEGACY_FILES = ["app-state.json", "feeds.json", "sources.json"] as const;
/** Load the full config from disk. Returns {} if missing or corrupt.
* Runs one-time legacy migration on first call. */
export async function loadConfig(): Promise<PodTuiConfig> {
await migrateOnce();
try {
const file = Bun.file(getConfigFilePath(CONFIG_FILE));
if (!(await file.exists())) return {};
const raw = await file.json();
if (!raw || typeof raw !== "object") return {};
return raw as PodTuiConfig;
} catch {
return {};
}
}
// ── Write serialization ────────────────────────────────────────────────────
// A simple promise chain ensures reads-modify-writes execute sequentially so
// two concurrent saves can't clobber each other's sections.
let writeChain: Promise<void> = Promise.resolve();
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
export function updateConfig(patch: Partial<PodTuiConfig>): void {
writeChain = writeChain.then(async () => {
try {
await ensureConfigDir();
const current = await loadConfig();
const next = { ...current, ...patch };
await Bun.write(
getConfigFilePath(CONFIG_FILE),
JSON.stringify(next, null, 2),
);
} catch {
// Fire-and-forget persistence — silently ignore write errors.
}
});
}
/** Await all pending config writes (used by sync/export flows). */
export async function flushConfig(): Promise<void> {
await writeChain;
}
/** Guards so migration runs exactly once per process. */
let migrationDone = false;
let migrationPromise: Promise<void> | null = null;
/** Run legacy migration + backup cleanup once, before the first config read. */
async function migrateOnce(): Promise<void> {
if (migrationDone) return;
if (!migrationPromise) migrationPromise = migrateLegacyConfig();
await migrationPromise;
migrationDone = true;
}
/**
* One-time migration: if config.json doesn't exist but legacy per-section
* files do, merge them into a single config.json. Also cleans up any stale
* backup files (`.backup` suffix) left by the old config-backup module.
*
* Safe to call on every startup — no-op once config.json exists (except for
* backup cleanup, which runs unconditionally since those files are now dead).
*/
export async function migrateLegacyConfig(): Promise<void> {
try {
await ensureConfigDir();
const dir = getConfigDir();
const configExists = await Bun.file(
getConfigFilePath(CONFIG_FILE),
).exists();
if (!configExists) {
const merged: PodTuiConfig = {};
// app-state.json → settings, preferences, customTheme
const appStateFile = Bun.file(getConfigFilePath("app-state.json"));
if (await appStateFile.exists()) {
try {
const raw = await appStateFile.json();
if (raw && typeof raw === "object") {
merged.settings = raw.settings;
merged.preferences = raw.preferences;
merged.customTheme = raw.customTheme;
}
} catch {
// ignore corrupt legacy file
}
}
// feeds.json → feeds
const feedsFile = Bun.file(getConfigFilePath("feeds.json"));
if (await feedsFile.exists()) {
try {
const raw = await feedsFile.json();
if (Array.isArray(raw)) merged.feeds = raw;
} catch {
// ignore
}
}
// sources.json → sources
const sourcesFile = Bun.file(getConfigFilePath("sources.json"));
if (await sourcesFile.exists()) {
try {
const raw = await sourcesFile.json();
if (Array.isArray(raw)) merged.sources = raw;
} catch {
// ignore
}
}
if (Object.keys(merged).length > 0) {
await Bun.write(
getConfigFilePath(CONFIG_FILE),
JSON.stringify(merged, null, 2),
);
// Remove migrated legacy files
for (const name of LEGACY_FILES) {
await Bun.file(getConfigFilePath(name))
.exists()
.then(async (exists) => {
if (exists)
await import("fs/promises").then((fs) =>
fs.unlink(getConfigFilePath(name)).catch(() => {}),
);
});
}
}
}
// Clean up stale backup files (no longer created, remove old ones)
await cleanBackups(dir);
} catch {
// Migration is best-effort — never block startup.
}
}
/** Remove all `.backup` files from the config directory. */
async function cleanBackups(dir: string): Promise<void> {
try {
const { readdir, unlink } = await import("fs/promises");
const entries = await readdir(dir);
const backups = entries.filter((e) => e.endsWith(".backup"));
for (const name of backups) {
await unlink(`${dir}/${name}`).catch(() => {});
}
} catch {
// ignore
}
}

View File

@@ -105,8 +105,6 @@ export type AppEvents = {
"player.play": { episodeId: string };
"player.pause": { episodeId: string };
"player.stop": {};
"auth.login": { userId: string };
"auth.logout": {};
"toast.show": {
message: string;
variant: "info" | "success" | "warning" | "error";

View File

@@ -1,82 +1,56 @@
/**
* Feeds persistence via JSON file in XDG_CONFIG_HOME
*
* Reads and writes feeds to a JSON file instead of localStorage.
* Feeds & sources persistence — stored in the centralized `config.json`
* (see utils/config.ts). No backups; writes always overwrite.
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup";
import { loadConfig, updateConfig } from "./config";
import type { Feed } from "../types/feed";
const FEEDS_FILE = "feeds.json";
const SOURCES_FILE = "sources.json";
import type { PodcastSource } from "../types/source";
/** 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),
})),
};
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 */
/** Load feeds from config.json */
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 [];
}
try {
const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return [];
return cfg.feeds.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
}
/** Save feeds to config.json */
export function saveFeedsToFile(feeds: Feed[]): void {
updateConfig({ feeds });
}
/** Load sources from JSON file */
/** Load sources from config.json */
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;
}
try {
const cfg = await loadConfig();
if (!Array.isArray(cfg.sources)) return null;
return cfg.sources 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
}
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] });
}

View File

@@ -1,77 +0,0 @@
import type { AppSettings, UserPreferences } from "../types/settings"
import type { Feed } from "../types/feed"
const STORAGE_KEYS = {
settings: "podtui_settings",
preferences: "podtui_preferences",
feeds: "podtui_feeds",
}
export const savePreference = (key: keyof UserPreferences, value: boolean) => {
const current = loadPreferences()
const next = { ...current, [key]: value }
savePreferences(next)
}
export const loadPreference = (key: keyof UserPreferences) => {
return loadPreferences()[key]
}
export const saveSettings = (settings: AppSettings) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings))
} catch {
// ignore
}
}
export const loadSettings = (): AppSettings | null => {
if (typeof localStorage === "undefined") return null
try {
const raw = localStorage.getItem(STORAGE_KEYS.settings)
return raw ? (JSON.parse(raw) as AppSettings) : null
} catch {
return null
}
}
export const savePreferences = (preferences: UserPreferences) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.preferences, JSON.stringify(preferences))
} catch {
// ignore
}
}
export const loadPreferences = (): UserPreferences => {
if (typeof localStorage === "undefined") {
return { showExplicit: false, autoDownload: false }
}
try {
const raw = localStorage.getItem(STORAGE_KEYS.preferences)
return raw ? (JSON.parse(raw) as UserPreferences) : { showExplicit: false, autoDownload: false }
} catch {
return { showExplicit: false, autoDownload: false }
}
}
export const saveFeeds = (feeds: Feed[]) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
} catch {
// ignore
}
}
export const loadFeeds = (): Feed[] => {
if (typeof localStorage === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEYS.feeds)
return raw ? (JSON.parse(raw) as Feed[]) : []
} catch {
return []
}
}