refactor(feed): episode windows, source registry, dead interface
episode-windows.ts: keep-fn, date-band walk (was copy-pasted twice), sameRefreshWindow — one pure module. source-registry.ts owns source CRUD + its persistence. Dead members with zero callers deleted: updateFeed, togglePinned, removeSource, setFilter/selectedFeedId. Feed persistence routed through the persist scheduler.
This commit is contained in:
111
src/utils/episode-windows.ts
Normal file
111
src/utils/episode-windows.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Episode cache-window math — shared by the feed store's refresh, retention,
|
||||
* and fetch-more paging paths. Pure module: no Solid, no store imports.
|
||||
*/
|
||||
|
||||
import type { Episode } from "../types/episode";
|
||||
import { episodeSignature } from "./episode-merge";
|
||||
import { episodeInWindow } from "./feeds-persistence";
|
||||
|
||||
/** Floor on the visible episode window for a subscribed show: at least this
|
||||
* many most-recent episodes always load, regardless of a stricter count or
|
||||
* date cache bound. */
|
||||
const MIN_EPISODES_PER_SHOW = 5;
|
||||
|
||||
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
||||
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
||||
const FETCH_MORE_WINDOW_DAYS = 14;
|
||||
|
||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
||||
* (Infinity) so they can never be excluded by a date cutoff. */
|
||||
export const episodeTs = (ep: Episode): number => {
|
||||
const t = ep.pubDate?.getTime();
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
||||
};
|
||||
|
||||
/** Read the episode cache bound from preferences: a closure that decides
|
||||
* whether the episode at `index` (0 = newest, after sort) is kept. The five
|
||||
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
|
||||
* overriding a stricter count or date bound so every show surfaces at least
|
||||
* five episodes. */
|
||||
export function episodeKeepFn(
|
||||
prefs: {
|
||||
episodeCacheMode: "date" | "count";
|
||||
episodeCacheCount: number;
|
||||
episodeCacheDays: number;
|
||||
},
|
||||
now?: Date,
|
||||
): (ep: Episode, index: number) => boolean {
|
||||
const at = now ?? new Date();
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const count = Math.max(1, prefs.episodeCacheCount);
|
||||
return (_ep: Episode, index: number) =>
|
||||
index < Math.max(count, MIN_EPISODES_PER_SHOW);
|
||||
}
|
||||
const days = Math.max(1, prefs.episodeCacheDays);
|
||||
return (ep: Episode, index: number) =>
|
||||
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, at, days);
|
||||
}
|
||||
|
||||
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
|
||||
* 2-week band. With nothing loaded (a show whose episodes all fall outside
|
||||
* the cache window), the band anchors at the cache-window edge (now minus
|
||||
* the configured days) — a dormant show can't drag in arbitrarily old
|
||||
* episodes just because the button is pressed. */
|
||||
export const dateFetchMoreCutoff = (
|
||||
cached: Episode[],
|
||||
loaded: number,
|
||||
windowDays: number,
|
||||
): number => {
|
||||
if (loaded > 0) {
|
||||
const t = episodeTs(cached[loaded - 1]);
|
||||
if (Number.isFinite(t)) {
|
||||
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
|
||||
// cache-window edge (e.g. 60d → reveals the 60–74d slice).
|
||||
return (
|
||||
Date.now() -
|
||||
Math.max(1, windowDays) * 24 * 3600 * 1000 -
|
||||
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
|
||||
);
|
||||
};
|
||||
|
||||
/** Episodes the date band adds past the loaded window: count forward while
|
||||
* each next cached episode still falls on/after the cutoff. The single
|
||||
* implementation behind both fetch-more paths (one feed / all feeds) — an
|
||||
* empty band adds nothing, which doubles as the "has more" guard. */
|
||||
export function dateBandCount(
|
||||
cached: Episode[],
|
||||
loaded: number,
|
||||
cutoff: number,
|
||||
): number {
|
||||
let count = loaded;
|
||||
while (count < cached.length && episodeTs(cached[count]) >= cutoff) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** True when the freshly fetched window matches the corresponding PREFIX of
|
||||
* the existing episode list (id-set equality, order-insensitive). With
|
||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||
* fetched window, so unchanged-detection must compare the fetched window
|
||||
* against the existing list's prefix — comparing full lists would bump
|
||||
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
||||
* one-time positional-id migration, or a feed that rotates enclosure URLs)
|
||||
* the id sets differ for the SAME content, so a content-signature
|
||||
* comparison decides: an unchanged feed stays unchanged. */
|
||||
export function sameRefreshWindow(
|
||||
existing: Episode[],
|
||||
fetched: Episode[],
|
||||
): boolean {
|
||||
if (fetched.length === 0) return true;
|
||||
const prefix = existing.slice(0, fetched.length);
|
||||
const ids = new Set(prefix.map((e) => e.id));
|
||||
if (fetched.every((e) => ids.has(e.id))) return true;
|
||||
if (prefix.length !== fetched.length) return false;
|
||||
const signatures = new Set(prefix.map(episodeSignature));
|
||||
return fetched.every((e) => signatures.has(episodeSignature(e)));
|
||||
}
|
||||
59
src/utils/source-registry.ts
Normal file
59
src/utils/source-registry.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Source registry — owns the podcast source list and its immediate file
|
||||
* persistence. The feed store seeds it at boot and wires the loaded list in.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
import { saveSourcesToFile } from "./feeds-persistence";
|
||||
|
||||
/** Create a source registry around the given initial list. Every mutation
|
||||
* persists immediately (async, fire-and-forget) — source edits are rare
|
||||
* and must not sit in a debounce window across a process exit. */
|
||||
export function createSourceRegistry(initial: PodcastSource[]) {
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([...initial]);
|
||||
|
||||
/** Swap in a fully rebuilt list WITHOUT persisting — the boot-time
|
||||
* loader saves only when its migration actually changed data. */
|
||||
const replaceAll = (list: PodcastSource[]): void => {
|
||||
setSources(list);
|
||||
};
|
||||
|
||||
const addSource = (source: Omit<PodcastSource, "id">): PodcastSource => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSourcesToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
|
||||
const updateSource = (
|
||||
sourceId: string,
|
||||
updates: Partial<PodcastSource>,
|
||||
): void => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSourcesToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSource = (sourceId: string): void => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSourcesToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return { sources, replaceAll, addSource, updateSource, toggleSource };
|
||||
}
|
||||
Reference in New Issue
Block a user