import { extname } from "node:path"; import { checkRelPath } from "./relpath"; /** * Large media of a converted import stay out of git (v1.1 S, docs/operations.md "Imported courses in the studio"). * The repo commits `MEDIA_REFS` (path, size and checksum per file, spec 5); the repo's `info/exclude` (not the * author-editable `.gitignore`) gets one line per path, and the studio never counts them as changes. Every studio worktree gets the files as hard links to `/media//`. */ export const MEDIA_REFS = ".stcourses/media.json"; export const LARGE_FILE_BYTES = 8 * 1024 * 1024; const MEDIA_EXT = new Set(["mp4", "m4v", "webm", "mov", "mkv", "avi", "mp3", "m4a", "wav", "ogg", "oga", "opus", "flac"]); export interface MediaRef { path: string; size: number; sha256: string } /** Video and audio always; anything else only when it is over `LARGE_FILE_BYTES`. */ export function isLargeMedia(rel: string, size: number): boolean { return MEDIA_EXT.has(extname(rel).slice(1).toLowerCase()) || size > LARGE_FILE_BYTES; } /** One anchored `.gitignore` pattern matching exactly `rel`. Throws for names a `.gitignore` line cannot hold. */ export function gitignoreLine(rel: string): string { if (/[\n\r]/.test(rel)) throw new Error(`a file name with a line break cannot be kept out of git: ${JSON.stringify(rel)}`); let s = rel.replace(/[\\*?[\]]/g, (c) => `\\${c}`); const trail = /( +)$/.exec(s)?.[1]?.length ?? 0; if (trail) s = s.slice(0, -trail) + "\\ ".repeat(trail); return `/${s}`; } export const MEDIA_EXCLUDE_HEADER = "# Imported media kept outside git: see .stcourses/media.json (STCourses)"; /** Ignore-file content: `existing` (if any) first, then the header and one line per referenced file. */ export function mediaGitignore(paths: string[], existing: string | null): string { const own = existing ? (existing.endsWith("\n") ? existing : existing + "\n") + "\n" : ""; return `${own}${MEDIA_EXCLUDE_HEADER}\n${paths.map(gitignoreLine).join("\n")}\n`; } export async function sha256File(abs: string): Promise { const h = new Bun.CryptoHasher("sha256"); for await (const chunk of Bun.file(abs).stream()) h.update(chunk); return h.digest("hex"); } export function stringifyMediaRefs(files: MediaRef[]): string { return JSON.stringify({ schema: 1, files }, null, 2) + "\n"; } /** The reference list, or null when the text is not a valid one (every path must pass `checkRelPath`). */ export function parseMediaRefs(text: string): MediaRef[] | null { let raw: unknown; try { raw = JSON.parse(text); } catch { return null; } const r = raw as { schema?: unknown; files?: unknown } | null; if (!r || r.schema !== 1 || !Array.isArray(r.files)) return null; const out: MediaRef[] = []; for (const f of r.files as Partial[]) { if (!f || typeof f.path !== "string" || typeof f.size !== "number" || !Number.isInteger(f.size) || f.size < 0) return null; if (typeof f.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(f.sha256)) return null; try { if (checkRelPath(f.path) !== f.path) return null; } catch { return null; } out.push({ path: f.path, size: f.size, sha256: f.sha256 }); } return out; }