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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user