mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
feat(anime): filter episodes and mark them watched by hand
- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter - Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus - Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched - Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Filtering for the episode list.
|
||||
*
|
||||
* A season's worth of episodes does not fit on screen, and sources hand them
|
||||
* over in whatever order they please, so the list needs a way to jump straight
|
||||
* to one. The query is deliberately forgiving: a number finds that episode, a
|
||||
* range finds a span of them, and anything else is a substring of the name.
|
||||
*/
|
||||
|
||||
export interface FilterableEpisode {
|
||||
/** Effective episode number, after the list's fallback numbering. */
|
||||
number: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type EpisodeFilter =
|
||||
| { kind: 'number'; value: number; text: string }
|
||||
| { kind: 'range'; from: number; to: number }
|
||||
| { kind: 'text'; text: string };
|
||||
|
||||
const NUMBER = String.raw`\d{1,4}(?:\.\d+)?`;
|
||||
const RANGE_PATTERN = new RegExp(`^(${NUMBER})\\s*[-–—~]\\s*(${NUMBER})$`);
|
||||
const NUMBER_PATTERN = new RegExp(`^(${NUMBER})$`);
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a query into what it asks for, or null when it asks for nothing.
|
||||
*
|
||||
* A bare number stays a text match as well as a numeric one: sources put the
|
||||
* number in the name often enough ("Episode 12 - …") that a source reporting no
|
||||
* numbers at all would otherwise filter to nothing.
|
||||
*/
|
||||
export function parseEpisodeFilter(query: string): EpisodeFilter | null {
|
||||
const text = normalize(query);
|
||||
if (!text) return null;
|
||||
|
||||
const range = text.match(RANGE_PATTERN);
|
||||
if (range) {
|
||||
const first = Number.parseFloat(range[1]!);
|
||||
const second = Number.parseFloat(range[2]!);
|
||||
// "18-12" is a typo, not an empty range.
|
||||
return { kind: 'range', from: Math.min(first, second), to: Math.max(first, second) };
|
||||
}
|
||||
|
||||
const number = text.match(NUMBER_PATTERN);
|
||||
if (number) {
|
||||
return { kind: 'number', value: Number.parseFloat(number[1]!), text: text.toLowerCase() };
|
||||
}
|
||||
|
||||
return { kind: 'text', text: text.toLowerCase() };
|
||||
}
|
||||
|
||||
export function matchesEpisodeFilter(episode: FilterableEpisode, filter: EpisodeFilter): boolean {
|
||||
const name = episode.name.toLowerCase();
|
||||
switch (filter.kind) {
|
||||
case 'number':
|
||||
return episode.number === filter.value || name.includes(filter.text);
|
||||
case 'range':
|
||||
return (
|
||||
episode.number !== null && episode.number >= filter.from && episode.number <= filter.to
|
||||
);
|
||||
case 'text':
|
||||
return name.includes(filter.text);
|
||||
}
|
||||
}
|
||||
|
||||
export function filterEpisodes<T extends FilterableEpisode>(episodes: T[], query: string): T[] {
|
||||
const filter = parseEpisodeFilter(query);
|
||||
if (!filter) return episodes;
|
||||
return episodes.filter((episode) => matchesEpisodeFilter(episode, filter));
|
||||
}
|
||||
Reference in New Issue
Block a user