diff --git a/src/stores/search.ts b/src/stores/search.ts index 75ab42e..ff0ee60 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -5,12 +5,15 @@ import { createSignal } from "solid-js"; import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search"; +import { + loadSearchHistoryFromFile, + saveSearchHistoryToFile, +} from "../utils/app-persistence"; import { useFeedStore } from "./feed"; import type { SearchResult, SearchScope } from "../types/source"; -const STORAGE_KEY = "podtui_search_history"; const STORAGE_SCOPE_KEY = "podtui_search_scope"; -const MAX_HISTORY = 20; +const MAX_HISTORY = 10; export interface SearchState { query: string; @@ -21,25 +24,19 @@ export interface SearchState { const CACHE_TTL = 1000 * 60 * 5; -/** Load search history from localStorage */ -function loadHistory(): string[] { - if (typeof localStorage === "undefined") return []; - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : []; - } catch { - return []; - } -} - -/** Save search history to localStorage */ -function saveHistory(history: string[]): void { - if (typeof localStorage === "undefined") return; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); - } catch { - // Ignore errors +/** Normalize raw history: drop blanks, dedupe case-insensitively (newest + * wins), cap at MAX_HISTORY. */ +function sanitizeHistory(items: string[]): string[] { + const seen = new Set(); + const cleaned: string[] = []; + for (const item of items) { + const trimmed = item.trim(); + const key = trimmed.toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + cleaned.push(trimmed); } + return cleaned.slice(0, MAX_HISTORY); } /** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */ @@ -70,10 +67,19 @@ export function createSearchStore() { const [isSearching, setIsSearching] = createSignal(false); const [results, setResults] = createSignal([]); const [error, setError] = createSignal(null); - const [history, setHistory] = createSignal(loadHistory()); + const [history, setHistory] = createSignal([]); const [selectedSources, setSelectedSources] = createSignal([]); const [scope, setScopeState] = createSignal(loadScope()); + /** Load search history from file (fire-and-forget; recents appear as + * soon as the file is read). */ + async function init(): Promise { + const loaded = await loadSearchHistoryFromFile(); + if (loaded.length > 0) setHistory(sanitizeHistory(loaded)); + } + + init(); + /** Set the search scope (shows vs episodes) and persist it. */ const setScope = (next: SearchScope) => { setScopeState(next); @@ -164,9 +170,8 @@ export function createSearchStore() { /** Add query to history */ const addToHistory = (q: string) => { setHistory((prev) => { - const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()); - const updated = [q, ...filtered].slice(0, MAX_HISTORY); - saveHistory(updated); + const updated = sanitizeHistory([q, ...prev]); + saveSearchHistoryToFile(updated); return updated; }); }; @@ -174,14 +179,14 @@ export function createSearchStore() { /** Clear search history */ const clearHistory = () => { setHistory([]); - saveHistory([]); + saveSearchHistoryToFile([]); }; /** Remove single history item */ const removeFromHistory = (q: string) => { setHistory((prev) => { const updated = prev.filter((h) => h !== q); - saveHistory(updated); + saveSearchHistoryToFile(updated); return updated; }); }; @@ -213,6 +218,27 @@ export function createSearchStore() { ); }; + /** Mark a podcast as unsubscribed in results (after an in-place + * unsubscribe from the results list). */ + const markUnsubscribed = (podcastId: string, feedUrl?: string) => { + setResults((prev) => + prev.map((result) => { + const matchesId = result.podcast.id === podcastId; + const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false; + if (matchesId || matchesUrl) { + return { + ...result, + podcast: { + ...result.podcast, + isSubscribed: false, + }, + }; + } + return result; + }), + ); + }; + return { // State query, @@ -232,6 +258,7 @@ export function createSearchStore() { setSelectedSources, setScope, markSubscribed, + markUnsubscribed, }; } diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index bd87ab9..90b353a 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -7,7 +7,8 @@ * No backups — writes always overwrite. */ -import { ensureConfigDir, getConfigFilePath } from "./config-dir"; +import { mkdirSync, writeFileSync } from "fs"; +import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir"; import { loadConfig, updateConfig } from "./config"; import type { AppState, @@ -45,6 +46,7 @@ const defaultPreferences: UserPreferences = { autoDownloadWhitelist: [], autoJumpToPlayer: true, fetchMoreMode: "manual", + refreshIntervalMinutes: 15, }; const defaultState: AppState = { @@ -123,6 +125,39 @@ export function saveProgressToFile(data: Record): void { })(); } +// ── Search History (separate file — changes on every search) ──────────────── + +const SEARCH_HISTORY_FILE = "search-history.json"; + +/** Load search history from JSON file */ +export async function loadSearchHistoryFromFile(): Promise { + try { + const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE)); + if (!(await file.exists())) return []; + + const raw = await file.json(); + if (!Array.isArray(raw)) return []; + return raw.filter((item): item is string => typeof item === "string"); + } catch { + return []; + } +} + +/** Save search history to JSON file (overwrite, no backup) */ +export function saveSearchHistoryToFile(history: string[]): void { + (async () => { + try { + await ensureConfigDir(); + await Bun.write( + getConfigFilePath(SEARCH_HISTORY_FILE), + JSON.stringify(history, null, 2), + ); + } catch { + // Silently ignore write errors + } + })(); +} + // ── Audio Nav State (separate file — changes on every track change) ────────── const AUDIO_NAV_FILE = "audio-nav.json"; @@ -156,3 +191,60 @@ export function saveAudioNavToFile(data: T): void { } })(); } + +// ── Last Player State (separate file — written on every load/stop) ────────── + +const LAST_PLAYER_FILE = "last-player.json"; + +/** Which episode is currently loaded in the player, persisted so the next + * launch can restore it paused. `episodeId: null` means the player is empty + * (e.g. after Stop). */ +export interface LastPlayerState { + episodeId: string | null; + timestamp: string | Date | null; +} + +/** Load the last-loaded-player marker (null when absent or unreadable) */ +export async function loadLastPlayerFromFile(): Promise { + try { + const file = Bun.file(getConfigFilePath(LAST_PLAYER_FILE)); + if (!(await file.exists())) return null; + + const raw = await file.json(); + if (!raw || typeof raw !== "object") return null; + + return raw as LastPlayerState; + } catch { + return null; + } +} + +/** Save the last-loaded-player marker (overwrite, fire-and-forget) */ +export function saveLastPlayerToFile(state: LastPlayerState): void { + (async () => { + try { + await ensureConfigDir(); + await Bun.write( + getConfigFilePath(LAST_PLAYER_FILE), + JSON.stringify(state, null, 2), + ); + } catch { + // Silently ignore write errors + } + })(); +} + +/** Synchronous variant for the process-exit teardown. `q` quits through + * `process.exit(0)`, which runs exit listeners synchronously — an async + * write would never land. */ +export function saveLastPlayerSync(state: LastPlayerState): void { + try { + mkdirSync(getConfigDir(), { recursive: true }); + writeFileSync( + getConfigFilePath(LAST_PLAYER_FILE), + JSON.stringify(state, null, 2), + ); + } catch { + // Silently ignore write errors + } +}