remove migration code
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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