// web/screens/ingest.jsx — экран «Обработать новые». // Структура повторяет дизайн (_design/unzipped/screens/ingest.jsx): // • статистика сверху (всего файлов / групп / дубликатов) // • группа = редактируемый заголовок + дата + кол-во + флаги + кнопки // • strip миниатюр под заголовком с подписями имён и плашкой «дубль md5» // // Поля от сервера (ingest.Scan возвращает ScanResultView = {groups, stale}): // groups: [{id, title, date, files[{filename, yandexPath, md5, modified, // size, mimeType, isDuplicate, dupNote}]}] // stale: [{photoID, shootID, shootTitle, filename, yandexPath, oldMD5, newMD5}] // — фотки из активных серий с изменившимся md5 (Лена перезалила // оригинал). UI показывает их отдельным блоком с кнопкой обновить кеш. function IngestScreen({ setRoute }) { const [scan, reload] = useAsync(() => api.ingest.Scan(), []); const [creating, setCreating] = React.useState(false); // Локальная редакция заголовков и флагов — состояние UI до отправки. const [edits, setEdits] = React.useState({}); // {gid: {title, medium, editorial}} // Локальная копия групп: split/merge редактируют её без обращения к серверу; // настоящие shoots создаются только при «Создать серии». const [groups, setGroups] = React.useState([]); const [splitMode, setSplitMode] = React.useState(null); // id группы в режиме «разделить» // Заголовки уже существующих АКТИВНЫХ серий — чтобы номер при дроблении не // совпал с уже созданной серией в работе (запрос Лены 2026-06-23). Архивные // НЕ учитываем: их имена можно переиспользовать. const [activeTitles, setActiveTitles] = React.useState([]); React.useEffect(() => { api.feed.shoots("false") .then((r) => { if (Array.isArray(r)) { setActiveTitles(r.map((s) => (s.Title || "").trim()).filter(Boolean)); } }) .catch(() => {}); }, []); const [_, toast] = useToasts(); // При новых данных скана — синкаемся: фильтруем дубликаты в отдельный // массив skippedFiles, активные группы кладём в groups (правка 2026-06-03). // Дубликаты не теряются — Лена может развернуть «Уже в работе» и вернуть // нужные в активные через drag&drop (например для перегенерации данных). const [skippedFiles, setSkippedFiles] = React.useState([]); // [{file, fromGroupId}] const [skippedOpen, setSkippedOpen] = React.useState(false); // Stale: фотки активных серий с изменившимся md5 в Я.Диске. const [staleFiles, setStaleFiles] = React.useState([]); // StalePhotoView[] const [staleBusy, setStaleBusy] = React.useState(false); // Когда новых файлов нет, но есть архив — раскрываем блок сразу, чтобы // Лена сразу видела все доступные файлы для возможной перегенерации. React.useEffect(() => { if (skippedFiles.length > 0 && groups.length === 0 && !skippedOpen) { setSkippedOpen(true); } }, [skippedFiles.length, groups.length]); React.useEffect(() => { // Бэк теперь возвращает {groups, stale} вместо массива групп. // На случай старого ответа (массив) — fallback. const payload = scan.data && !Array.isArray(scan.data) ? scan.data : { groups: Array.isArray(scan.data) ? scan.data : [], stale: [] }; const list = payload.groups || []; const stale = payload.stale || []; const skipped = []; const filtered = list .map((g) => { const files = (g.files || []).filter((f) => { // Точные дубли (md5) и «возможные повторы» (тот же путь, изменён) по // умолчанию уводим в «уже обрабатывались» — не включаем в новую серию. if (f.isDuplicate || f.isPossibleRepeat) { skipped.push({ file: f, fromGroupId: g.id, fromGroupTitle: g.title }); return false; } return true; }); // Стабильный порядок по имени файла (префикс-дата + номер кадра // _DSCxxxxx) — чтобы при входе в «Обработать новые» кадры шли по порядку // съёмки, а не как попало с сервера (правка 2026-06-08). files.sort((a, b) => (a.filename || "").localeCompare(b.filename || "", undefined, { numeric: true })); return { ...g, files }; }) .filter((g) => g.files.length > 0); // Развести одинаковые авто-заголовки прямо на скане: внутридневное // дробление по EXIF даёт две серии одного дня с именем «220806», плюс // имя может совпасть с уже активной серией. Первый занявший базу остаётся // как есть (с хвостовым пробелом для дописывания «утро/вечер»), следующие // получают «220806 (1)», «(2)»… const usedTitles = activeTitles.slice(); const numbered = filtered.map((g) => { const base = (g.title || "").trim().replace(/\s*\(\d+\)\s*$/, ""); if (!base) return g; if (!usedTitles.includes(base)) { usedTitles.push(base); return g; // оставляем как есть (хвостовой пробел сохраняется) } const used = new Set(); for (const t of usedTitles) { const tt = (t || "").trim(); if (tt.startsWith(base)) { const m = tt.slice(base.length).match(/^\s*\((\d+)\)$/); if (m) used.add(parseInt(m[1], 10)); } } let n = 1; while (used.has(n)) n++; const title = `${base} (${n}) `; usedTitles.push(title.trim()); return { ...g, title }; }); setSkippedFiles(skipped); setStaleFiles(stale); setGroups(numbered); const next = {}; for (const g of numbered) { next[g.id] = { title: g.title || "", medium: undefined, editorial: false }; } setEdits(next); setSplitMode(null); }, [scan.data]); // «Обновить кеш» для блока изменившихся: POST /v1/photos/refresh-cache c // массивом {photoID, newMD5}. После успеха перевызываем Scan — блок stale // исчезнет (md5 в БД теперь свежий), а новая версия превью подтянется // лениво при следующем открытии серии. const refreshStale = async () => { if (staleFiles.length === 0 || staleBusy) return; setStaleBusy(true); try { const items = staleFiles.map((s) => ({ photoID: s.photoID, newMD5: s.newMD5 })); const r = await fetch("/v1/photos/refresh-cache", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items }), }); if (!r.ok) throw new Error("HTTP " + r.status); const res = await r.json(); toast(`Обновлено: ${res.refreshed}${res.errors.length ? ` (ошибок: ${res.errors.length})` : ""}`, "info"); reload(); } catch (e) { toast("Не удалось обновить: " + e.message, "error"); } finally { setStaleBusy(false); } }; // ─── Drag-and-drop файлов между группами + из «Уже в работе» ─────── // dragFile — {file, sourceGroupId|"skipped"}. setOnDragStart, sync onDrop: // onto активная группа → переместить туда // onto «новая серия» зону → создать новую группу из файла // onto «уже в работе» (только активный → пропуск) — не делаем, дубль // пользователь решает сам по сериям const [dragFile, setDragFile] = React.useState(null); const fileDragStart = (file, sourceGroupId) => { setDragFile({ file, sourceGroupId }); }; const fileDragEnd = () => setDragFile(null); // Переместить файл между группами (или из skipped в активную). const moveFileToGroup = (targetGroupId) => { if (!dragFile) return; const { file, sourceGroupId } = dragFile; if (sourceGroupId === targetGroupId) return; // Удалить из источника. if (sourceGroupId === "skipped") { setSkippedFiles((sf) => sf.filter((x) => x.file.yandexPath !== file.yandexPath)); } else { setGroups((gs) => gs.map((g) => g.id === sourceGroupId ? { ...g, files: g.files.filter((f) => f.yandexPath !== file.yandexPath) } : g ).filter((g) => g.files.length > 0)); } // Добавить в цель. // Для возврата из skipped — снимаем флаг isDuplicate (иначе фильтр payload его выбросит). const cleanFile = sourceGroupId === "skipped" ? { ...file, isDuplicate: false, isPossibleRepeat: false } : file; setGroups((gs) => gs.map((g) => g.id === targetGroupId ? { ...g, files: [...g.files, cleanFile] } : g )); fileDragEnd(); }; // Перетаскивание файла в «новую серию» — создаёт новую группу с заголовком // по дате из имени. const moveFileToNewGroup = () => { if (!dragFile) return; const { file, sourceGroupId } = dragFile; if (sourceGroupId === "skipped") { setSkippedFiles((sf) => sf.filter((x) => x.file.yandexPath !== file.yandexPath)); } else { setGroups((gs) => gs.map((g) => g.id === sourceGroupId ? { ...g, files: g.files.filter((f) => f.yandexPath !== file.yandexPath) } : g ).filter((g) => g.files.length > 0)); } const cleanFile = sourceGroupId === "skipped" ? { ...file, isDuplicate: false, isPossibleRepeat: false } : file; const newID = "manual_" + Date.now(); const m = (file.filename || "").match(/^(\d{6})/); const base = m ? m[1] : ""; // Имя новой серии: если такая дата уже есть среди групп — нумеруем «(N)». setGroups((gs) => { const title = assignSeriesTitle(gs, base); setEdits((e) => ({ ...e, [newID]: { title, medium: undefined, editorial: false } })); return [...gs, { id: newID, title, date: file.modified || "", files: [cleanFile] }]; }); fileDragEnd(); }; // splitAt — режет группу gid после индекса afterIdx (1..len-1). const splitAt = (gid, afterIdx) => { setGroups((gs) => { const i = gs.findIndex((g) => g.id === gid); if (i < 0) return gs; const g = gs[i]; if (afterIdx <= 0 || afterIdx >= g.files.length) return gs; const a = { ...g, files: g.files.slice(0, afterIdx) }; const newID = g.id + "_" + Date.now(); // Заголовок отрезанного куска — корень по дате-префиксу первого файла // (иначе по заголовку исходной группы без суффикса) + свободный «(N)». const firstName = g.files[afterIdx].filename || ""; const m = firstName.match(/^(\d{6})/); const base = m ? m[1] : titleRoot(effTitle(g)); const childTitle = assignSeriesTitle(gs, base); const b = { ...g, id: newID, files: g.files.slice(afterIdx), title: childTitle, }; // Заодно поднимаем edits для новой группы — наследуем флаги. setEdits((e) => ({ ...e, [newID]: { ...(e[gid] || {}), title: childTitle } })); return [...gs.slice(0, i), a, b, ...gs.slice(i + 1)]; }); }; // mergeDown — склеивает текущую группу gid со следующей. const mergeDown = (gid) => { setGroups((gs) => { const i = gs.findIndex((g) => g.id === gid); if (i < 0 || i >= gs.length - 1) return gs; const a = gs[i], b = gs[i + 1]; const merged = { ...a, files: [...a.files, ...b.files] }; return [...gs.slice(0, i), merged, ...gs.slice(i + 2)]; }); // Удаляем edits второй группы (она больше не существует). setEdits((e) => { const next = { ...e }; const i = groups.findIndex((g) => g.id === gid); if (i >= 0 && i < groups.length - 1) delete next[groups[i + 1].id]; return next; }); }; const setEdit = (gid, patch) => { setEdits((e) => ({ ...e, [gid]: { ...e[gid], ...patch } })); }; // ─── Нумерация имён при дроблении ────────────────────────────────── // Когда одну серию режут на несколько (split) или вытаскивают файл в // «новую серию», у кусков получался ОДИН и тот же заголовок (дата-префикс, // напр. «260617») — в списке серий потом не различить. Теперь отрезанные // куски получают суффикс «260617 (1)», «(2)»… а оригинал остаётся «260617» // (запрос Лены 2026-06-23). const effTitle = (g) => { const e = edits[g.id]; const t = e && e.title !== undefined ? e.title : (g.title || ""); return (t || "").trim(); }; // titleRoot убирает уже имеющийся суффикс «(N)» — чтобы при повторном // дроблении не плодить «260617 (1) (1)». const titleRoot = (t) => (t || "").trim().replace(/\s*\(\d+\)\s*$/, ""); // assignSeriesTitle: если «голый» base ещё никем не занят — отдаём его как // есть; иначе подбираем наименьший свободный суффикс «base (N)». gs — текущий // список групп (читаем эффективные заголовки с учётом ручных правок). const assignSeriesTitle = (gs, base) => { const b = (base || "").trim(); if (!b) return ""; // Учитываем И группы текущего батча, И заголовки активных серий в работе. const titles = [...gs.map(effTitle), ...activeTitles]; if (!titles.includes(b)) return b; const used = new Set(); for (const t of titles) { if (t.startsWith(b)) { const m = t.slice(b.length).match(/^\s*\((\d+)\)$/); if (m) used.add(parseInt(m[1], 10)); } } let n = 1; while (used.has(n)) n++; return `${b} (${n})`; }; const totalFiles = groups.reduce((n, g) => n + (g.files || []).length, 0); const create = async () => { if (!groups.length) return; // Выходим из режима «разделить» если он активен. Раньше Лена нажимала // «Создать серии» прямо из режима разделения, и казалось что правки // не сохраняются (правка 2026-06-03). На самом деле они сохранялись, // но визуально это путало; теперь явный выход из режима делает поведение // более предсказуемым. setSplitMode(null); const payload = groups .map((g) => { const e = edits[g.id] || {}; return { title: (e.title || g.title || "").trim(), contentType: e.medium || "photo", isEditorial: !!e.editorial, files: (g.files || []) .filter((f) => !f.isDuplicate && !f.isPossibleRepeat) .map((f) => ({ filename: f.filename, yandexPath: f.yandexPath, md5: f.md5, modified: f.modified, })), }; }) .filter((g) => g.files.length > 0); if (payload.length === 0) { toast("Во всех группах только дубликаты — создавать нечего", "error"); return; } setCreating(true); try { const ids = await api.ingest.CreateShoots(payload); const n = Array.isArray(ids) ? ids.length : payload.length; toast(`Создано ${n} серий`, "info"); setRoute("shoots"); } catch (e) { toast("Не удалось создать серии: " + e.message, "error"); } finally { setCreating(false); } }; return (