Files
ersatztv/web/src/mediaSources/paths.ts
T
timothyandClaude Opus 4.8 6ce448d265 feat(spa): media-source SPA foundation — shared client, helpers, App-owned popstate wrapper (#202 slice S5)
Owns the shared single-files so the S6a (Local) / S6b (Remote) editor slices touch
disjoint files. Editor screens are stubbed (MediaSourceEditorPlaceholder) for S6.

- web/src/api/mediaSources.ts (+test): client module over the new media-source write
  endpoints (local CRUD + move/path-exists; Plex pin-flow/sign-out; shared remote
  state/connection/libraries/path-replacements/refresh; family = only URL variance),
  DTOs re-exported from generated v1, messageFromMediaSourcesError; barrel export.
- web/src/mediaSources/{familyMeta,paths,pinFlowPoll}.ts (+tests): family labels/routes/
  remote-path column naming (owns RemoteFamily); client-side NormalizePath mirror for
  in-draft dup detection; pure §C1 pin-flow poll state machine (waiting/finalizing/
  success/timeout/budget-exhausted), timer-free and fully unit-tested.
- routing.ts parseLibrariesSubRoute + LibrariesSubRoute union split Local vs Remote.
- App.tsx: libraries route allowSubPaths; LibrariesRouteScreen wrapper dispatching a
  flat switch to placeholders; App-owned popstate (finding 4) — App is the single
  popstate owner, consults canLeaveCurrentScreen() and only on approval updates
  librariesSubPath passed DOWN to the wrapper (wrapper never self-listens); state write
  scoped to the libraries route so Playouts/Media pops stay byte-identical (nit 3).
- LibrariesScreen hub wiring: Add-Source menu (Local/Plex/Jellyfin/Emby), remote source
  gear -> family screen, local library row gear -> edit route; removed the disabled
  Scan-All button + the deferred-sources card (§C7/§D.1).
- Tests: App-owned-popstate dirty-guard case (confirm false keeps URL+sub-screen; true
  navigates); mediaSources client URL/verb mapping; pinFlowPoll transitions; familyMeta/
  paths units; hub-wiring navigation.
- docs/spa-conventions.md §8 (resolved sub-path+dirty-guard caveat -> App-owned popstate)
  + §2 exemplar list (guarded-route exception).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:11:16 +02:00

30 lines
1.6 KiB
TypeScript

// Client-side path normalization for in-draft duplicate detection in the local-library editor.
//
// This mirrors the *comparison-relevant* part of the Blazor `UpdateLocalLibraryHandler.NormalizePath`
// (`Path.GetFullPath(new Uri(path).LocalPath).TrimEnd(sep, altSep).ToUpperInvariant()`), which the
// server uses to merge/dedupe library paths by normalized string (design §C4c). A browser cannot
// reproduce `Path.GetFullPath` (it resolves relative paths against the server CWD and collapses
// `.`/`..`) — that resolution stays authoritative on the server. What we CAN and must mirror for
// catching an obvious in-draft duplicate before a round-trip is the trailing-separator trim and the
// case-insensitive (upper-invariant) compare. Two already-absolute paths that differ only by a
// trailing slash or letter case are treated as the same path here, exactly as the server would.
/** Normalize a path for case/trailing-separator-insensitive comparison (browser-side subset). */
export function normalizePath(path: string): string {
return path.replace(/[\\/]+$/, '').toUpperCase();
}
/** True when `a` and `b` name the same path under {@link normalizePath}. */
export function pathsEqual(a: string, b: string): boolean {
return normalizePath(a) === normalizePath(b);
}
/**
* True when `candidate` already exists (by normalized string) in `existing`. Used to reject an
* Add-Path that duplicates a path already in the draft.
*/
export function isDuplicatePath(existing: readonly string[], candidate: string): boolean {
const normalizedCandidate = normalizePath(candidate);
return existing.some((path) => normalizePath(path) === normalizedCandidate);
}