import slugify from "@sindresorhus/slugify"; import { isValidGroupId } from "yt-dlp-transcript-common/lib/channelGroups"; import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { getSite, listSiteIds, writeSite, type Site, type SiteChannelMembership, } from "yt-dlp-transcript-common/lib/site"; // Server-side half of the channel form's "Sites" membership section (see // SiteMembershipsSection.tsx). The form serializes one entry per CHECKED site // into the hidden `siteMembershipsJson` field; a site absent from the array // means "not a member" (removal on edit). The field being absent entirely // (zero sites configured, or a legacy submit) means "don't touch memberships". export type MembershipRequest = { siteId: string; // Explicit existing group on that site; absent = the site's default group. groupId?: string; // Mutually exclusive with groupId: create this group (id = slugify(name)) // on the site and assign the channel to it. newGroupName?: string; }; // Parse the raw hidden-field value. Returns null when the field is absent so // callers can skip membership reconciliation entirely; throws on a payload // that isn't a JSON array (never produced by our form). export function parseSiteMembershipsField( raw: FormDataEntryValue | null, ): MembershipRequest[] | null { if (typeof raw !== "string") return null; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error("Malformed site memberships payload"); } if (!Array.isArray(parsed)) { throw new Error("Malformed site memberships payload"); } const out: MembershipRequest[] = []; const seen = new Set(); for (const entry of parsed) { if (!entry || typeof entry !== "object") continue; const r = entry as Record; if (typeof r.siteId !== "string" || !r.siteId || seen.has(r.siteId)) { continue; } seen.add(r.siteId); const req: MembershipRequest = { siteId: r.siteId }; // newGroupName wins when both are present (the form never emits both). if (typeof r.newGroupName === "string" && r.newGroupName.trim()) { req.newGroupName = r.newGroupName.trim(); } else if (typeof r.groupId === "string" && r.groupId.trim()) { req.groupId = r.groupId.trim(); } out.push(req); } return out; } // Compute the Site objects that need rewriting so `slug`'s membership matches // `requests`. Pure planning: reads every configured site, validates the // requests (throws a user-facing Error on bad input), and returns ONLY the // sites whose groups/membership actually changed — a no-op save rewrites // nothing. Requests naming unknown siteIds are silently skipped (the site was // deleted since the form loaded). export function planSiteMembershipWrites( paths: Paths, slug: string, requests: MembershipRequest[], ): Site[] { const bySiteId = new Map(requests.map((r) => [r.siteId, r])); const changed: Site[] = []; for (const siteId of listSiteIds(paths)) { const site = getSite(siteId, paths); const request = bySiteId.get(siteId); const existing = site.channels.find((c) => c.slug === slug); // Resolve the target group (possibly creating one) for a checked site. let groups = site.groups; let groupId: string | undefined; if (request) { if (request.newGroupName) { const id = slugify(request.newGroupName); if (!isValidGroupId(id)) { throw new Error( `Could not derive a valid group id from "${request.newGroupName}" (site "${siteId}")`, ); } // Same slug as an existing group -> reuse it (idempotent resubmits). if (!groups.some((g) => g.id === id)) { // Matches SiteForm's addGroup default: new groups start unselected // and without the inline flag (only the seeded default group renders // its channels as loose chips) — deliberate, not an omission. groups = [ ...groups, { id, name: request.newGroupName, selectedByDefault: false }, ]; } groupId = id; } else if (request.groupId) { // Explicit group must still exist — writeSite would silently drop an // unknown groupId from the membership, so fail loudly instead. if (!groups.some((g) => g.id === request.groupId)) { throw new Error( `Group "${request.groupId}" no longer exists on site "${siteId}"`, ); } groupId = request.groupId; } } // Next membership list: preserve every other channel's entry untouched and // keep this channel's existing extra fields (notably `order`). let channels: SiteChannelMembership[]; if (!request) { channels = existing ? site.channels.filter((c) => c.slug !== slug) : site.channels; } else if (existing) { channels = site.channels.map((c) => { if (c.slug !== slug) return c; if (groupId) return { ...c, groupId }; const { groupId: _drop, ...rest } = c; return rest; }); } else { channels = [...site.channels, groupId ? { slug, groupId } : { slug }]; } const groupsChanged = groups !== site.groups; const membershipChanged = request ? !existing || (existing.groupId ?? undefined) !== groupId : !!existing; if (groupsChanged || membershipChanged) { changed.push({ ...site, groups, channels }); } } return changed; } export async function applySiteWrites( sites: Site[], paths: Paths, ): Promise { for (const site of sites) { await writeSite(site, paths); } }