remove migration code

This commit is contained in:
2026-02-06 15:00:21 -05:00
parent 1e3b794b8e
commit 75f1f7d6af
7 changed files with 482 additions and 623 deletions

View File

@@ -1,13 +1,20 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes" import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences, VisualizerSettings } from "../types/settings" import type {
import { resolveTheme } from "../utils/theme-resolver" AppSettings,
import type { ThemeJson } from "../types/theme-schema" AppState,
ThemeColors,
ThemeName,
ThemeMode,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { resolveTheme } from "../utils/theme-resolver";
import type { ThemeJson } from "../types/theme-schema";
import { import {
loadAppStateFromFile, loadAppStateFromFile,
saveAppStateToFile, saveAppStateToFile,
migrateAppStateFromLocalStorage, } from "../utils/app-persistence";
} from "../utils/app-persistence"
const defaultVisualizerSettings: VisualizerSettings = { const defaultVisualizerSettings: VisualizerSettings = {
bars: 32, bars: 32,
@@ -15,7 +22,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
} };
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
@@ -23,82 +30,84 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
} };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
} };
const defaultState: AppState = { const defaultState: AppState = {
settings: defaultSettings, settings: defaultSettings,
preferences: defaultPreferences, preferences: defaultPreferences,
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
} };
export function createAppStore() { export function createAppStore() {
// Start with defaults; async load will update once ready // Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState) const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation // Fire-and-forget async initialisation
const init = async () => { const init = async () => {
await migrateAppStateFromLocalStorage() const loaded = await loadAppStateFromFile();
const loaded = await loadAppStateFromFile() setState(loaded);
setState(loaded) };
} init();
init()
const saveState = (next: AppState) => { const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {}) saveAppStateToFile(next).catch(() => {});
} };
const updateState = (next: AppState) => { const updateState = (next: AppState) => {
setState(next) setState(next);
saveState(next) saveState(next);
} };
const updateSettings = (updates: Partial<AppSettings>) => { const updateSettings = (updates: Partial<AppSettings>) => {
const next = { const next = {
...state(), ...state(),
settings: { ...state().settings, ...updates }, settings: { ...state().settings, ...updates },
} };
updateState(next) updateState(next);
} };
const updatePreferences = (updates: Partial<UserPreferences>) => { const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = { const next = {
...state(), ...state(),
preferences: { ...state().preferences, ...updates }, preferences: { ...state().preferences, ...updates },
} };
updateState(next) updateState(next);
} };
const updateCustomTheme = (updates: Partial<ThemeColors>) => { const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = { const next = {
...state(), ...state(),
customTheme: { ...state().customTheme, ...updates }, customTheme: { ...state().customTheme, ...updates },
} };
updateState(next) updateState(next);
} };
const updateVisualizer = (updates: Partial<VisualizerSettings>) => { const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({ updateSettings({
visualizer: { ...state().settings.visualizer, ...updates }, visualizer: { ...state().settings.visualizer, ...updates },
}) });
} };
const setTheme = (theme: ThemeName) => { const setTheme = (theme: ThemeName) => {
updateSettings({ theme }) updateSettings({ theme });
} };
const resolveThemeColors = (): ThemeColors => { const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme const theme = state().settings.theme;
if (theme === "custom") return state().customTheme if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme] const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME if (!json) return DEFAULT_THEME;
return resolveTheme(json as ThemeJson, "dark" as ThemeMode) as unknown as ThemeColors return resolveTheme(
} json as ThemeJson,
"dark" as ThemeMode,
) as unknown as ThemeColors;
};
return { return {
state, state,
@@ -108,14 +117,14 @@ export function createAppStore() {
updateVisualizer, updateVisualizer,
setTheme, setTheme,
resolveTheme: resolveThemeColors, resolveTheme: resolveThemeColors,
} };
} }
let appStoreInstance: ReturnType<typeof createAppStore> | null = null let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
export function useAppStore() { export function useAppStore() {
if (!appStoreInstance) { if (!appStoreInstance) {
appStoreInstance = createAppStore() appStoreInstance = createAppStore();
} }
return appStoreInstance return appStoreInstance;
} }

View File

