remove migration code
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
|
||||
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences, VisualizerSettings } from "../types/settings"
|
||||
import { resolveTheme } from "../utils/theme-resolver"
|
||||
import type { ThemeJson } from "../types/theme-schema"
|
||||
import { createSignal } from "solid-js";
|
||||
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
|
||||
import type {
|
||||
AppSettings,
|
||||
AppState,
|
||||
ThemeColors,
|
||||
ThemeName,
|
||||
ThemeMode,
|
||||
UserPreferences,
|
||||
VisualizerSettings,
|
||||
} from "../types/settings";
|
||||
import { resolveTheme } from "../utils/theme-resolver";
|
||||
import type { ThemeJson } from "../types/theme-schema";
|
||||
import {
|
||||
loadAppStateFromFile,
|
||||
saveAppStateToFile,
|
||||
migrateAppStateFromLocalStorage,
|
||||
} from "../utils/app-persistence"
|
||||
} from "../utils/app-persistence";
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
bars: 32,
|
||||
@@ -15,7 +22,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
};
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
@@ -23,82 +30,84 @@ const defaultSettings: AppSettings = {
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
}
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
}
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
settings: defaultSettings,
|
||||
preferences: defaultPreferences,
|
||||
customTheme: DEFAULT_THEME,
|
||||
}
|
||||
};
|
||||
|
||||
export function createAppStore() {
|
||||
// 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
|
||||
const init = async () => {
|
||||
await migrateAppStateFromLocalStorage()
|
||||
const loaded = await loadAppStateFromFile()
|
||||
setState(loaded)
|
||||
}
|
||||
init()
|
||||
const loaded = await loadAppStateFromFile();
|
||||
setState(loaded);
|
||||
};
|
||||
init();
|
||||
|
||||
const saveState = (next: AppState) => {
|
||||
saveAppStateToFile(next).catch(() => {})
|
||||
}
|
||||
saveAppStateToFile(next).catch(() => {});
|
||||
};
|
||||
|
||||
const updateState = (next: AppState) => {
|
||||
setState(next)
|
||||
saveState(next)
|
||||
}
|
||||
setState(next);
|
||||
saveState(next);
|
||||
};
|
||||
|
||||
const updateSettings = (updates: Partial<AppSettings>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
settings: { ...state().settings, ...updates },
|
||||
}
|
||||
updateState(next)
|
||||
}
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
|
||||
const updatePreferences = (updates: Partial<UserPreferences>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
preferences: { ...state().preferences, ...updates },
|
||||
}
|
||||
updateState(next)
|
||||
}
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
|
||||
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
|
||||
const next = {
|
||||
...state(),
|
||||
customTheme: { ...state().customTheme, ...updates },
|
||||
}
|
||||
updateState(next)
|
||||
}
|
||||
};
|
||||
updateState(next);
|
||||
};
|
||||
|
||||
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
|
||||
updateSettings({
|
||||
visualizer: { ...state().settings.visualizer, ...updates },
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setTheme = (theme: ThemeName) => {
|
||||
updateSettings({ theme })
|
||||
}
|
||||
updateSettings({ theme });
|
||||
};
|
||||
|
||||
const resolveThemeColors = (): ThemeColors => {
|
||||
const theme = state().settings.theme
|
||||
if (theme === "custom") return state().customTheme
|
||||
if (theme === "system") return DEFAULT_THEME
|
||||
const json = THEME_JSON[theme]
|
||||
if (!json) return DEFAULT_THEME
|
||||
return resolveTheme(json as ThemeJson, "dark" as ThemeMode) as unknown as ThemeColors
|
||||
}
|
||||
const theme = state().settings.theme;
|
||||
if (theme === "custom") return state().customTheme;
|
||||
if (theme === "system") return DEFAULT_THEME;
|
||||
const json = THEME_JSON[theme];
|
||||
if (!json) return DEFAULT_THEME;
|
||||
return resolveTheme(
|
||||
json as ThemeJson,
|
||||
"dark" as ThemeMode,
|
||||
) as unknown as ThemeColors;
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
@@ -108,14 +117,14 @@ export function createAppStore() {
|
||||
updateVisualizer,
|
||||
setTheme,
|
||||
resolveTheme: resolveThemeColors,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let appStoreInstance: ReturnType<typeof createAppStore> | null = null
|
||||
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
|
||||
|
||||
export function useAppStore() {
|
||||
if (!appStoreInstance) {
|
||||
appStoreInstance = createAppStore()
|
||||
appStoreInstance = createAppStore();
|
||||
}
|
||||
return appStoreInstance
|
||||
return appStoreInstance;
|
||||
}
|
||||
|
||||
@@ -3,174 +3,193 @@
|
||||
* Manages feed data, sources, and filtering
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { FeedVisibility } from "../types/feed"
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode, EpisodeStatus } from "../types/episode"
|
||||
import type { PodcastSource, SourceType } from "../types/source"
|
||||
import { DEFAULT_SOURCES } from "../types/source"
|
||||
import { parseRSSFeed } from "../api/rss-parser"
|
||||
import { createSignal } from "solid-js";
|
||||
import { FeedVisibility } from "../types/feed";
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import type { Episode, EpisodeStatus } from "../types/episode";
|
||||
import type { PodcastSource, SourceType } from "../types/source";
|
||||
import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import {
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
saveSourcesToFile,
|
||||
migrateFeedsFromLocalStorage,
|
||||
migrateSourcesFromLocalStorage,
|
||||
} from "../utils/feeds-persistence"
|
||||
import { useDownloadStore } from "./download"
|
||||
import { DownloadStatus } from "../types/episode"
|
||||
} from "../utils/feeds-persistence";
|
||||
import { useDownloadStore } from "./download";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
|
||||
/** Max episodes to load per page/chunk */
|
||||
const MAX_EPISODES_REFRESH = 50
|
||||
const MAX_EPISODES_REFRESH = 50;
|
||||
|
||||
/** 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[]) */
|
||||
const fullEpisodeCache = new Map<string, Episode[]>()
|
||||
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||
|
||||
/** 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) */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
saveFeedsToFile(feeds).catch(() => {})
|
||||
saveFeedsToFile(feeds).catch(() => {});
|
||||
}
|
||||
|
||||
/** Save sources to file (async, fire-and-forget) */
|
||||
function saveSources(sources: PodcastSource[]): void {
|
||||
saveSourcesToFile(sources).catch(() => {})
|
||||
saveSourcesToFile(sources).catch(() => {});
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
export function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([])
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([...DEFAULT_SOURCES])
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
]);
|
||||
|
||||
// Async initialization: migrate from localStorage, then load from file
|
||||
;(async () => {
|
||||
await migrateFeedsFromLocalStorage()
|
||||
await migrateSourcesFromLocalStorage()
|
||||
const loadedFeeds = await loadFeedsFromFile()
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds)
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>()
|
||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources)
|
||||
})()
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||
})();
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
})
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null)
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false)
|
||||
});
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()]
|
||||
const f = filter()
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
|
||||
// Filter by visibility
|
||||
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
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId)
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
|
||||
// Filter by pinned
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned)
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase()
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query)
|
||||
)
|
||||
feed.podcast.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by selected field
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
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":
|
||||
return sortDir * (a.episodes.length - b.episodes.length)
|
||||
return sortDir * (a.episodes.length - b.episodes.length);
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
|
||||
return sortDir * (aLatest - bLatest)
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||
return sortDir * (aLatest - bLatest);
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Pinned feeds always first
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1
|
||||
if (!a.isPinned && b.isPinned) return 1
|
||||
return 0
|
||||
})
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return result
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed }> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []
|
||||
|
||||
const getAllEpisodesChronological = (): Array<{
|
||||
episode: Episode;
|
||||
feed: Feed;
|
||||
}> => {
|
||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
for (const feed of feeds()) {
|
||||
for (const episode of feed.episodes) {
|
||||
allEpisodes.push({ episode, feed })
|
||||
allEpisodes.push({ episode, feed });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 */
|
||||
const fetchEpisodes = async (feedUrl: string, limit: number, feedId?: string): Promise<Episode[]> => {
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
feedId?: string,
|
||||
): Promise<Episode[]> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
})
|
||||
if (!response.ok) return []
|
||||
const xml = await response.text()
|
||||
const parsed = parseRSSFeed(xml, feedUrl)
|
||||
const allEpisodes = parsed.episodes
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feedUrl);
|
||||
const allEpisodes = parsed.episodes;
|
||||
|
||||
// Cache all parsed episodes for pagination
|
||||
if (feedId) {
|
||||
fullEpisodeCache.set(feedId, allEpisodes)
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length))
|
||||
fullEpisodeCache.set(feedId, allEpisodes);
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||
}
|
||||
|
||||
return allEpisodes.slice(0, limit)
|
||||
return allEpisodes.slice(0, limit);
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
||||
const feedId = crypto.randomUUID()
|
||||
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE, feedId)
|
||||
const addFeed = async (
|
||||
podcast: Podcast,
|
||||
sourceId: string,
|
||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||
) => {
|
||||
const feedId = crypto.randomUUID();
|
||||
const episodes = await fetchEpisodes(
|
||||
podcast.feedUrl,
|
||||
MAX_EPISODES_SUBSCRIBE,
|
||||
feedId,
|
||||
);
|
||||
const newFeed: Feed = {
|
||||
id: feedId,
|
||||
podcast,
|
||||
@@ -179,220 +198,238 @@ export function createFeedStore() {
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
}
|
||||
};
|
||||
setFeeds((prev) => {
|
||||
const updated = [...prev, newFeed]
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
return newFeed
|
||||
}
|
||||
const updated = [...prev, newFeed];
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
return newFeed;
|
||||
};
|
||||
|
||||
/** Auto-download newest episodes for a feed */
|
||||
const autoDownloadEpisodes = (feedId: string, newEpisodes: Episode[], count: number) => {
|
||||
const autoDownloadEpisodes = (
|
||||
feedId: string,
|
||||
newEpisodes: Episode[],
|
||||
count: number,
|
||||
) => {
|
||||
try {
|
||||
const dlStore = useDownloadStore()
|
||||
const dlStore = useDownloadStore();
|
||||
// Sort by pubDate descending (newest first)
|
||||
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
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted
|
||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||
for (const ep of toDownload) {
|
||||
const status = dlStore.getDownloadStatus(ep.id)
|
||||
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
|
||||
dlStore.startDownload(ep, feedId)
|
||||
const status = dlStore.getDownloadStatus(ep.id);
|
||||
if (
|
||||
status === DownloadStatus.NONE ||
|
||||
status === DownloadStatus.FAILED
|
||||
) {
|
||||
dlStore.startDownload(ep, feedId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Download store may not be available yet
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
const refreshFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId)
|
||||
if (!feed) return
|
||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id))
|
||||
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feedId)
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||
const episodes = await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
|
||||
)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Auto-download new episodes if enabled for this feed
|
||||
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) {
|
||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0)
|
||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Refresh all feeds */
|
||||
const refreshAllFeeds = async () => {
|
||||
const currentFeeds = feeds()
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id)
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId)
|
||||
episodeLoadCount.delete(feedId)
|
||||
fullEpisodeCache.delete(feedId);
|
||||
episodeLoadCount.delete(feedId);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.filter((f) => f.id !== feedId)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
const updated = prev.filter((f) => f.id !== feedId);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f
|
||||
)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f
|
||||
)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource]
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
return newSource
|
||||
}
|
||||
const updated = [...prev, newSource];
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
|
||||
/** Update a source */
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source
|
||||
)
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false
|
||||
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId)
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
return true
|
||||
}
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
|
||||
)
|
||||
saveSources(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId)
|
||||
}
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId()
|
||||
return id ? getFeed(id) : undefined
|
||||
}
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId)
|
||||
if (!cached) return false
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0
|
||||
return loaded < cached.length
|
||||
}
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return
|
||||
const feed = getFeed(feedId)
|
||||
if (!feed) return
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
setIsLoadingMore(true)
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId)
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
})
|
||||
if (!response.ok) return
|
||||
const xml = await response.text()
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl)
|
||||
cached = parsed.episodes
|
||||
fullEpisodeCache.set(feedId, cached)
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// 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 newCount = Math.min(currentCount + MAX_EPISODES_REFRESH, cached.length)
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.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)
|
||||
const episodes = cached.slice(0, newCount)
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f
|
||||
)
|
||||
saveFeeds(updated)
|
||||
return updated
|
||||
})
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false)
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Set auto-download settings for a feed */
|
||||
const setAutoDownload = (feedId: string, enabled: boolean, count: number = 0) => {
|
||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count })
|
||||
}
|
||||
const setAutoDownload = (
|
||||
feedId: string,
|
||||
enabled: boolean,
|
||||
count: number = 0,
|
||||
) => {
|
||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
@@ -401,14 +438,14 @@ export function createFeedStore() {
|
||||
filter,
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
@@ -424,15 +461,15 @@ export function createFeedStore() {
|
||||
toggleSource,
|
||||
updateSource,
|
||||
setAutoDownload,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton feed store */
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||
|
||||
export function useFeedStore() {
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore()
|
||||
feedStoreInstance = createFeedStore();
|
||||
}
|
||||
return feedStoreInstance
|
||||
return feedStoreInstance;
|
||||
}
|
||||
|
||||
@@ -5,55 +5,56 @@
|
||||
* Tracks position, duration, completion, and last-played timestamp.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Progress } from "../types/episode"
|
||||
import { createSignal } from "solid-js";
|
||||
import type { Progress } from "../types/episode";
|
||||
import {
|
||||
loadProgressFromFile,
|
||||
saveProgressToFile,
|
||||
migrateProgressFromLocalStorage,
|
||||
} from "../utils/app-persistence"
|
||||
} from "../utils/app-persistence";
|
||||
|
||||
/** 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 */
|
||||
const MIN_POSITION_TO_SAVE = 5
|
||||
const MIN_POSITION_TO_SAVE = 5;
|
||||
|
||||
// --- 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) */
|
||||
function persist(): void {
|
||||
saveProgressToFile(progressMap()).catch(() => {})
|
||||
saveProgressToFile(progressMap()).catch(() => {});
|
||||
}
|
||||
|
||||
/** Parse raw progress entries from file, reviving Date objects */
|
||||
function parseProgressEntries(raw: Record<string, unknown>): Record<string, Progress> {
|
||||
const result: Record<string, Progress> = {}
|
||||
function parseProgressEntries(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, Progress> {
|
||||
const result: Record<string, Progress> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const p = value as Record<string, unknown>
|
||||
const p = value as Record<string, unknown>;
|
||||
result[key] = {
|
||||
episodeId: p.episodeId as string,
|
||||
position: p.position as number,
|
||||
duration: p.duration as number,
|
||||
timestamp: new Date(p.timestamp as string),
|
||||
playbackSpeed: p.playbackSpeed as number | undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Async initialisation — migrate from localStorage then load from file */
|
||||
async function initProgress(): Promise<void> {
|
||||
await migrateProgressFromLocalStorage()
|
||||
const raw = await loadProgressFromFile()
|
||||
const parsed = parseProgressEntries(raw as Record<string, unknown>)
|
||||
setProgressMap(parsed)
|
||||
const raw = await loadProgressFromFile();
|
||||
const parsed = parseProgressEntries(raw as Record<string, unknown>);
|
||||
setProgressMap(parsed);
|
||||
}
|
||||
|
||||
// Fire-and-forget init
|
||||
initProgress()
|
||||
initProgress();
|
||||
|
||||
function createProgressStore() {
|
||||
return {
|
||||
@@ -61,14 +62,14 @@ function createProgressStore() {
|
||||
* Get progress for a specific episode.
|
||||
*/
|
||||
get(episodeId: string): Progress | undefined {
|
||||
return progressMap()[episodeId]
|
||||
return progressMap()[episodeId];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all progress entries.
|
||||
*/
|
||||
all(): Record<string, Progress> {
|
||||
return progressMap()
|
||||
return progressMap();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -80,7 +81,7 @@ function createProgressStore() {
|
||||
duration: number,
|
||||
playbackSpeed?: number,
|
||||
): void {
|
||||
if (position < MIN_POSITION_TO_SAVE && duration > 0) return
|
||||
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
|
||||
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
@@ -91,34 +92,34 @@ function createProgressStore() {
|
||||
timestamp: new Date(),
|
||||
playbackSpeed,
|
||||
},
|
||||
}))
|
||||
persist()
|
||||
}));
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an episode is completed.
|
||||
*/
|
||||
isCompleted(episodeId: string): boolean {
|
||||
const p = progressMap()[episodeId]
|
||||
if (!p || p.duration <= 0) return false
|
||||
return p.position / p.duration >= COMPLETION_THRESHOLD
|
||||
const p = progressMap()[episodeId];
|
||||
if (!p || p.duration <= 0) return false;
|
||||
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get progress percentage (0-100) for an episode.
|
||||
*/
|
||||
getPercent(episodeId: string): number {
|
||||
const p = progressMap()[episodeId]
|
||||
if (!p || p.duration <= 0) return 0
|
||||
return Math.min(100, Math.round((p.position / p.duration) * 100))
|
||||
const p = progressMap()[episodeId];
|
||||
if (!p || p.duration <= 0) return 0;
|
||||
return Math.min(100, Math.round((p.position / p.duration) * 100));
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark an episode as completed (set position to duration).
|
||||
*/
|
||||
markCompleted(episodeId: string): void {
|
||||
const p = progressMap()[episodeId]
|
||||
const duration = p?.duration ?? 0
|
||||
const p = progressMap()[episodeId];
|
||||
const duration = p?.duration ?? 0;
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[episodeId]: {
|
||||
@@ -128,8 +129,8 @@ function createProgressStore() {
|
||||
timestamp: new Date(),
|
||||
playbackSpeed: p?.playbackSpeed,
|
||||
},
|
||||
}))
|
||||
persist()
|
||||
}));
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -137,29 +138,29 @@ function createProgressStore() {
|
||||
*/
|
||||
remove(episodeId: string): void {
|
||||
setProgressMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[episodeId]
|
||||
return next
|
||||
})
|
||||
persist()
|
||||
const next = { ...prev };
|
||||
delete next[episodeId];
|
||||
return next;
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all progress data.
|
||||
*/
|
||||
clear(): void {
|
||||
setProgressMap({})
|
||||
persist()
|
||||
setProgressMap({});
|
||||
persist();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: ReturnType<typeof createProgressStore> | null = null
|
||||
let instance: ReturnType<typeof createProgressStore> | null = null;
|
||||
|
||||
export function useProgressStore() {
|
||||
if (!instance) {
|
||||
instance = createProgressStore()
|
||||
instance = createProgressStore();
|
||||
}
|
||||
return instance
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
* App state persistence via JSON file in XDG_CONFIG_HOME
|
||||
*
|
||||
* 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 { backupConfigFile } from "./config-backup"
|
||||
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings"
|
||||
import { DEFAULT_THEME } from "../constants/themes"
|
||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||
import { backupConfigFile } from "./config-backup";
|
||||
import type {
|
||||
AppState,
|
||||
AppSettings,
|
||||
UserPreferences,
|
||||
VisualizerSettings,
|
||||
} from "../types/settings";
|
||||
import { DEFAULT_THEME } from "../constants/themes";
|
||||
|
||||
const APP_STATE_FILE = "app-state.json"
|
||||
const PROGRESS_FILE = "progress.json"
|
||||
|
||||
const LEGACY_APP_STATE_KEY = "podtui_app_state"
|
||||
const LEGACY_PROGRESS_KEY = "podtui_progress"
|
||||
const APP_STATE_FILE = "app-state.json";
|
||||
const PROGRESS_FILE = "progress.json";
|
||||
|
||||
// --- Defaults ---
|
||||
|
||||
@@ -24,7 +25,7 @@ const defaultVisualizerSettings: VisualizerSettings = {
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
};
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
@@ -32,141 +33,89 @@ const defaultSettings: AppSettings = {
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
}
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
showExplicit: false,
|
||||
autoDownload: false,
|
||||
}
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
settings: defaultSettings,
|
||||
preferences: defaultPreferences,
|
||||
customTheme: DEFAULT_THEME,
|
||||
}
|
||||
};
|
||||
|
||||
// --- App State ---
|
||||
|
||||
/** Load app state from JSON file */
|
||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(APP_STATE_FILE)
|
||||
const file = Bun.file(filePath)
|
||||
if (!(await file.exists())) return defaultState
|
||||
const filePath = getConfigFilePath(APP_STATE_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return defaultState;
|
||||
|
||||
const raw = await file.json()
|
||||
if (!raw || typeof raw !== "object") return defaultState
|
||||
const raw = await file.json();
|
||||
if (!raw || typeof raw !== "object") return defaultState;
|
||||
|
||||
const parsed = raw as Partial<AppState>
|
||||
const parsed = raw as Partial<AppState>;
|
||||
return {
|
||||
settings: { ...defaultSettings, ...parsed.settings },
|
||||
preferences: { ...defaultPreferences, ...parsed.preferences },
|
||||
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return defaultState
|
||||
return defaultState;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save app state to JSON file */
|
||||
export async function saveAppStateToFile(state: AppState): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir()
|
||||
await backupConfigFile(APP_STATE_FILE)
|
||||
const filePath = getConfigFilePath(APP_STATE_FILE)
|
||||
await Bun.write(filePath, JSON.stringify(state, null, 2))
|
||||
await ensureConfigDir();
|
||||
await backupConfigFile(APP_STATE_FILE);
|
||||
const filePath = getConfigFilePath(APP_STATE_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(state, null, 2));
|
||||
} catch {
|
||||
// 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 {
|
||||
episodeId: string
|
||||
position: number
|
||||
duration: number
|
||||
timestamp: string | Date
|
||||
playbackSpeed?: number
|
||||
episodeId: string;
|
||||
position: number;
|
||||
duration: number;
|
||||
timestamp: string | Date;
|
||||
playbackSpeed?: number;
|
||||
}
|
||||
|
||||
/** Load progress map from JSON file */
|
||||
export async function loadProgressFromFile(): Promise<Record<string, ProgressEntry>> {
|
||||
export async function loadProgressFromFile(): Promise<
|
||||
Record<string, ProgressEntry>
|
||||
> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(PROGRESS_FILE)
|
||||
const file = Bun.file(filePath)
|
||||
if (!(await file.exists())) return {}
|
||||
const filePath = getConfigFilePath(PROGRESS_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return {};
|
||||
|
||||
const raw = await file.json()
|
||||
if (!raw || typeof raw !== "object") return {}
|
||||
return raw as Record<string, ProgressEntry>
|
||||
const raw = await file.json();
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
return raw as Record<string, ProgressEntry>;
|
||||
} catch {
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
await ensureConfigDir()
|
||||
await backupConfigFile(PROGRESS_FILE)
|
||||
const filePath = getConfigFilePath(PROGRESS_FILE)
|
||||
await Bun.write(filePath, JSON.stringify(data, null, 2))
|
||||
await ensureConfigDir();
|
||||
await backupConfigFile(PROGRESS_FILE);
|
||||
const filePath = getConfigFilePath(PROGRESS_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
||||
} catch {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,116 @@
|
||||
/**
|
||||
* Config file validation and migration for PodTUI
|
||||
*
|
||||
* Validates JSON structure of config files, handles corrupted files
|
||||
* 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 {
|
||||
migrateAppStateFromLocalStorage,
|
||||
migrateProgressFromLocalStorage,
|
||||
} from "./app-persistence"
|
||||
import {
|
||||
migrateFeedsFromLocalStorage,
|
||||
migrateSourcesFromLocalStorage,
|
||||
} from "./feeds-persistence"
|
||||
|
||||
import { getConfigFilePath } from "./config-dir";
|
||||
// --- Validation helpers ---
|
||||
|
||||
/** Check that a value is a non-null object */
|
||||
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 */
|
||||
export function validateAppState(data: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
export function validateAppState(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
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
|
||||
if (data.settings !== undefined) {
|
||||
if (!isObject(data.settings)) {
|
||||
errors.push("settings must be an object")
|
||||
errors.push("settings must be an object");
|
||||
} else {
|
||||
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.fontSize !== undefined && typeof s.fontSize !== "number") 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")
|
||||
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.fontSize !== undefined && typeof s.fontSize !== "number")
|
||||
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
|
||||
if (data.preferences !== undefined) {
|
||||
if (!isObject(data.preferences)) {
|
||||
errors.push("preferences must be an object")
|
||||
errors.push("preferences must be an object");
|
||||
} else {
|
||||
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.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean")
|
||||
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.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
|
||||
errors.push("preferences.autoDownload must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
// 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 */
|
||||
export function validateFeeds(data: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
export function validateFeeds(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
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++) {
|
||||
const feed = data[i]
|
||||
const feed = data[i];
|
||||
if (!isObject(feed)) {
|
||||
errors.push(`feeds[${i}] is not an object`)
|
||||
continue
|
||||
errors.push(`feeds[${i}] is not an object`);
|
||||
continue;
|
||||
}
|
||||
if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`)
|
||||
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`)
|
||||
if (typeof feed.id !== "string")
|
||||
errors.push(`feeds[${i}].id must be a string`);
|
||||
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 */
|
||||
export function validateProgress(data: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
export function validateProgress(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
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)) {
|
||||
if (!isObject(value)) {
|
||||
errors.push(`progress["${key}"] is not an object`)
|
||||
continue
|
||||
errors.push(`progress["${key}"] is not an object`);
|
||||
continue;
|
||||
}
|
||||
const p = value as Record<string, unknown>
|
||||
if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`)
|
||||
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`)
|
||||
const p = value as Record<string, unknown>;
|
||||
if (typeof p.episodeId !== "string")
|
||||
errors.push(`progress["${key}"].episodeId must be a string`);
|
||||
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 ---
|
||||
@@ -115,52 +124,27 @@ export async function safeReadConfigFile<T>(
|
||||
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
||||
): Promise<{ data: T | null; errors: string[] }> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(filename)
|
||||
const file = Bun.file(filePath)
|
||||
const filePath = getConfigFilePath(filename);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return { data: null, errors: [] }
|
||||
return { data: null, errors: [] };
|
||||
}
|
||||
|
||||
const text = await file.text()
|
||||
let parsed: unknown
|
||||
const text = await file.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
parsed = JSON.parse(text);
|
||||
} 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) {
|
||||
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) {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
||||
*
|
||||
* 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 { backupConfigFile } from "./config-backup"
|
||||
import type { Feed } from "../types/feed"
|
||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||
import { backupConfigFile } from "./config-backup";
|
||||
import type { Feed } from "../types/feed";
|
||||
|
||||
const FEEDS_FILE = "feeds.json"
|
||||
const SOURCES_FILE = "sources.json"
|
||||
const FEEDS_FILE = "feeds.json";
|
||||
const SOURCES_FILE = "sources.json";
|
||||
|
||||
/** Deserialize date strings back to Date objects in feed data */
|
||||
function reviveDates(feed: Feed): Feed {
|
||||
@@ -25,31 +24,31 @@ function reviveDates(feed: Feed): Feed {
|
||||
...ep,
|
||||
pubDate: new Date(ep.pubDate),
|
||||
})),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Load feeds from JSON file */
|
||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(FEEDS_FILE)
|
||||
const file = Bun.file(filePath)
|
||||
if (!(await file.exists())) return []
|
||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return [];
|
||||
|
||||
const raw = await file.json()
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(reviveDates)
|
||||
const raw = await file.json();
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(reviveDates);
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Save feeds to JSON file */
|
||||
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir()
|
||||
await backupConfigFile(FEEDS_FILE)
|
||||
const filePath = getConfigFilePath(FEEDS_FILE)
|
||||
await Bun.write(filePath, JSON.stringify(feeds, null, 2))
|
||||
await ensureConfigDir();
|
||||
await backupConfigFile(FEEDS_FILE);
|
||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(feeds, null, 2));
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
@@ -58,75 +57,26 @@ export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
||||
/** Load sources from JSON file */
|
||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(SOURCES_FILE)
|
||||
const file = Bun.file(filePath)
|
||||
if (!(await file.exists())) return null
|
||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return null;
|
||||
|
||||
const raw = await file.json()
|
||||
if (!Array.isArray(raw)) return null
|
||||
return raw as T[]
|
||||
const raw = await file.json();
|
||||
if (!Array.isArray(raw)) return null;
|
||||
return raw as T[];
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save sources to JSON file */
|
||||
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir()
|
||||
await backupConfigFile(SOURCES_FILE)
|
||||
const filePath = getConfigFilePath(SOURCES_FILE)
|
||||
await Bun.write(filePath, JSON.stringify(sources, null, 2))
|
||||
await ensureConfigDir();
|
||||
await backupConfigFile(SOURCES_FILE);
|
||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
||||
await Bun.write(filePath, JSON.stringify(sources, null, 2));
|
||||
} catch {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user