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 { createSignal } from "solid-js";
|
||||||
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
||||||
|
import {
|
||||||
|
loadSearchHistoryFromFile,
|
||||||
|
saveSearchHistoryToFile,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult, SearchScope } from "../types/source";
|
import type { SearchResult, SearchScope } from "../types/source";
|
||||||
|
|
||||||
const STORAGE_KEY = "podtui_search_history";
|
|
||||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||||
const MAX_HISTORY = 20;
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -21,25 +24,19 @@ export interface SearchState {
|
|||||||
|
|
||||||
const CACHE_TTL = 1000 * 60 * 5;
|
const CACHE_TTL = 1000 * 60 * 5;
|
||||||
|
|
||||||
/** Load search history from localStorage */
|
/** Normalize raw history: drop blanks, dedupe case-insensitively (newest
|
||||||
function loadHistory(): string[] {
|
* wins), cap at MAX_HISTORY. */
|
||||||
if (typeof localStorage === "undefined") return [];
|
function sanitizeHistory(items: string[]): string[] {
|
||||||
try {
|
const seen = new Set<string>();
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const cleaned: string[] = [];
|
||||||
return stored ? JSON.parse(stored) : [];
|
for (const item of items) {
|
||||||
} catch {
|
const trimmed = item.trim();
|
||||||
return [];
|
const key = trimmed.toLowerCase();
|
||||||
}
|
if (!key || seen.has(key)) continue;
|
||||||
}
|
seen.add(key);
|
||||||
|
cleaned.push(trimmed);
|
||||||
/** 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
|
|
||||||
}
|
}
|
||||||
|
return cleaned.slice(0, MAX_HISTORY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
||||||
@@ -70,10 +67,19 @@ export function createSearchStore() {
|
|||||||
const [isSearching, setIsSearching] = createSignal(false);
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
const [history, setHistory] = createSignal<string[]>([]);
|
||||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
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. */
|
/** Set the search scope (shows vs episodes) and persist it. */
|
||||||
const setScope = (next: SearchScope) => {
|
const setScope = (next: SearchScope) => {
|
||||||
setScopeState(next);
|
setScopeState(next);
|
||||||
@@ -164,9 +170,8 @@ export function createSearchStore() {
|
|||||||
/** Add query to history */
|
/** Add query to history */
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
const updated = sanitizeHistory([q, ...prev]);
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
saveSearchHistoryToFile(updated);
|
||||||
saveHistory(updated);
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -174,14 +179,14 @@ export function createSearchStore() {
|
|||||||
/** Clear search history */
|
/** Clear search history */
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
saveHistory([]);
|
saveSearchHistoryToFile([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove single history item */
|
/** Remove single history item */
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((h) => h !== q);
|
const updated = prev.filter((h) => h !== q);
|
||||||
saveHistory(updated);
|
saveSearchHistoryToFile(updated);
|
||||||
return 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 {
|
return {
|
||||||
// State
|
// State
|
||||||
query,
|
query,
|
||||||
@@ -232,6 +258,7 @@ export function createSearchStore() {
|
|||||||
setSelectedSources,
|
setSelectedSources,
|
||||||
setScope,
|
setScope,
|
||||||
markSubscribed,
|
markSubscribed,
|
||||||
|
markUnsubscribed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* No backups — writes always overwrite.
|
* 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 { loadConfig, updateConfig } from "./config";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -45,6 +46,7 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "manual",
|
||||||
|
refreshIntervalMinutes: 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
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) ──────────
|
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
||||||
|
|
||||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
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