@@ -3,174 +3,193 @@
* Manages feed data, sources, and filtering * Manages feed data, sources, and filtering
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { FeedVisibility } from "../types/feed" import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed" import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast" import type { Podcast } from "../types/podcast";
import type { Episode, EpisodeStatus } from "../types/episode" import type { Episode, EpisodeStatus } from "../types/episode";
import type { PodcastSource, SourceType } from "../types/source" import type { PodcastSource, SourceType } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source" import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser" import { parseRSSFeed } from "../api/rss-parser";
import { import {
loadFeedsFromFile, loadFeedsFromFile,
saveFeedsToFile, saveFeedsToFile,
loadSourcesFromFile, loadSourcesFromFile,
saveSourcesToFile, saveSourcesToFile,
migrateFeedsFromLocalStorage, } from "../utils/feeds-persistence";
migrateSourcesFromLocalStorage, import { useDownloadStore } from "./download";
} from "../utils/feeds-persistence" import { DownloadStatus } from "../types/episode";
import { useDownloadStore } from "./download"
import { DownloadStatus } from "../types/episode"
/** Max episodes to load per page/chunk */ /** Max episodes to load per page/chunk */
const MAX_EPISODES_REFRESH = 50 const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */ /** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20 const MAX_EPISODES_SUBSCRIBE = 20;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */ /** Cache of all parsed episodes per feed (feedId -> Episode[]) */
const fullEpisodeCache = new Map<string, Episode[]>() const fullEpisodeCache = new Map<string, Episode[]>();
/** Track how many episodes are currently loaded per feed */ /** Track how many episodes are currently loaded per feed */
const episodeLoadCount = new Map<string, number>() const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */ /** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void { function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds).catch(() => {}) saveFeedsToFile(feeds).catch(() => {});
} }
/** Save sources to file (async, fire-and-forget) */ /** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void { function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources).catch(() => {}) saveSourcesToFile(sources).catch(() => {});
} }
/** Create feed store */ /** Create feed store */
export function createFeedStore() { export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]) const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES]) const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES,
]);
// Async initialization: migrate from localStorage, then load from file (async () => {
;(async () => { const loadedFeeds = await loadFeedsFromFile();
await migrateFeedsFromLocalStorage() if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
await migrateSourcesFromLocalStorage() const loadedSources = await loadSourcesFromFile<PodcastSource>();
const loadedFeeds = await loadFeedsFromFile() if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
if (loadedFeeds.length > 0) setFeeds(loadedFeeds) })();
const loadedSources = await loadSourcesFromFile<PodcastSource>()
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
})()
const [filter, setFilter] = createSignal<FeedFilter>({ const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all", visibility: "all",
sortBy: "updated" as FeedSortField, sortBy: "updated" as FeedSortField,
sortDirection: "desc", sortDirection: "desc",
}) });
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null) const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false) const [isLoadingMore, setIsLoadingMore] = createSignal(false);
/** Get filtered and sorted feeds */ /** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => { const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()] let result = [...feeds()];
const f = filter() const f = filter();
// Filter by visibility // Filter by visibility
if (f.visibility && f.visibility !== "all") { if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility) result = result.filter((feed) => feed.visibility === f.visibility);
} }
// Filter by source // Filter by source
if (f.sourceId) { if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId) result = result.filter((feed) => feed.sourceId === f.sourceId);
} }
// Filter by pinned // Filter by pinned
if (f.pinnedOnly) { if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned) result = result.filter((feed) => feed.isPinned);
} }
// Filter by search query // Filter by search query
if (f.searchQuery) { if (f.searchQuery) {
const query = f.searchQuery.toLowerCase() const query = f.searchQuery.toLowerCase();
result = result.filter( result = result.filter(
(feed) => (feed) =>
feed.podcast.title.toLowerCase().includes(query) || feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) || feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query) feed.podcast.description?.toLowerCase().includes(query),
) );
} }
// Sort by selected field // Sort by selected field
const sortDir = f.sortDirection === "asc" ? 1 : -1 const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => { result.sort((a, b) => {
switch (f.sortBy) { switch (f.sortBy) {
case "title": case "title":
return sortDir * (a.customName || a.podcast.title).localeCompare(b.customName || b.podcast.title) return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title,
)
);
case "episodeCount": case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length) return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode": case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0 const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0 const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest) return sortDir * (aLatest - bLatest);
case "updated": case "updated":
default: default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()) return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
} }
}) });
// Pinned feeds always first // Pinned feeds always first
result.sort((a, b) => { result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1 if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1 if (!a.isPinned && b.isPinned) return 1;
return 0 return 0;
}) });
return result return result;
} };
/** Get episodes in reverse chronological order across all feeds */ /** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => { const getAllEpisodesChronological = (): Array<{
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [] episode: Episode;
feed: Feed;
}> => {
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
for (const feed of feeds()) { for (const feed of feeds()) {
for (const episode of feed.episodes) { for (const episode of feed.episodes) {
allEpisodes.push({ episode, feed }) allEpisodes.push({ episode, feed });
} }
} }
// Sort by publication date (newest first) // Sort by publication date (newest first)
allEpisodes.sort((a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime()) allEpisodes.sort(
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
);
return allEpisodes return allEpisodes;
} };
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise<Episode[]> => { const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[]> => {
try { try {
const response = await fetch(feedUrl, { const response = await fetch(feedUrl, {
headers: { headers: {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
"Accept": "application/rss+xml, application/xml, text/xml, */*", Accept: "application/rss+xml, application/xml, text/xml, */*",
}, },
}) });
if (!response.ok) return [] if (!response.ok) return [];
const xml = await response.text() const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl) const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = parsed.episodes const allEpisodes = parsed.episodes;
// Cache all parsed episodes for pagination // Cache all parsed episodes for pagination
if (feedId) { if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes) fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)) episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
} }
return allEpisodes.slice(0, limit) return allEpisodes.slice(0, limit);
} catch { } catch {
return [] return [];
} }
} };
/** Add a new feed and auto-fetch latest 20 episodes */ /** Add a new feed and auto-fetch latest 20 episodes */
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => { const addFeed = async (
const feedId = crypto.randomUUID() podcast: Podcast,
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId) sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => {
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
const newFeed: Feed = { const newFeed: Feed = {
id: feedId, id: feedId,
podcast, podcast,
@@ -179,220 +198,238 @@ export function createFeedStore() {
sourceId, sourceId,
lastUpdated: new Date(), lastUpdated: new Date(),
isPinned: false, isPinned: false,
} };
setFeeds((prev) => { setFeeds((prev) => {
const updated = [...prev, newFeed] const updated = [...prev, newFeed];
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
return newFeed return newFeed;
} };
/** Auto-download newest episodes for a feed */ /** Auto-download newest episodes for a feed */
const autoDownloadEpisodes = (feedId: string, newEpisodes: Episode[], count: number) => { const autoDownloadEpisodes = (
feedId: string,
newEpisodes: Episode[],
count: number,
) => {
try { try {
const dlStore = useDownloadStore() const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first) // Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort( const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime() (a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
) );
// count = 0 means download all new episodes // count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) { for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id) const status = dlStore.getDownloadStatus(ep.id);
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) { if (
dlStore.startDownload(ep, feedId) status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
} }
} }
} catch { } catch {
// Download store may not be available yet // Download store may not be available yet
} }
} };
/** Refresh a single feed - re-fetch latest 50 episodes */ /** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => { const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId) const feed = getFeed(feedId);
if (!feed) return if (!feed) return;
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)) const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId) const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
// Auto-download new episodes if enabled for this feed // Auto-download new episodes if enabled for this feed
if (feed.autoDownload) { if (feed.autoDownload) {
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)) const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
if (newEpisodes.length > 0) { if (newEpisodes.length > 0) {
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0) autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
} }
} }
} };
/** Refresh all feeds */ /** Refresh all feeds */
const refreshAllFeeds = async () => { const refreshAllFeeds = async () => {
const currentFeeds = feeds() const currentFeeds = feeds();
for (const feed of currentFeeds) { for (const feed of currentFeeds) {
await refreshFeed(feed.id) await refreshFeed(feed.id);
} }
} };
/** Remove a feed */ /** Remove a feed */
const removeFeed = (feedId: string) => { const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId) fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId) episodeLoadCount.delete(feedId);
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.filter((f) => f.id !== feedId) const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} };
/** Update a feed */ /** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => { const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} };
/** Toggle feed pinned status */ /** Toggle feed pinned status */
const togglePinned = (feedId: string) => { const togglePinned = (feedId: string) => {
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} };
/** Add a source */ /** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => { const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = { const newSource: PodcastSource = {
...source, ...source,
id: crypto.randomUUID(), id: crypto.randomUUID(),
} };
setSources((prev) => { setSources((prev) => {
const updated = [...prev, newSource] const updated = [...prev, newSource];
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
return newSource return newSource;
} };
/** Update a source */ /** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => { const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => { setSources((prev) => {
const updated = prev.map((source) => const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source source.id === sourceId ? { ...source, ...updates } : source,
) );
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
} };
/** Remove a source */ /** Remove a source */
const removeSource = (sourceId: string) => { const removeSource = (sourceId: string) => {
// Don't remove default sources // Don't remove default sources
if (sourceId === "itunes" || sourceId === "rss") return false if (sourceId === "itunes" || sourceId === "rss") return false;
setSources((prev) => { setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId) const updated = prev.filter((s) => s.id !== sourceId);
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
return true return true;
} };
/** Toggle source enabled status */ /** Toggle source enabled status */
const toggleSource = (sourceId: string) => { const toggleSource = (sourceId: string) => {
setSources((prev) => { setSources((prev) => {
const updated = prev.map((s) => const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
) );
saveSources(updated) saveSources(updated);
return updated return updated;
}) });
} };
/** Get feed by ID */ /** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => { const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId) return feeds().find((f) => f.id === feedId);
} };
/** Get selected feed */ /** Get selected feed */
const getSelectedFeed = (): Feed | undefined => { const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId() const id = selectedFeedId();
return id ? getFeed(id) : undefined return id ? getFeed(id) : undefined;
} };
/** Check if a feed has more episodes available beyond what's currently loaded */ /** Check if a feed has more episodes available beyond what's currently loaded */
const hasMoreEpisodes = (feedId: string): boolean => { const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId) const cached = fullEpisodeCache.get(feedId);
if (!cached) return false if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0 const loaded = episodeLoadCount.get(feedId) ?? 0;
return loaded < cached.length return loaded < cached.length;
} };
/** Load the next chunk of episodes for a feed from the cache. /** Load the next chunk of episodes for a feed from the cache.
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
const loadMoreEpisodes = async (feedId: string) => { const loadMoreEpisodes = async (feedId: string) => {
if (isLoadingMore()) return if (isLoadingMore()) return;
const feed = getFeed(feedId) const feed = getFeed(feedId);
if (!feed) return if (!feed) return;
setIsLoadingMore(true) setIsLoadingMore(true);
try { try {
let cached = fullEpisodeCache.get(feedId) let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed // If no cache, re-fetch and parse the full feed
if (!cached) { if (!cached) {
const response = await fetch(feed.podcast.feedUrl, { const response = await fetch(feed.podcast.feedUrl, {
headers: { headers: {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
"Accept": "application/rss+xml, application/xml, text/xml, */*", Accept: "application/rss+xml, application/xml, text/xml, */*",
}, },
}) });
if (!response.ok) return if (!response.ok) return;
const xml = await response.text() const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl) const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached) fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed // Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length) episodeLoadCount.set(feedId, feed.episodes.length);
} }
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(currentCount + MAX_EPISODES_REFRESH, cached.length) const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
);
if (newCount <= currentCount) return // nothing more to load if (newCount <= currentCount) return; // nothing more to load
episodeLoadCount.set(feedId, newCount) episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount) const episodes = cached.slice(0, newCount);
setFeeds((prev) => { setFeeds((prev) => {
const updated = prev.map((f) => const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f f.id === feedId ? { ...f, episodes } : f,
) );
saveFeeds(updated) saveFeeds(updated);
return updated return updated;
}) });
} finally { } finally {
setIsLoadingMore(false) setIsLoadingMore(false);
} }
} };
/** Set auto-download settings for a feed */ /** Set auto-download settings for a feed */
const setAutoDownload = (feedId: string, enabled: boolean, count: number = 0) => { const setAutoDownload = (
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count }) feedId: string,
} enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
};
return { return {
// State // State
@@ -401,14 +438,14 @@ export function createFeedStore() {
filter, filter,
selectedFeedId, selectedFeedId,
isLoadingMore, isLoadingMore,
// Computed // Computed
getFilteredFeeds, getFilteredFeeds,
getAllEpisodesChronological, getAllEpisodesChronological,
getFeed, getFeed,
getSelectedFeed, getSelectedFeed,
hasMoreEpisodes, hasMoreEpisodes,
// Actions // Actions
setFilter, setFilter,
setSelectedFeedId, setSelectedFeedId,
@@ -424,15 +461,15 @@ export function createFeedStore() {
toggleSource, toggleSource,
updateSource, updateSource,
setAutoDownload, setAutoDownload,
} };
} }
/** Singleton feed store */ /** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() { export function useFeedStore() {
if (!feedStoreInstance) { if (!feedStoreInstance) {
feedStoreInstance = createFeedStore() feedStoreInstance = createFeedStore();
} }
return feedStoreInstance return feedStoreInstance;
} }

View File

@@ -5,55 +5,56 @@
* Tracks position, duration, completion, and last-played timestamp. * Tracks position, duration, completion, and last-played timestamp.
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import type { Progress } from "../types/episode" import type { Progress } from "../types/episode";
import { import {
loadProgressFromFile, loadProgressFromFile,
saveProgressToFile, saveProgressToFile,
migrateProgressFromLocalStorage, } from "../utils/app-persistence";
} from "../utils/app-persistence"
/** Threshold (fraction 0-1) at which an episode is considered completed */ /** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95 const COMPLETION_THRESHOLD = 0.95;
/** Minimum seconds of progress before persisting */ /** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5 const MIN_POSITION_TO_SAVE = 5;
// --- Singleton store --- // --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>({}) const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
{},
);
/** Persist current progress map to file (fire-and-forget) */ /** Persist current progress map to file (fire-and-forget) */
function persist(): void { function persist(): void {
saveProgressToFile(progressMap()).catch(() => {}) saveProgressToFile(progressMap()).catch(() => {});
} }
/** Parse raw progress entries from file, reviving Date objects */ /** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> { function parseProgressEntries(
const result: Record<string, Progress> = {} raw: Record<string, unknown>,
): Record<string, Progress> {
const result: Record<string, Progress> = {};
for (const [key, value] of Object.entries(raw)) { for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown> const p = value as Record<string, unknown>;
result[key] = { result[key] = {
episodeId: p.episodeId as string, episodeId: p.episodeId as string,
position: p.position as number, position: p.position as number,
duration: p.duration as number, duration: p.duration as number,
timestamp: new Date(p.timestamp as string), timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined, playbackSpeed: p.playbackSpeed as number | undefined,
} };
} }
return result return result;
} }
/** Async initialisation — migrate from localStorage then load from file */
async function initProgress(): Promise<void> { async function initProgress(): Promise<void> {
await migrateProgressFromLocalStorage() const raw = await loadProgressFromFile();
const raw = await loadProgressFromFile() const parsed = parseProgressEntries(raw as Record<string, unknown>);
const parsed = parseProgressEntries(raw as Record<string, unknown>) setProgressMap(parsed);
setProgressMap(parsed)
} }
// Fire-and-forget init // Fire-and-forget init
initProgress() initProgress();
function createProgressStore() { function createProgressStore() {
return { return {
@@ -61,14 +62,14 @@ function createProgressStore() {
* Get progress for a specific episode. * Get progress for a specific episode.
*/ */
get(episodeId: string): Progress | undefined { get(episodeId: string): Progress | undefined {
return progressMap()[episodeId] return progressMap()[episodeId];
}, },
/** /**
* Get all progress entries. * Get all progress entries.
*/ */
all(): Record<string, Progress> { all(): Record<string, Progress> {
return progressMap() return progressMap();
}, },
/** /**
@@ -80,7 +81,7 @@ function createProgressStore() {
duration: number, duration: number,
playbackSpeed?: number, playbackSpeed?: number,
): void { ): void {
if (position < MIN_POSITION_TO_SAVE && duration > 0) return if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
setProgressMap((prev) => ({ setProgressMap((prev) => ({
...prev, ...prev,
@@ -91,34 +92,34 @@ function createProgressStore() {
timestamp: new Date(), timestamp: new Date(),
playbackSpeed, playbackSpeed,
}, },
})) }));
persist() persist();
}, },
/** /**
* Check if an episode is completed. * Check if an episode is completed.
*/ */
isCompleted(episodeId: string): boolean { isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId] const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD return p.position / p.duration >= COMPLETION_THRESHOLD;
}, },
/** /**
* Get progress percentage (0-100) for an episode. * Get progress percentage (0-100) for an episode.
*/ */
getPercent(episodeId: string): number { getPercent(episodeId: string): number {
const p = progressMap()[episodeId] const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0 if (!p || p.duration <= 0) return 0;
return Math.min(100, Math.round((p.position / p.duration) * 100)) return Math.min(100, Math.round((p.position / p.duration) * 100));
}, },
/** /**
* Mark an episode as completed (set position to duration). * Mark an episode as completed (set position to duration).
*/ */
markCompleted(episodeId: string): void { markCompleted(episodeId: string): void {
const p = progressMap()[episodeId] const p = progressMap()[episodeId];
const duration = p?.duration ?? 0 const duration = p?.duration ?? 0;
setProgressMap((prev) => ({ setProgressMap((prev) => ({
...prev, ...prev,
[episodeId]: { [episodeId]: {
@@ -128,8 +129,8 @@ function createProgressStore() {
timestamp: new Date(), timestamp: new Date(),
playbackSpeed: p?.playbackSpeed, playbackSpeed: p?.playbackSpeed,
}, },
})) }));
persist() persist();
}, },
/** /**
@@ -137,29 +138,29 @@ function createProgressStore() {
*/ */
remove(episodeId: string): void { remove(episodeId: string): void {
setProgressMap((prev) => { setProgressMap((prev) => {
const next = { ...prev } const next = { ...prev };
delete next[episodeId] delete next[episodeId];
return next return next;
}) });
persist() persist();
}, },
/** /**
* Clear all progress data. * Clear all progress data.
*/ */
clear(): void { clear(): void {
setProgressMap({}) setProgressMap({});
persist() persist();
}, },
} };
} }
// Singleton instance // Singleton instance
let instance: ReturnType<typeof createProgressStore> | null = null let instance: ReturnType<typeof createProgressStore> | null = null;
export function useProgressStore() { export function useProgressStore() {
if (!instance) { if (!instance) {
instance = createProgressStore() instance = createProgressStore();
} }
return instance return instance;
} }

