feat(search): persist recent searches between sessions

History used localStorage, which never exists in the Bun TUI, so nothing
survived a restart. Store the 10 most recent queries in config-dir
search-history.json (same fire-and-forget file pattern as audio-nav.json),
loaded asynchronously at store init. Dedupe case-insensitively, cap at 10.
This commit is contained in:
2026-08-11 12:45:43 -04:00
parent 2bf1c229c7
commit df9c519439
2 changed files with 146 additions and 27 deletions

View File

@@ -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<string>();
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<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [history, setHistory] = createSignal<string[]>([]);
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** Load search history from file (fire-and-forget; recents appear as
* soon as the file is read). */
async function init(): Promise<void> {
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,
};
}

View File

@@ -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<string, unknown>): 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<string[]> {
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<T>(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<LastPlayerState | null> {
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
}
}