diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index d587492c..62aeda4c 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -545,21 +545,62 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma // ── Per-group track config (one run handles every layout) ── -// Kept-ids csv per group ('none' = drop all). A group with every track kept is a no-op. -const groupRemoves = (csv: string, trackCount: number) => { - if (csv === 'none') return true; - const kept = new Set(csv.split(',').filter(Boolean).map(Number)); - return kept.size < trackCount; +// Which per-group pickers a task declares. +type PickerKinds = { audio: boolean; subs: boolean; subEdit: boolean }; +// Per-group selection: audio/subs are kept-id csv (or 'none'); subEdit is the keep+label list. +type GroupSel = { audio: string; subs: string; subEdit: SubtitleEditEntry[] }; + +const parseCsv = (csv: string) => new Set(csv.split(',').filter(Boolean).map(Number).filter((n) => !Number.isNaN(n))); + +const pickerKinds = (defs: Record | null): PickerKinds => ({ + audio: Object.values(defs ?? {}).some((d) => d.type === 'audio_tracks'), + subs: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_tracks'), + subEdit: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_edit'), +}); + +const defaultGroupSel = (g: FolderTrackGroup): GroupSel => ({ + audio: g.audioTracks.map((t) => t.id).join(','), // keep all + subs: g.subtitleTracks.map((t) => t.id).join(','), // keep all + subEdit: g.subtitleTracks.map((t) => ({ id: t.id, keep: true, label: trackLabel(t) })), +}); + +// Does a group's selection change anything vs keep-all + original labels? +const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => { + if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length)) return true; + if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length)) return true; + if (has.subEdit) { + for (const t of g.subtitleTracks) { + const e = s.subEdit.find((x) => x.id === t.id); + if (!e || !e.keep || e.label !== trackLabel(t)) return true; + } + } + return false; }; -type PerGroupAudioConfigProps = { +// Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps +// every group; otherwise only groups that actually change are included. +const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => { + const spec = (csv: string, count: number) => (csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(',')); + return groups + .map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) })) + .filter(({ g, s }) => allFiles || groupChanges(g, s, has)) + .map(({ g, s }) => ({ + files: g.files, + ...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}), + ...(has.subs ? { subs: spec(s.subs, g.subtitleTracks.length) } : {}), + ...(has.subEdit ? { subEdit: g.subtitleTracks.map((t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }) } : {}), + })); +}; + +type PerGroupTrackConfigProps = { groups: FolderTrackGroup[]; - values: string[]; // per-group kept-ids csv (or 'none') - onChange: (groupIdx: number, value: string) => void; + has: PickerKinds; + sel: GroupSel[]; + onChange: (groupIdx: number, patch: Partial) => void; probing?: boolean; }; -const PerGroupAudioConfig = ({ groups, values, onChange, probing }: PerGroupAudioConfigProps) => { +const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTrackConfigProps) => { if (probing) { return (
@@ -574,42 +615,100 @@ const PerGroupAudioConfig = ({ groups, values, onChange, probing }: PerGroupAudi
); } + const label = (kind: string) => {kind}; return (
- {groups.length} track layout{groups.length !== 1 ? 's' : ''} — uncheck tracks to remove. All groups run in one pass. + {groups.length} track layout{groups.length !== 1 ? 's' : ''} — configure each; all groups run in one pass. {groups.map((g, gi) => { - const csv = String(values[gi] ?? ''); - const selected = new Set(csv.split(',').filter(Boolean).map(Number).filter((n) => !Number.isNaN(n))); - const toggle = (id: number) => { - const next = new Set(selected); - if (next.has(id)) next.delete(id); - else next.add(id); - const ids = [...next].sort((a, b) => a - b); - onChange(gi, ids.length > 0 ? ids.join(',') : 'none'); + const s = sel[gi] ?? defaultGroupSel(g); + const aSel = parseCsv(s.audio); + const sSel = parseCsv(s.subs); + const toggleA = (id: number) => { + const n = new Set(aSel); + n.has(id) ? n.delete(id) : n.add(id); + onChange(gi, { audio: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' }); }; - const removing = g.audioTracks.length - selected.size; + const toggleS = (id: number) => { + const n = new Set(sSel); + n.has(id) ? n.delete(id) : n.add(id); + onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' }); + }; + const entryFor = (t: SubtitleTrack): SubtitleEditEntry => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }; + const updateEdit = (id: number, patch: Partial) => + onChange(gi, { subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))) }); return ( -
-
- - Group {gi + 1} - · {g.count} file{g.count !== 1 ? 's' : ''} - - {removing > 0 ? `removing ${removing}` : 'no change'} -
-
- {g.audioTracks.map((t) => ( - - ))} -
+
+ + Group {gi + 1} + · {g.count} file{g.count !== 1 ? 's' : ''} + + + {has.audio && ( +
+ {label('audio')} + {g.audioTracks.length === 0 ? ( + none + ) : ( + g.audioTracks.map((t) => ( + + )) + )} +
+ )} + + {has.subs && ( +
+ {label('subtitles')} + {g.subtitleTracks.length === 0 ? ( + none + ) : ( + g.subtitleTracks.map((t) => ( + + )) + )} +
+ )} + + {has.subEdit && ( +
+ {label('subtitles')} + {g.subtitleTracks.length === 0 ? ( + none + ) : ( + g.subtitleTracks.map((t) => { + const e = entryFor(t); + return ( +
+ updateEdit(t.id, { keep: !e.keep })} className="accent-duck-teal cursor-pointer shrink-0" /> + {t.lang || 'und'} + updateEdit(t.id, { label: ev.target.value })} + className="flex-1 min-w-0 px-2 py-1 text-sm rounded-md border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal disabled:opacity-40" + /> +
+ ); + }) + )} +
+ )} +
files
    @@ -659,17 +758,21 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa const [folderKeepAll, setFolderKeepAll] = useState(false); // Task opts into per-group track config (one run, each layout its own selection) via config.perGroupTracks. const [perGroupTracks, setPerGroupTracks] = useState(false); + // perGroupAllFiles: process every file even when a group keeps everything (convert — a format change + // is still work). Otherwise only groups that actually change are run (track-only edits). + const [perGroupAllFiles, setPerGroupAllFiles] = useState(false); const [allGroups, setAllGroups] = useState([]); - const [groupSel, setGroupSel] = useState([]); // per-group kept-ids csv (or 'none') + const [groupSel, setGroupSel] = useState([]); // Fetch task detail to get input definitions useEffect(() => { client - .get<{ inputs?: Record; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean } }>(`/tasks/${taskDirName}`) + .get<{ inputs?: Record; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`) .then((task) => { const defs: Record = task.inputs ?? {}; setFolderKeepAll(task.config?.folderKeepAll === true); setPerGroupTracks(task.config?.perGroupTracks === true); + setPerGroupAllFiles(task.config?.perGroupAllFiles === true); setInputDefs(defs); // Initialize from autofill context, then defaults const initial: Record = {}; @@ -731,9 +834,9 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa : probe.groups; const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount; - // Per-group mode uses every group with its own picker, each defaulting to keep-all. + // Per-group mode uses every group with its own pickers, each defaulting to keep-all. setAllGroups(groups); - setGroupSel(groups.map((g) => g.audioTracks.map((t) => t.id).join(',') || 'none')); + setGroupSel(groups.map(defaultGroupSel)); const majority = groups[0]; if (!majority) { @@ -793,21 +896,17 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa (d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks', ); - // Per-group mode: any group that removes at least one track is real work. + // Which per-group pickers this task declares, and whether the current selection is real work. + const has = pickerKinds(inputDefs); const perGroupHasWork = - perGroupTracks && entryType === 'directory' && allGroups.some((g, gi) => groupRemoves(String(groupSel[gi] ?? ''), g.audioTracks.length)); + perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0; const handleRun = () => { - // Per-group config: flatten each group's selection to a per-file "\t" map, so a - // single run handles every layout. No-op groups (all tracks kept) are left out. + // Per-group config: serialize each layout's selection to $INPUT_GROUP_CONFIG (JSON), so a single + // run handles every group. For track-only edits, no-op groups are left out; convert keeps all. if (perGroupTracks && entryType === 'directory') { - const lines: string[] = []; - allGroups.forEach((g, gi) => { - const csv = String(groupSel[gi] ?? ''); - if (!groupRemoves(csv, g.audioTracks.length)) return; - for (const f of g.files) lines.push(`${csv || 'none'}\t${f}`); - }); - const allInputs = { ...formValues, ...(lines.length ? { track_map: lines.join('\n') } : {}), ...autoInputs }; + const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles); + const allInputs = { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs }; runner.run(taskDirName, allInputs, cwd); return; } @@ -833,10 +932,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
)} {perGroupTracks && entryType === 'directory' ? ( - setGroupSel((prev) => prev.map((s, i) => (i === gi ? v : s)))} + has={has} + sel={groupSel} + onChange={(gi, patch) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? { ...s, ...patch } : s)))} probing={probing} /> ) : (