View File

@@ -2,19 +2,20 @@
* App state persistence via JSON file in XDG_CONFIG_HOME * App state persistence via JSON file in XDG_CONFIG_HOME
* *
* Reads and writes app settings, preferences, and custom theme to a JSON file * Reads and writes app settings, preferences, and custom theme to a JSON file
* instead of localStorage. Provides migration from localStorage on first run.
*/ */
import { ensureConfigDir, getConfigFilePath } from "./config-dir" import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup" import { backupConfigFile } from "./config-backup";
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings" import type {
import { DEFAULT_THEME } from "../constants/themes" AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes";
const APP_STATE_FILE = "app-state.json" const APP_STATE_FILE = "app-state.json";
const PROGRESS_FILE = "progress.json" const PROGRESS_FILE = "progress.json";
const LEGACY_APP_STATE_KEY = "podtui_app_state"
const LEGACY_PROGRESS_KEY = "podtui_progress"
// --- Defaults --- // --- Defaults ---
@@ -24,7 +25,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
} };
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
theme: "system", theme: "system",
@@ -32,141 +33,89 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1, playbackSpeed: 1,
downloadPath: "", downloadPath: "",
visualizer: defaultVisualizerSettings, visualizer: defaultVisualizerSettings,
} };
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
} };
const defaultState: AppState = { const defaultState: AppState = {
settings: defaultSettings, settings: defaultSettings,
preferences: defaultPreferences, preferences: defaultPreferences,
customTheme: DEFAULT_THEME, customTheme: DEFAULT_THEME,
} };
// --- App State --- // --- App State ---
/** Load app state from JSON file */ /** Load app state from JSON file */
export async function loadAppStateFromFile(): Promise<AppState> { export async function loadAppStateFromFile(): Promise<AppState> {
try { try {
const filePath = getConfigFilePath(APP_STATE_FILE) const filePath = getConfigFilePath(APP_STATE_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return defaultState if (!(await file.exists())) return defaultState;
const raw = await file.json() const raw = await file.json();
if (!raw || typeof raw !== "object") return defaultState if (!raw || typeof raw !== "object") return defaultState;
const parsed = raw as Partial<AppState> const parsed = raw as Partial<AppState>;
return { return {
settings: { ...defaultSettings, ...parsed.settings }, settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences }, preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme }, customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
} };
} catch { } catch {
return defaultState return defaultState;
} }
} }
/** Save app state to JSON file */ /** Save app state to JSON file */
export async function saveAppStateToFile(state: AppState): Promise<void> { export async function saveAppStateToFile(state: AppState): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(APP_STATE_FILE) await backupConfigFile(APP_STATE_FILE);
const filePath = getConfigFilePath(APP_STATE_FILE) const filePath = getConfigFilePath(APP_STATE_FILE);
await Bun.write(filePath, JSON.stringify(state, null, 2)) await Bun.write(filePath, JSON.stringify(state, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }
/**
* Migrate app state from localStorage to file.
* Only runs once — if the state file already exists, it's a no-op.
*/
export async function migrateAppStateFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(APP_STATE_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem(LEGACY_APP_STATE_KEY)
if (!raw) return false
const parsed = JSON.parse(raw) as Partial<AppState>
const state: AppState = {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
}
await saveAppStateToFile(state)
return true
} catch {
return false
}
}
// --- Progress ---
interface ProgressEntry { interface ProgressEntry {
episodeId: string episodeId: string;
position: number position: number;
duration: number duration: number;
timestamp: string | Date timestamp: string | Date;
playbackSpeed?: number playbackSpeed?: number;
} }
/** Load progress map from JSON file */ /** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> { export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
try { try {
const filePath = getConfigFilePath(PROGRESS_FILE) const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return {} if (!(await file.exists())) return {};
const raw = await file.json() const raw = await file.json();
if (!raw || typeof raw !== "object") return {} if (!raw || typeof raw !== "object") return {};
return raw as Record<string, ProgressEntry> return raw as Record<string, ProgressEntry>;
} catch { } catch {
return {} return {};
} }
} }
/** Save progress map to JSON file */ /** Save progress map to JSON file */
export async function saveProgressToFile(data: Record<string, unknown>): Promise<void> { export async function saveProgressToFile(
data: Record<string, unknown>,
): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(PROGRESS_FILE) await backupConfigFile(PROGRESS_FILE);
const filePath = getConfigFilePath(PROGRESS_FILE) const filePath = getConfigFilePath(PROGRESS_FILE);
await Bun.write(filePath, JSON.stringify(data, null, 2)) await Bun.write(filePath, JSON.stringify(data, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }
/**
* Migrate progress from localStorage to file.
* Only runs once — if the progress file already exists, it's a no-op.
*/
export async function migrateProgressFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem(LEGACY_PROGRESS_KEY)
if (!raw) return false
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") return false
await saveProgressToFile(parsed as Record<string, unknown>)
return true
} catch {
return false
}
}

View File

@@ -1,107 +1,116 @@
/** /**
* Config file validation and migration for PodTUI
*
* Validates JSON structure of config files, handles corrupted files * Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single * gracefully (falling back to defaults), and provides a single
* entry-point to migrate all localStorage data to XDG config files.
*/ */
import { getConfigFilePath } from "./config-dir" import { getConfigFilePath } from "./config-dir";
import {
migrateAppStateFromLocalStorage,
migrateProgressFromLocalStorage,
} from "./app-persistence"
import {
migrateFeedsFromLocalStorage,
migrateSourcesFromLocalStorage,
} from "./feeds-persistence"
// --- Validation helpers --- // --- Validation helpers ---
/** Check that a value is a non-null object */ /** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> { function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v) return v !== null && typeof v === "object" && !Array.isArray(v);
} }
/** Validate AppState JSON structure */ /** Validate AppState JSON structure */
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } { export function validateAppState(data: unknown): {
const errors: string[] = [] valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) { if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] } return { valid: false, errors: ["app-state.json is not an object"] };
} }
// settings // settings
if (data.settings !== undefined) { if (data.settings !== undefined) {
if (!isObject(data.settings)) { if (!isObject(data.settings)) {
errors.push("settings must be an object") errors.push("settings must be an object");
} else { } else {
const s = data.settings as Record<string, unknown> const s = data.settings as Record<string, unknown>;
if (s.theme !== undefined && typeof s.theme !== "string") errors.push("settings.theme must be a string") if (s.theme !== undefined && typeof s.theme !== "string")
if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number") errors.push("settings.theme must be a string");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number") if (s.fontSize !== undefined && typeof s.fontSize !== "number")
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string") errors.push("settings.fontSize must be a number");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
errors.push("settings.playbackSpeed must be a number");
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
errors.push("settings.downloadPath must be a string");
} }
} }
// preferences // preferences
if (data.preferences !== undefined) { if (data.preferences !== undefined) {
if (!isObject(data.preferences)) { if (!isObject(data.preferences)) {
errors.push("preferences must be an object") errors.push("preferences must be an object");
} else { } else {
const p = data.preferences as Record<string, unknown> const p = data.preferences as Record<string, unknown>;
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") errors.push("preferences.showExplicit must be a boolean") if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean") errors.push("preferences.showExplicit must be a boolean");
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
errors.push("preferences.autoDownload must be a boolean");
} }
} }
// customTheme // customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) { if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object") errors.push("customTheme must be an object");
} }
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors };
} }
/** Validate feeds JSON structure */ /** Validate feeds JSON structure */
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } { export function validateFeeds(data: unknown): {
const errors: string[] = [] valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] } return { valid: false, errors: ["feeds.json is not an array"] };
} }
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const feed = data[i] const feed = data[i];
if (!isObject(feed)) { if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`) errors.push(`feeds[${i}] is not an object`);
continue continue;
} }
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`) if (typeof feed.id !== "string")
if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`) errors.push(`feeds[${i}].id must be a string`);
if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`) if (!isObject(feed.podcast))
errors.push(`feeds[${i}].podcast must be an object`);
if (!Array.isArray(feed.episodes))
errors.push(`feeds[${i}].episodes must be an array`);
} }
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors };
} }
/** Validate progress JSON structure */ /** Validate progress JSON structure */
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } { export function validateProgress(data: unknown): {
const errors: string[] = [] valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) { if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] } return { valid: false, errors: ["progress.json is not an object"] };
} }
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) { if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`) errors.push(`progress["${key}"] is not an object`);
continue continue;
} }
const p = value as Record<string, unknown> const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`) if (typeof p.episodeId !== "string")
if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`) errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`) if (typeof p.position !== "number")
errors.push(`progress["${key}"].position must be a number`);
if (typeof p.duration !== "number")
errors.push(`progress["${key}"].duration must be a number`);
} }
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors };
} }
// --- Safe config file reading --- // --- Safe config file reading ---
@@ -115,52 +124,27 @@ export async function safeReadConfigFile<T>(
validator: (data: unknown) => { valid: boolean; errors: string[] }, validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> { ): Promise<{ data: T | null; errors: string[] }> {
try { try {
const filePath = getConfigFilePath(filename) const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) { if (!(await file.exists())) {
return { data: null, errors: [] } return { data: null, errors: [] };
} }
const text = await file.text() const text = await file.text();
let parsed: unknown let parsed: unknown;
try { try {
parsed = JSON.parse(text) parsed = JSON.parse(text);
} catch { } catch {
return { data: null, errors: [`${filename}: invalid JSON`] } return { data: null, errors: [`${filename}: invalid JSON`] };
} }
const result = validator(parsed) const result = validator(parsed);
if (!result.valid) { if (!result.valid) {
return { data: null, errors: result.errors } return { data: null, errors: result.errors };
} }
return { data: parsed as T, errors: [] } return { data: parsed as T, errors: [] };
} catch (err) { } catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] } return { data: null, errors: [`${filename}: ${String(err)}`] };
} }
} }
// --- Unified migration ---
/**
* Run all localStorage -> file migrations.
* Safe to call multiple times; each migration is a no-op if the target
* file already exists.
*
* Returns a summary of what was migrated.
*/
export async function migrateAllFromLocalStorage(): Promise<{
appState: boolean
progress: boolean
feeds: boolean
sources: boolean
}> {
const [appState, progress, feeds, sources] = await Promise.all([
migrateAppStateFromLocalStorage(),
migrateProgressFromLocalStorage(),
migrateFeedsFromLocalStorage(),
migrateSourcesFromLocalStorage(),
])
return { appState, progress, feeds, sources }
}

