// 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 (

Обработать новые

Прочитано из _Pikasrv/ на Яндекс Диске · сверено по md5
{/* Статистика сверху — показываем когда есть ХОТЯ БЫ что-то: либо активные группы, либо файлы в «уже в работе», либо stale-фотки из активных серий (правка 2026-06-03 / 2026-06-05). */} {(groups.length > 0 || skippedFiles.length > 0 || staleFiles.length > 0) && (
{totalFiles}
Новых файлов
{groups.length}
Предложено серий
0 ? "pointer" : "default" }} onClick={() => skippedFiles.length > 0 && setSkippedOpen(!skippedOpen)} title={skippedFiles.length > 0 ? "Клик — раскрыть/скрыть список" : ""}>
{skippedFiles.length}
Уже в работе{skippedFiles.length > 0 && ( )}
md5
Группировка по дате в имени
Порог разрыва времени 1ч 30мин
)} {scan.loading &&
сканируем Я.Диск…
} {scan.error && (
Ошибка скана: {scan.error.message}
)} {/* Stale-блок: фотки активных серий, у которых md5 в Я.Диске не совпадает с тем, что в БД (Лена перезалила оригинал). Кнопка «Обновить кеш» дёргает /v1/photos/refresh-cache и стирает 512px-копии, чтобы при следующем просмотре подтянуть свежую версию. */} {staleFiles.length > 0 && (
В активных сериях изменилось фото: {staleFiles.length}
После обновления превью и распознавание увидят свежую версию из Я.Диска. Старые анализы / тексты сохраняются — если надо переделать, жми «Сбросить анализ» в серии.
Показать список
{staleFiles.map((sp) => (
{sp.shootTitle?.trim() || `#${sp.shootID}`} · {sp.filename}
))}
)} {/* Placeholder «нет файлов вообще» — только если И активных нет, И в «уже в работе» пусто, И stale-список пуст (правка 2026-06-03, 2026-06-05). */} {!scan.loading && groups.length === 0 && skippedFiles.length === 0 && staleFiles.length === 0 && !scan.error && (
Новых файлов нет
Закинь фото в _Pikasrv/ на Я.Диске и нажми «Пересканировать».
)} {!scan.loading && groups.length === 0 && skippedFiles.length > 0 && !scan.error && (
Новых файлов на Я.Диске нет — все {skippedFiles.length} уже привязаны к существующим сериям. Развернул архив ниже на случай если хочешь перегенерировать какой-то файл.
)} {groups.map((g, i) => ( setEdit(g.id, p)} variant="below" inSplitMode={splitMode === g.id} onToggleSplit={() => setSplitMode(splitMode === g.id ? null : g.id)} onSplitAt={(idx) => splitAt(g.id, idx)} canMerge={i < groups.length - 1} onMergeDown={() => mergeDown(g.id)} dragFile={dragFile} onFileDragStart={(f) => fileDragStart(f, g.id)} onFileDragEnd={fileDragEnd} onAcceptFileDrop={() => moveFileToGroup(g.id)} /> ))} {/* Drop-зона «Новая серия» — появляется во время drag. Сбросить файл сюда → создать отдельную серию из одного файла (потом можно дотащить другие). */} {dragFile && (
e.preventDefault()} onDrop={(e) => { e.preventDefault(); moveFileToNewGroup(); }} style={{ marginTop: 14, padding: "16px 14px", background: "var(--accent-soft)", border: "2px dashed var(--accent)", borderRadius: "var(--r-lg)", color: "var(--accent-2)", textAlign: "center", fontSize: 13, }}> Бросить здесь — создать отдельную серию
)} {/* «Уже в работе» — раскрывающийся блок дубликатов с возможностью перетащить файл обратно в активные группы (для перегенерации данных по этому файлу). Правка 2026-06-03 по запросу Лены. */} {skippedFiles.length > 0 && skippedOpen && (
{skippedFiles.length} {skippedFiles.length === 1 ? "файл" : "файлов"} уже привязан{skippedFiles.length === 1 ? "" : "ы"} к существующим сериям. Перетащи нужный в любую группу выше или в «новую серию» — если хочешь перегенерировать данные.
{skippedFiles.map((sf) => (
fileDragStart(sf.file, "skipped")} onDragEnd={fileDragEnd} style={{ display: "flex", flexDirection: "column", width: 100, cursor: "grab", opacity: dragFile && dragFile.file.yandexPath === sf.file.yandexPath ? 0.4 : 1, }} title={`${sf.file.filename} · был в группе ${sf.fromGroupTitle || sf.fromGroupId}`}>
{sf.file.filename}
))}
)}
); } function Group({ g, edit, setEdit, variant, inSplitMode, onToggleSplit, onSplitAt, canMerge, onMergeDown, dragFile, onFileDragStart, onFileDragEnd, onAcceptFileDrop }) { const files = g.files || []; const title = edit.title !== undefined ? edit.title : (g.title || ""); const variantLabel = null; // Подсветка группы как drop-target во время drag (файл из другой группы // или из «Уже в работе»). const isDropTarget = dragFile && dragFile.sourceGroupId !== g.id; const [dragOver, setDragOver] = React.useState(false); return (
{ if (isDropTarget) { e.preventDefault(); setDragOver(true); } }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { if (!isDropTarget) return; e.preventDefault(); setDragOver(false); onAcceptFileDrop && onAcceptFileDrop(); }} style={{ outline: dragOver ? "2px dashed var(--accent)" : undefined, outlineOffset: dragOver ? 4 : undefined, background: dragOver ? "var(--accent-soft)" : undefined, transition: "background 100ms", }}>
{ const v = e.currentTarget.textContent.trim(); setEdit({ title: v }); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.currentTarget.blur(); } if (e.key === "Escape") { e.currentTarget.textContent = title; e.currentTarget.blur(); } }} > {title}
{formatDate(g.date)} · {files.length} файлов
{inSplitMode && (
Клик в мятный зазор между файлами разрежет группу. Несколько разрезов подряд — можно.
)} {variantLabel && (
{variantLabel}
)}
{files.map((f, idx) => ( {inSplitMode && idx > 0 && onSplitAt(idx)} hint={`Разрезать после ${files[idx - 1].filename}`}/>}
onFileDragStart && onFileDragStart(f)} onDragEnd={() => onFileDragEnd && onFileDragEnd()} style={{ cursor: inSplitMode ? "default" : "grab", opacity: dragFile && dragFile.file.yandexPath === f.yandexPath ? 0.4 : 1, }} title={inSplitMode ? "" : "Перетащи в другую серию или в «новую серию» ниже"}>
))}
); } // ThumbCard — одна плитка файла. Три варианта подписи под сравнение: // "below" — имя под превью, в две строки (как в Проводнике) // "overlay-small" — имя поверх внизу, мелким шрифтом, обрезается // "tooltip" — без видимой подписи, только в hover-тултипе function ThumbCard({ f, variant }) { const dupCls = f.isDuplicate ? " dup" : (f.isPossibleRepeat ? " repeat" : ""); const tip = f.filename + (f.dupNote ? " · " + f.dupNote : ""); const badge = f.isDuplicate ? дубль md5 : (f.isPossibleRepeat ? повтор? : null); if (variant === "below") { return (
{badge}
{/* Шрифт подобран так, чтобы стандартное имя "260526_01.jpg" (13 символов) влезало в одну строку при ширине ячейки 120px. Более длинные имена (видео) — переносятся по слову/символу. */}
{f.filename}
); } if (variant === "overlay-small") { return (
{f.filename} {badge}
); } // tooltip-only return (
{badge}
); } // SplitGap — кликабельный зазор между файлами в режиме «разделить». // В отличие от CSS-класса .split-gap из дизайна (он сидит в гриде и в нашем // flex-варианте «below» с разной высотой ячеек теряется), эта кнопка имеет // собственную постоянно-видимую вертикальную полоску. function SplitGap({ onClick, hint }) { const [hover, setHover] = React.useState(false); return ( ); } // Thumb — превью файла через /v1/preview (бэк проксирует Я.Диск). // fit: "cover" режет до краёв рамки (теряется ориентация), // "contain" вписывает целиком с полями (видно горизонтально/вертикально). function Thumb({ path, size, alt, fit }) { if (!path) return null; const src = "/v1/preview?path=" + encodeURIComponent(path) + "&size=" + (size || "M"); return {alt; } function formatDate(iso) { if (!iso) return ""; const d = new Date(iso); const months = ["янв","фев","мар","апр","мая","июн","июл","авг","сен","окт","ноя","дек"]; return d.getDate() + " " + months[d.getMonth()] + " " + d.getFullYear(); } window.IngestScreen = IngestScreen; window.Thumb = Thumb;