diff --git a/src/stores/app.ts b/src/stores/app.ts index 973aca6..8265715 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -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(defaultState) + const [state, setState] = createSignal(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) => { const next = { ...state(), settings: { ...state().settings, ...updates }, - } - updateState(next) - } + }; + updateState(next); + }; const updatePreferences = (updates: Partial) => { const next = { ...state(), preferences: { ...state().preferences, ...updates }, - } - updateState(next) - } + }; + updateState(next); + }; const updateCustomTheme = (updates: Partial) => { const next = { ...state(), customTheme: { ...state().customTheme, ...updates }, - } - updateState(next) - } + }; + updateState(next); + }; const updateVisualizer = (updates: Partial) => { 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 | null = null +let appStoreInstance: ReturnType | null = null; export function useAppStore() { if (!appStoreInstance) { - appStoreInstance = createAppStore() + appStoreInstance = createAppStore(); } - return appStoreInstance + return appStoreInstance; } diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 01d2dd1..ea4aca9 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -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() +const fullEpisodeCache = new Map(); /** Track how many episodes are currently loaded per feed */ -const episodeLoadCount = new Map() +const episodeLoadCount = new Map(); /** 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([]) - const [sources, setSources] = createSignal([...DEFAULT_SOURCES]) + const [feeds, setFeeds] = createSignal([]); + const [sources, setSources] = createSignal([ + ...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() - if (loadedSources && loadedSources.length > 0) setSources(loadedSources) - })() + (async () => { + const loadedFeeds = await loadFeedsFromFile(); + if (loadedFeeds.length > 0) setFeeds(loadedFeeds); + const loadedSources = await loadSourcesFromFile(); + if (loadedSources && loadedSources.length > 0) setSources(loadedSources); + })(); const [filter, setFilter] = createSignal({ visibility: "all", sortBy: "updated" as FeedSortField, sortDirection: "desc", - }) - const [selectedFeedId, setSelectedFeedId] = createSignal(null) - const [isLoadingMore, setIsLoadingMore] = createSignal(false) + }); + const [selectedFeedId, setSelectedFeedId] = createSignal(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 => { + const fetchEpisodes = async ( + feedUrl: string, + limit: number, + feedId?: string, + ): Promise => { 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) => { 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) => { 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) => { 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 | null = null +let feedStoreInstance: ReturnType | null = null; export function useFeedStore() { if (!feedStoreInstance) { - feedStoreInstance = createFeedStore() + feedStoreInstance = createFeedStore(); } - return feedStoreInstance + return feedStoreInstance; } diff --git a/src/stores/progress.ts b/src/stores/progress.ts index fa6bbeb..1aa4417 100644 --- a/src/stores/progress.ts +++ b/src/stores/progress.ts @@ -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>({}) +const [progressMap, setProgressMap] = createSignal>( + {}, +); /** 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): Record { - const result: Record = {} +function parseProgressEntries( + raw: Record, +): Record { + const result: Record = {}; for (const [key, value] of Object.entries(raw)) { - const p = value as Record + const p = value as Record; 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 { - await migrateProgressFromLocalStorage() - const raw = await loadProgressFromFile() - const parsed = parseProgressEntries(raw as Record) - setProgressMap(parsed) + const raw = await loadProgressFromFile(); + const parsed = parseProgressEntries(raw as Record); + 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 { - 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 | null = null +let instance: ReturnType | null = null; export function useProgressStore() { if (!instance) { - instance = createProgressStore() + instance = createProgressStore(); } - return instance + return instance; } diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index 4b80ba6..35f2e4d 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -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 { 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 + const parsed = raw as Partial; 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 { 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 { - 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 - 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> { +export async function loadProgressFromFile(): Promise< + Record +> { 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 + const raw = await file.json(); + if (!raw || typeof raw !== "object") return {}; + return raw as Record; } catch { - return {} + return {}; } } /** Save progress map to JSON file */ -export async function saveProgressToFile(data: Record): Promise { +export async function saveProgressToFile( + data: Record, +): Promise { 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 { - 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) - return true - } catch { - return false - } -} diff --git a/src/utils/config-validation.ts b/src/utils/config-validation.ts index ddbeb88..df018e9 100644 --- a/src/utils/config-validation.ts +++ b/src/utils/config-validation.ts @@ -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 { - 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 - 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; + 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 - 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; + 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 - 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; + 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( 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 } -} diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index 2f3a54c..c5eda7b 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -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 { 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 { 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 { /** Load sources from JSON file */ export async function loadSourcesFromFile(): Promise { 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(sources: T[]): Promise { 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 { - 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 { - 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 - } -} diff --git a/src/utils/theme.test.ts b/src/utils/theme.test.ts deleted file mode 100644 index 0704755..0000000 --- a/src/utils/theme.test.ts +++ /dev/null @@ -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() - }) -})