View File

@@ -2,15 +2,14 @@
* Feeds persistence via JSON file in XDG_CONFIG_HOME * Feeds persistence via JSON file in XDG_CONFIG_HOME
* *
* Reads and writes feeds to a JSON file instead of localStorage. * Reads and writes feeds to a JSON file instead of localStorage.
* Provides migration from localStorage on first run.
*/ */
import { ensureConfigDir, getConfigFilePath } from "./config-dir" import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup" import { backupConfigFile } from "./config-backup";
import type { Feed } from "../types/feed" import type { Feed } from "../types/feed";
const FEEDS_FILE = "feeds.json" const FEEDS_FILE = "feeds.json";
const SOURCES_FILE = "sources.json" const SOURCES_FILE = "sources.json";
/** Deserialize date strings back to Date objects in feed data */ /** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed { function reviveDates(feed: Feed): Feed {
@@ -25,31 +24,31 @@ function reviveDates(feed: Feed): Feed {
...ep, ...ep,
pubDate: new Date(ep.pubDate), pubDate: new Date(ep.pubDate),
})), })),
} };
} }
/** Load feeds from JSON file */ /** Load feeds from JSON file */
export async function loadFeedsFromFile(): Promise<Feed[]> { export async function loadFeedsFromFile(): Promise<Feed[]> {
try { try {
const filePath = getConfigFilePath(FEEDS_FILE) const filePath = getConfigFilePath(FEEDS_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return [] if (!(await file.exists())) return [];
const raw = await file.json() const raw = await file.json();
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return [];
return raw.map(reviveDates) return raw.map(reviveDates);
} catch { } catch {
return [] return [];
} }
} }
/** Save feeds to JSON file */ /** Save feeds to JSON file */
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> { export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(FEEDS_FILE) await backupConfigFile(FEEDS_FILE);
const filePath = getConfigFilePath(FEEDS_FILE) const filePath = getConfigFilePath(FEEDS_FILE);
await Bun.write(filePath, JSON.stringify(feeds, null, 2)) await Bun.write(filePath, JSON.stringify(feeds, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
@@ -58,75 +57,26 @@ export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
/** Load sources from JSON file */ /** Load sources from JSON file */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> { export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try { try {
const filePath = getConfigFilePath(SOURCES_FILE) const filePath = getConfigFilePath(SOURCES_FILE);
const file = Bun.file(filePath) const file = Bun.file(filePath);
if (!(await file.exists())) return null if (!(await file.exists())) return null;
const raw = await file.json() const raw = await file.json();
if (!Array.isArray(raw)) return null if (!Array.isArray(raw)) return null;
return raw as T[] return raw as T[];
} catch { } catch {
return null return null;
} }
} }
/** Save sources to JSON file */ /** Save sources to JSON file */
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> { export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
try { try {
await ensureConfigDir() await ensureConfigDir();
await backupConfigFile(SOURCES_FILE) await backupConfigFile(SOURCES_FILE);
const filePath = getConfigFilePath(SOURCES_FILE) const filePath = getConfigFilePath(SOURCES_FILE);
await Bun.write(filePath, JSON.stringify(sources, null, 2)) await Bun.write(filePath, JSON.stringify(sources, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }
/**
* Migrate feeds from localStorage to file.
* Only runs once — if the feeds file already exists, it's a no-op.
*/
export async function migrateFeedsFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(FEEDS_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false // Already migrated
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem("podtui_feeds")
if (!raw) return false
const feeds = JSON.parse(raw) as Feed[]
if (!Array.isArray(feeds) || feeds.length === 0) return false
await saveFeedsToFile(feeds)
return true
} catch {
return false
}
}
/**
* Migrate sources from localStorage to file.
*/
export async function migrateSourcesFromLocalStorage(): Promise<boolean> {
try {
const filePath = getConfigFilePath(SOURCES_FILE)
const file = Bun.file(filePath)
if (await file.exists()) return false
if (typeof localStorage === "undefined") return false
const raw = localStorage.getItem("podtui_sources")
if (!raw) return false
const sources = JSON.parse(raw)
if (!Array.isArray(sources) || sources.length === 0) return false
await saveSourcesToFile(sources)
return true
} catch {
return false
}
}

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from "bun:test"
import { ansiToRgba } from "./ansi-to-rgba"
import { resolveTheme } from "./theme-resolver"
import type { ThemeJson } from "../types/theme-schema"
describe("theme utils", () => {
it("converts ansi codes", () => {
const color = ansiToRgba(1)
expect(color).toBeTruthy()
})
it("resolves simple theme", () => {
const json: ThemeJson = {
theme: {
primary: "#ffffff",
secondary: "#000000",
accent: "#000000",
error: "#000000",
warning: "#000000",
success: "#000000",
info: "#000000",
text: "#000000",
textMuted: "#000000",
background: "#000000",
backgroundPanel: "#000000",
backgroundElement: "#000000",
border: "#000000",
borderActive: "#000000",
borderSubtle: "#000000",
diffAdded: "#000000",
diffRemoved: "#000000",
diffContext: "#000000",
diffHunkHeader: "#000000",
diffHighlightAdded: "#000000",
diffHighlightRemoved: "#000000",
diffAddedBg: "#000000",
diffRemovedBg: "#000000",
diffContextBg: "#000000",
diffLineNumber: "#000000",
diffAddedLineNumberBg: "#000000",
diffRemovedLineNumberBg: "#000000",
markdownText: "#000000",
markdownHeading: "#000000",
markdownLink: "#000000",
markdownLinkText: "#000000",
markdownCode: "#000000",
markdownBlockQuote: "#000000",
markdownEmph: "#000000",
markdownStrong: "#000000",
markdownHorizontalRule: "#000000",
markdownListItem: "#000000",
markdownListEnumeration: "#000000",
markdownImage: "#000000",
markdownImageText: "#000000",
markdownCodeBlock: "#000000",
syntaxComment: "#000000",
syntaxKeyword: "#000000",
syntaxFunction: "#000000",
syntaxVariable: "#000000",
syntaxString: "#000000",
syntaxNumber: "#000000",
syntaxType: "#000000",
syntaxOperator: "#000000",
syntaxPunctuation: "#000000",
},
}
const resolved = resolveTheme(json, "dark") as unknown as { primary: unknown }
expect(resolved.primary).toBeTruthy()
})
})