ui_tts_import.js gives its frame and toolbar to ui_tts_import_screens.js and its window drop to ui_tts_file_drop.js; material_inspector.js gives the PNG chunk reader to png_metadata.js. 221 texts no code uses any more leave locales.js (both languages). No behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
28 KiB
JavaScript
600 lines
28 KiB
JavaScript
import { t } from './interface_settings.js';
|
||
import { createViewScope } from './ui_lifecycle.js';
|
||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||
import { loadGptSovitsStatus, loadVoices } from './audio_engines.js';
|
||
import {
|
||
buildImportBody, commitImport, discardUploads, importKind, inspectImport, nameConflict, pickWeights,
|
||
} from './tts_setup_api.js';
|
||
import { clashFreeNames, draftChecklist, draftState, groupKey, leftOut, planGroups, splitBatch } from './tts_import_groups.js';
|
||
import { TRAY, renderDraftCard, renderTrayCard, showSource, syncReference, updateRow } from './ui_tts_import_draft.js';
|
||
import { bindWindowDrop, readDroppedFiles } from './ui_tts_file_drop.js';
|
||
import { createFileSources } from './ui_tts_import_sources.js';
|
||
import { createImportPlayer } from './ui_tts_import_player.js';
|
||
import { createUploadQueue } from './ui_tts_import_uploads.js';
|
||
import { closeSpotlightTour, isSpotlightTourActive, startSpotlightTour } from './ui_spotlight_tour.js';
|
||
import {
|
||
TOURS, markTourSeen, renderBatchHero, renderChooseScreen, renderImportFrame, renderImportToolbar, seenTours,
|
||
} from './ui_tts_import_screens.js';
|
||
|
||
/**
|
||
* The GPT-SoVITS import window, through the Anomalous_TTS node. It first asks what
|
||
* the user has: one character (`single`: one card, its checklist leads step by
|
||
* step), many characters or a package (`batch`: a drop area, then files sorted
|
||
* into one card per character, plus a card for files that match no character),
|
||
* or files for an existing character (`add`, also opened straight from a
|
||
* character's "Add files"). A spotlight tour explains each screen the first time.
|
||
*
|
||
* Files come from a drop (folders too) or the browser's file or folder dialog, and
|
||
* are uploaded in chunks, a few files at a time, once they are in a character (see
|
||
* ui_tts_import_sources.js). A large add
|
||
* opens a batch of characters at a time; the rest waits for "next batch" (and opens
|
||
* by itself once the batch is imported). Files that would clash by name inside a
|
||
* character get their folder's name in front. Every clip can be listened to at any
|
||
* time (ui_tts_import_player.js). The node copies what is imported; the originals
|
||
* are never touched. Clips outside 3–10 s are left out. Each draft is inspected and
|
||
* imported on its own, so one failure leaves the others alone. Every file is one
|
||
* row object, owned by exactly one draft's `rows`; its elements are built once
|
||
* (ui_tts_import_draft.js) and move with it.
|
||
*/
|
||
|
||
let activeScope = null;
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
/**
|
||
* Open the import window. `files`: `{ file, dir }` items (or plain Files); `target`:
|
||
* add them to this character. `onDone(character)` runs once after imports finished.
|
||
*/
|
||
export async function openTtsImport({ files = [], target = null, onDone } = {}) {
|
||
let status;
|
||
let existing = [];
|
||
try {
|
||
[status, existing] = await Promise.all([
|
||
loadGptSovitsStatus(),
|
||
loadVoices().then(result => result.groups.map(group => group.character)),
|
||
]);
|
||
} catch (e) {
|
||
await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) }));
|
||
return;
|
||
}
|
||
if (!status) return;
|
||
if (status.local === false) {
|
||
await anomalousAlert(t('ttsSetupRemote'));
|
||
return;
|
||
}
|
||
// New characters go to the storage place. The status is kept a minute (a fresh install's folder may be there by now): ask again before refusing.
|
||
if (!target && !status.libraries.find(lib => lib.storage)?.writable) status = await loadGptSovitsStatus({ force: true }).catch(() => status) || status;
|
||
const home = status.libraries.find(lib => lib.storage);
|
||
if (!target && !home?.writable) return anomalousAlert(t('ttsImportNoLibrary', { path: status.storage }));
|
||
|
||
activeScope?.dispose();
|
||
const scope = createViewScope();
|
||
activeScope = scope;
|
||
const drafts = [];
|
||
const tray = { id: TRAY, rows: [] };
|
||
const cards = new Map(); // draft -> { refresh } of its drawn card
|
||
// Dropped files skip the question: several files are sorted like a package.
|
||
let mode = target ? 'add' : files.length ? 'batch' : null;
|
||
let nextDraft = 0;
|
||
let nextRow = 0;
|
||
let importing = false;
|
||
const player = createImportPlayer();
|
||
const pending = []; // groups of characters waiting for the next batch (tts_import_groups.js planGroups)
|
||
const uploads = createUploadQueue({
|
||
onProgress: updateRow,
|
||
onDone: (row) => { updateRow(row); touch(ownerOf(row)); refreshSoon(); },
|
||
});
|
||
let refreshTimer = 0;
|
||
|
||
const overlay = el('div', 'anomalous-voice-modal-overlay');
|
||
const modal = el('div', 'anomalous-voice-modal anomalous-tts-import');
|
||
modal.setAttribute('role', 'dialog');
|
||
modal.setAttribute('aria-modal', 'true');
|
||
const close = () => { if (!importing) scope.dispose(); };
|
||
const allRows = () => [...drafts.flatMap(draft => draft.rows), ...tray.rows];
|
||
scope.onDispose(() => {
|
||
closeSpotlightTour();
|
||
for (const draft of drafts) clearTimeout(draft.inspectTimer);
|
||
clearTimeout(refreshTimer);
|
||
uploads.clear();
|
||
pending.length = 0;
|
||
player.stop();
|
||
const rows = allRows();
|
||
for (const row of rows) {
|
||
row.controller.abort();
|
||
if (row.url) URL.revokeObjectURL(row.url);
|
||
}
|
||
discardUploads(rows.filter(row => !row.committed).map(row => row.uploadId).filter(Boolean));
|
||
overlay.remove();
|
||
if (activeScope === scope) activeScope = null;
|
||
});
|
||
// No close on a backdrop click: a stray click would throw away the files and uploads.
|
||
scope.listen(window, 'keydown', (e) => {
|
||
if (e.key !== 'Escape' || isSpotlightTourActive() || document.querySelector('.anomalous-dialog-overlay')) return;
|
||
e.stopPropagation();
|
||
if (menu.classList.contains('is-open')) { menu.classList.remove('is-open'); return; }
|
||
close();
|
||
}, true);
|
||
|
||
// ---- drafts and rows ----
|
||
function newDraft({ key, name, target: into = null }) {
|
||
const draft = {
|
||
id: `d${nextDraft++}`, key, name, target: into, nameEdited: false, rows: [], referenceKey: null,
|
||
referenceChosen: Boolean(into), // adding files never replaces the main voice unless asked
|
||
language: '', detectedLanguage: null, problems: [], inspectTimer: null, inspectToken: 0,
|
||
expanded: false, importing: false, done: false, failed: '',
|
||
};
|
||
drafts.push(draft);
|
||
return draft;
|
||
}
|
||
const placeOf = id => (id === TRAY ? tray : drafts.find(draft => draft.id === id));
|
||
const ownerOf = row => [...drafts, tray].find(place => place.rows.includes(row));
|
||
const openDrafts = () => drafts.filter(draft => !draft.done);
|
||
const solo = () => mode !== 'batch' && drafts.length === 1;
|
||
|
||
const rowView = row => ({ kind: row.kind, name: row.name, emotion: row.emotion, error: row.error, uploaded: Boolean(row.spec), queued: row.queued,
|
||
progress: row.progress, existing: row.info?.existing ?? null, supported: row.info?.supported, version: row.info?.version,
|
||
seconds: row.info?.seconds });
|
||
/** Keys of the draft's rows the import leaves out (clips outside 3–10 s and their line files). */
|
||
const skippedKeys = (rows) => {
|
||
const out = leftOut(rows.map(rowView));
|
||
return new Set(rows.filter((_, i) => out.has(i)).map(row => row.key));
|
||
};
|
||
|
||
function viewOf(draft) {
|
||
const name = draft.name.trim();
|
||
const conflict = draft.target ? null : nameConflict(name, existing);
|
||
const duplicate = !draft.target && Boolean(name) && openDrafts().some(other => other !== draft && !other.target
|
||
&& other.name.trim().toLowerCase() === name.toLowerCase());
|
||
const rows = draft.rows.map(rowView);
|
||
const main = draft.rows.find(row => row.key === draft.referenceKey)?.name || null;
|
||
const state = draftState({ target: draft.target, name, rows, main, conflict, duplicate,
|
||
done: draft.done, failed: draft.failed, importing: draft.importing });
|
||
const checklist = draftChecklist({ target: draft.target, name, rows, main, conflict, duplicate });
|
||
return { ...state, conflict, problems: draft.problems, checklist, skipped: skippedKeys(draft.rows) };
|
||
}
|
||
|
||
/** One card open at a time; a lone character is always open. */
|
||
function expand(draft) {
|
||
for (const other of drafts) other.expanded = other === draft;
|
||
}
|
||
|
||
/** Something about a draft changed: forget an earlier failure and look at its files again. */
|
||
function touch(draft) {
|
||
if (!draft || draft === tray) return;
|
||
draft.failed = '';
|
||
clearTimeout(draft.inspectTimer);
|
||
draft.inspectTimer = setTimeout(() => inspect(draft), 250);
|
||
}
|
||
|
||
function forgetRow(row) {
|
||
player.forget(row);
|
||
row.controller.abort();
|
||
if (row.uploadId) discardUploads([row.uploadId]);
|
||
if (row.url) URL.revokeObjectURL(row.url);
|
||
row.els?.root.remove();
|
||
}
|
||
|
||
function removeRow(row, { redraw = true } = {}) {
|
||
const place = ownerOf(row);
|
||
forgetRow(row);
|
||
place.rows.splice(place.rows.indexOf(row), 1);
|
||
if (place.referenceKey === row.key) place.referenceKey = null;
|
||
touch(place);
|
||
if (redraw) draw();
|
||
}
|
||
|
||
/** What the node gets for a row: its upload or path, and its name when it was renamed. */
|
||
const specOf = row => (row.name !== row.original ? { ...row.spec, name: row.name } : row.spec);
|
||
|
||
/**
|
||
* GPT and SoVITS hold one file per draft: a newer one replaces the old. The same file
|
||
* twice is skipped; another file of the same name is renamed (tts_import_groups.js
|
||
* clashFreeNames). In the tray a file keeps its own name and is not uploaded yet.
|
||
*/
|
||
function placeRow(place, row) {
|
||
if (place !== tray && (row.kind === 'gpt' || row.kind === 'sovits')) {
|
||
const old = place.rows.find(other => other.kind === row.kind && other !== row);
|
||
if (old) removeRow(old, { redraw: false });
|
||
} else if (place.rows.some(other => other !== row && other.kind === row.kind && other.dir === row.dir
|
||
&& other.original.toLowerCase() === row.original.toLowerCase())) {
|
||
return false;
|
||
}
|
||
row.name = place === tray ? row.original
|
||
: clashFreeNames([{ name: row.original, dir: row.dir, kind: row.kind }], place.rows.filter(other => other !== row))[0];
|
||
place.rows.push(row);
|
||
updateRow(row);
|
||
if (place !== tray && row.file && !row.queued) {
|
||
row.queued = true;
|
||
uploads.add(row);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function moveRow(row, id) {
|
||
const from = ownerOf(row);
|
||
const to = placeOf(id);
|
||
if (!to || to === from) return;
|
||
from.rows.splice(from.rows.indexOf(row), 1);
|
||
if (from.referenceKey === row.key) from.referenceKey = null;
|
||
if (!placeRow(to, row)) forgetRow(row);
|
||
touch(from);
|
||
touch(to);
|
||
if (to !== tray && !to.expanded && from === tray && !tray.rows.length) expand(to);
|
||
draw();
|
||
}
|
||
|
||
/** A whole folder of unassigned files at once. */
|
||
function moveRows(rows, id) {
|
||
const to = placeOf(id);
|
||
if (!to || to === tray) return;
|
||
for (const row of rows) {
|
||
tray.rows.splice(tray.rows.indexOf(row), 1);
|
||
if (!placeRow(to, row)) forgetRow(row);
|
||
}
|
||
touch(to);
|
||
if (!openDrafts().some(draft => draft.expanded)) expand(to);
|
||
draw();
|
||
}
|
||
|
||
/** Put `groups` (planGroups) into their characters: rows, uploads, and what to tell the user. */
|
||
function fill(groups, bad = []) {
|
||
const keptBest = [];
|
||
const touched = new Set();
|
||
let renamed = 0;
|
||
for (const group of groups) {
|
||
const owner = group.owner || (group.tray ? tray
|
||
: openDrafts().find(draft => draft.key === group.key) || newDraft({ key: group.key, name: group.name }));
|
||
const { skip, kept } = pickWeights(group.items);
|
||
keptBest.push(...kept);
|
||
group.items.forEach((item, i) => {
|
||
if (skip.has(i)) return;
|
||
const row = {
|
||
key: nextRow++, kind: item.kind, name: item.name, original: item.name, dir: item.dir || '',
|
||
file: item.file, size: item.file.size, spec: null,
|
||
queued: false, uploadId: null, progress: 0, error: '', info: null, emotion: '', text: '', textEdited: false,
|
||
committed: false, controller: new AbortController(),
|
||
};
|
||
if (!placeRow(owner, row)) { bad.push(item.name); return; }
|
||
if (row.name !== row.original) renamed++;
|
||
touched.add(owner);
|
||
});
|
||
}
|
||
const notes = keptBest.length > 2
|
||
? [t('ttsImportKeptBestMany', { count: keptBest.reduce((sum, k) => sum + k.others, 0) })]
|
||
: keptBest.map(k => t('ttsImportKeptBest', { kind: t(`ttsKind_${k.kind}`), file: k.name, count: k.others }));
|
||
if (renamed) notes.push(t('ttsImportRenamed', { count: renamed }));
|
||
if (bad.length) notes.push(t('ttsImportSkipped', { files: bad.join(', ') }));
|
||
if (pending.length) notes.push(t('ttsBatchLater', { count: pending.length }));
|
||
noteLine.textContent = notes.join(' ');
|
||
touched.forEach(touch);
|
||
const first = [...touched].find(owner => owner !== tray);
|
||
if (first && !openDrafts().some(draft => draft.expanded)) expand(first);
|
||
draw();
|
||
}
|
||
|
||
/**
|
||
* Add files: `items` are `{ name, dir, file }` or `{ name, dir, path, size }`. Into
|
||
* `into` when given (or the only card outside batch mode), else sorted into drafts,
|
||
* a batch at a time.
|
||
*/
|
||
function addItems(items, into = null) {
|
||
const known = items.map(item => ({ ...item, kind: importKind(item.name) }));
|
||
const bad = known.filter(item => !item.kind).map(item => item.name);
|
||
const usable = known.filter(item => item.kind);
|
||
const place = into || (solo() ? drafts[0] : null);
|
||
if (place) { fill([{ owner: place, items: usable }], bad); return; }
|
||
const { now, later } = splitBatch(planGroups(usable, openDrafts().map(draft => ({ key: draft.key, target: draft.target }))));
|
||
pending.push(...later);
|
||
fill(now, bad);
|
||
}
|
||
|
||
/** Open the next batch of waiting characters. */
|
||
function nextBatch() {
|
||
const { now, later } = splitBatch(pending.splice(0));
|
||
pending.push(...later);
|
||
fill(now);
|
||
}
|
||
|
||
/** Many uploads finish close together: repaint once for them. */
|
||
function refreshSoon() {
|
||
if (!refreshTimer) refreshTimer = setTimeout(() => { refreshTimer = 0; if (!scope.signal.aborted) refresh(); }, 100);
|
||
}
|
||
|
||
async function inspect(draft) {
|
||
if (draft.done || scope.signal.aborted) return;
|
||
const ready = draft.rows.filter(row => row.spec && !row.error);
|
||
if (draft.rows.some(row => !row.spec && !row.error)) return; // the last upload runs this again
|
||
const mine = ++draft.inspectToken;
|
||
if (!ready.length) {
|
||
draft.problems = [];
|
||
refresh();
|
||
return;
|
||
}
|
||
try {
|
||
const result = await inspectImport(ready.map(specOf), scope.signal, draft.target);
|
||
if (mine !== draft.inspectToken || scope.signal.aborted) return;
|
||
const skippedBefore = skippedKeys(draft.rows).size;
|
||
ready.forEach((row, i) => {
|
||
row.info = result.files[i];
|
||
if (row.info.size) row.size = row.info.size;
|
||
updateRow(row);
|
||
if (row.textEdited) return;
|
||
row.text = row.info.text || '';
|
||
if (row.els?.text) {
|
||
row.els.text.value = row.text;
|
||
showSource(row, row.info.text_source || 'none');
|
||
}
|
||
});
|
||
const { suggested } = result;
|
||
const renamed = !draft.target && !draft.nameEdited && !draft.name && Boolean(suggested.name);
|
||
if (renamed) draft.name = suggested.name;
|
||
if (!draft.referenceChosen) draft.referenceKey = suggested.reference !== null ? ready[suggested.reference]?.key ?? null : null;
|
||
const languageChanged = draft.detectedLanguage !== suggested.language;
|
||
draft.detectedLanguage = suggested.language;
|
||
draft.problems = result.problems;
|
||
syncReference(draft);
|
||
const regrouped = skippedKeys(draft.rows).size !== skippedBefore && (draft.expanded || solo());
|
||
if (renamed || regrouped || (languageChanged && (draft.expanded || solo()))) { draw(); return; }
|
||
} catch (e) {
|
||
if (mine !== draft.inspectToken || scope.signal.aborted) return;
|
||
draft.problems = [e.message || String(e)];
|
||
}
|
||
refresh();
|
||
}
|
||
|
||
// ---- picking files (ui_tts_import_sources.js) ----
|
||
const sources = createFileSources({
|
||
signal: scope.signal,
|
||
add: (items, into) => addItems(items, into),
|
||
note: (text) => { noteLine.textContent = `${noteLine.textContent} ${text}`.trim(); },
|
||
});
|
||
|
||
// ---- layout: header, steps, content, footer (ui_tts_import_screens.js) ----
|
||
const frame = renderImportFrame(target ? t('ttsImportAddTitle', { name: target }) : t('ttsImportTitle'), {
|
||
help: () => runTour(currentTour(), true), close, submit: () => importReady(),
|
||
});
|
||
const { summary, saveTo, submit, closeBtn } = frame;
|
||
|
||
// Step 1: what do you have?
|
||
const choose = renderChooseScreen(existing, {
|
||
single: () => { mode = 'single'; expand(newDraft({ key: '#solo', name: '' })); draw(); },
|
||
batch: () => { mode = 'batch'; draw(); },
|
||
character: (name) => { mode = 'add'; expand(newDraft({ key: groupKey(name), name, target: name })); draw(); },
|
||
});
|
||
|
||
const { toolbar, menuWrap, menu, found, dropHint, backBtn, nextBtn, newBtn } = renderImportToolbar({
|
||
files: () => sources.chooseFiles(),
|
||
folder: sources.chooseFolder,
|
||
next: nextBatch,
|
||
add: () => {
|
||
expand(newDraft({ key: `#new${nextDraft}`, name: '' }));
|
||
draw();
|
||
cardsBox.querySelector('.anomalous-tts-card.is-open .anomalous-tts-card-name-input')?.focus();
|
||
},
|
||
back: () => { drafts.length = 0; mode = null; draw(); },
|
||
});
|
||
scope.listen(document, 'mousedown', (e) => { if (!menuWrap.contains(e.target)) menu.classList.remove('is-open'); });
|
||
const noteLine = el('div', 'anomalous-tts-note');
|
||
const cardsBox = el('div', 'anomalous-tts-cards');
|
||
const content = el('div', 'anomalous-tts-content');
|
||
|
||
// Batch mode before any file: the drop area says what to bring.
|
||
const hero = renderBatchHero({
|
||
folder: sources.chooseFolder, files: () => sources.chooseFiles(), back: () => { mode = null; draw(); },
|
||
});
|
||
|
||
const ctx = {
|
||
status,
|
||
signal: scope.signal,
|
||
// Where a row can be moved: the open cards, and the tray when it holds files.
|
||
places: () => [
|
||
...openDrafts().map(draft => ({ id: draft.id, label: draft.target || draft.name.trim() || t('ttsBatchUnnamed') })),
|
||
...(tray.rows.length ? [{ id: TRAY, label: t('ttsBatchTray') }] : []),
|
||
],
|
||
play: row => player.toggle(row),
|
||
pick: (draft, kinds) => sources.chooseFiles(kinds, draft),
|
||
onToggle: (draft) => {
|
||
if (draft.done) return;
|
||
if (draft.expanded && openDrafts().length > 1) draft.expanded = false;
|
||
else expand(draft);
|
||
draw();
|
||
},
|
||
onName: (draft, value) => {
|
||
draft.name = value;
|
||
draft.nameEdited = true;
|
||
draft.key = groupKey(value) || draft.key;
|
||
draft.failed = '';
|
||
refreshSoon();
|
||
},
|
||
onSwitchToAdd: (draft) => {
|
||
const conflict = nameConflict(draft.name, existing);
|
||
if (conflict?.kind !== 'exact') return;
|
||
draft.target = conflict.name;
|
||
draft.name = conflict.name;
|
||
draft.key = groupKey(conflict.name);
|
||
draft.referenceKey = null; // do not replace its main voice unless the user picks one again
|
||
draft.referenceChosen = true;
|
||
touch(draft);
|
||
draw();
|
||
},
|
||
onReference: (row) => {
|
||
const draft = ownerOf(row);
|
||
draft.referenceKey = draft.referenceKey === row.key ? null : row.key;
|
||
draft.referenceChosen = true;
|
||
syncReference(draft);
|
||
refresh();
|
||
},
|
||
onEdited: (row) => {
|
||
const draft = ownerOf(row);
|
||
if (draft === tray) return;
|
||
draft.failed = '';
|
||
refresh();
|
||
},
|
||
onLanguage: (draft, value) => { draft.language = value; draw(); },
|
||
onMoveRow: moveRow,
|
||
onMoveRows: moveRows,
|
||
onShowAll: (draft) => { draft.showAll = true; draw(); },
|
||
onRemoveRow: (row) => removeRow(row),
|
||
onRemoveDraft: async (draft) => {
|
||
if (draft.rows.length && !await anomalousConfirm(t('ttsCardRemoveConfirm', { name: draft.target || draft.name.trim() || t('ttsBatchUnnamed'), count: draft.rows.length }))) return;
|
||
if (scope.signal.aborted) return;
|
||
clearTimeout(draft.inspectTimer);
|
||
draft.rows.forEach(forgetRow);
|
||
drafts.splice(drafts.indexOf(draft), 1);
|
||
cards.delete(draft);
|
||
draw();
|
||
},
|
||
onDropFiles: (draft, dataTransfer) => {
|
||
modal.classList.remove('is-dragover');
|
||
readDroppedFiles(dataTransfer).then(dropped => { if (!scope.signal.aborted) sources.takeFiles(dropped, draft); });
|
||
},
|
||
};
|
||
|
||
// ---- tours ----
|
||
const currentTour = () => (mode === null ? 'choose' : cardsBox.querySelector('.anomalous-tts-card.is-open .anomalous-tts-check') && content.contains(cardsBox) ? 'card' : null);
|
||
function runTour(id, force = false) {
|
||
if (!id || scope.signal.aborted || isSpotlightTourActive() || (!force && seenTours().has(id))) return;
|
||
if (startSpotlightTour(null, { steps: TOURS[id] })) markTourSeen(id);
|
||
}
|
||
|
||
// ---- drawing ----
|
||
/** Status only: the cards' pills, checklists and problems, the steps and the footer. */
|
||
function refresh() {
|
||
let ready = 0;
|
||
let waiting = 0;
|
||
for (const draft of drafts) {
|
||
const view = viewOf(draft);
|
||
cards.get(draft)?.refresh(view);
|
||
if (view.tone === 'ready') ready++;
|
||
else if (view.tone !== 'done') waiting++;
|
||
}
|
||
frame.setStep(importing || (ready && !waiting && !tray.rows.length) ? 2 : mode === null ? 0 : 1);
|
||
const open = openDrafts().length;
|
||
found.textContent = mode === 'add' && open === 1 ? t('ttsToolbarAdding', { name: drafts[0].target })
|
||
: mode === 'single' ? t('ttsToolbarSingle') : t('ttsToolbarFound', { count: open });
|
||
const parts = [];
|
||
if (waiting && !solo()) parts.push(t('ttsFooterWaiting', { count: waiting }));
|
||
if (tray.rows.length) parts.push(t('ttsFooterTray', { count: tray.rows.length }));
|
||
if (pending.length) parts.push(t('ttsFooterPending', { count: pending.length }));
|
||
summary.textContent = parts.join(' · ');
|
||
summary.hidden = !parts.length;
|
||
saveTo.textContent = t('ttsFooterSaveTo', { path: status.storage });
|
||
saveTo.title = status.storage;
|
||
submit.textContent = mode === 'add' && drafts.length === 1 ? t('ttsImportAddSubmit') : ready > 1 ? t('ttsBatchSubmit', { count: ready }) : t('ttsFooterImportOne');
|
||
submit.disabled = importing || !ready;
|
||
closeBtn.disabled = importing;
|
||
}
|
||
|
||
/** Structure changed: rebuild the screen (rows keep their elements), keep the scroll. */
|
||
function draw() {
|
||
const open = openDrafts();
|
||
if (open.length === 1) open[0].expanded = true;
|
||
const batchEmpty = mode === 'batch' && !allRows().length && !drafts.length;
|
||
modal.classList.toggle('is-empty', mode === null || batchEmpty);
|
||
if (mode === null) {
|
||
content.replaceChildren(choose);
|
||
} else if (batchEmpty) {
|
||
content.replaceChildren(hero);
|
||
} else {
|
||
const top = content.scrollTop;
|
||
cards.clear();
|
||
cardsBox.replaceChildren();
|
||
if (tray.rows.length) cardsBox.append(renderTrayCard(tray, ctx));
|
||
for (const draft of drafts) {
|
||
const card = renderDraftCard(draft, viewOf(draft), ctx, { solo: solo() });
|
||
cards.set(draft, card);
|
||
cardsBox.append(card.root);
|
||
}
|
||
const batch = mode === 'batch';
|
||
menuWrap.hidden = !batch;
|
||
newBtn.hidden = !batch;
|
||
dropHint.hidden = batch;
|
||
nextBtn.hidden = !pending.length;
|
||
nextBtn.textContent = t('ttsBatchNext', { count: pending.length });
|
||
backBtn.hidden = Boolean(target) || allRows().length > 0;
|
||
if (content.firstChild !== toolbar) content.replaceChildren(toolbar, noteLine, cardsBox);
|
||
content.scrollTop = top;
|
||
}
|
||
refresh();
|
||
setTimeout(() => runTour(currentTour()), 60); // after layout, so the spotlight finds its targets
|
||
}
|
||
|
||
// ---- importing, one character after another ----
|
||
async function importReady() {
|
||
const queue = drafts.filter(draft => viewOf(draft).tone === 'ready');
|
||
if (!queue.length) return;
|
||
importing = true;
|
||
let last = null;
|
||
for (const draft of queue) {
|
||
draft.importing = true;
|
||
refresh();
|
||
const skipped = skippedKeys(draft.rows);
|
||
const ready = draft.rows.filter(row => row.spec && !row.error && !skipped.has(row.key));
|
||
const index = ready.findIndex(row => row.key === draft.referenceKey);
|
||
const body = buildImportBody({
|
||
target: draft.target, library: status.storage, character: draft.name.trim(), language: draft.language,
|
||
referenceIndex: index >= 0 ? index : null,
|
||
rows: ready.map(row => ({ spec: specOf(row), kind: row.kind, emotion: row.emotion, text: row.text })),
|
||
});
|
||
try {
|
||
last = await commitImport(body);
|
||
draft.done = true;
|
||
draft.expanded = false;
|
||
draft.name = last.name;
|
||
for (const row of draft.rows) row.committed = true;
|
||
existing.push(last.name);
|
||
} catch (e) {
|
||
draft.failed = e.message || String(e);
|
||
}
|
||
draft.importing = false;
|
||
}
|
||
importing = false;
|
||
if (scope.signal.aborted) return;
|
||
if (last) onDone?.(last);
|
||
if (!openDrafts().length && pending.length) {
|
||
// This batch is in: its cards make way and the next batch opens by itself.
|
||
const imported = drafts.splice(0);
|
||
for (const draft of imported) {
|
||
cards.delete(draft);
|
||
for (const row of draft.rows) { // committed: the node already moved the uploads
|
||
player.forget(row);
|
||
if (row.url) URL.revokeObjectURL(row.url);
|
||
}
|
||
}
|
||
nextBatch();
|
||
noteLine.textContent = `${t('ttsBatchImported', { count: imported.length })} ${noteLine.textContent}`;
|
||
return;
|
||
}
|
||
if (!openDrafts().length && !tray.rows.length) {
|
||
scope.dispose();
|
||
return;
|
||
}
|
||
const failed = drafts.find(draft => draft.failed);
|
||
if (failed) expand(failed);
|
||
draw();
|
||
}
|
||
|
||
// Files can be dropped anywhere on the window: on the question screen they are
|
||
// sorted like a package, otherwise they go where the current mode puts them.
|
||
const dropCover = el('div', 'anomalous-tts-drop-cover', t('ttsDropCover'));
|
||
bindWindowDrop(modal, {
|
||
signal: scope.signal,
|
||
onFiles: (dropped) => {
|
||
if (mode === null) mode = 'batch';
|
||
sources.takeFiles(dropped);
|
||
},
|
||
});
|
||
|
||
modal.append(frame.header, frame.steps, content, frame.footer, dropCover, ...sources.inputs);
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
if (target) newDraft({ key: groupKey(target), name: target, target });
|
||
draw();
|
||
if (files.length) sources.takeFiles(files.map(item => (item instanceof File ? { file: item, dir: '' } : item)));
|
||
}
|