230 lines
11 KiB
JavaScript
230 lines
11 KiB
JavaScript
const form = document.querySelector('#analysis-form');
|
||
const button = document.querySelector('#analyze-button');
|
||
const errorBox = document.querySelector('#error-message');
|
||
const results = document.querySelector('#dashboard');
|
||
const infoButtons = document.querySelectorAll('.info-button');
|
||
const drillTriggers = document.querySelectorAll('[data-detail]');
|
||
const drilldownTitle = document.querySelector('#drilldown-title');
|
||
const drilldownCount = document.querySelector('#drilldown-count');
|
||
const drilldownSummary = document.querySelector('#drilldown-summary');
|
||
const drilldownList = document.querySelector('#drilldown-list');
|
||
const repairPanel = document.querySelector('#duplicate-repair');
|
||
const repairChoice = document.querySelector('#repair-choice');
|
||
const repairPreview = document.querySelector('#repair-preview');
|
||
const repairMessage = document.querySelector('#repair-message');
|
||
const previewRepairButton = document.querySelector('#preview-repair');
|
||
const applyRepairButton = document.querySelector('#apply-repair');
|
||
const restoreRepairButton = document.querySelector('#restore-repair');
|
||
let latestAnalysis = null;
|
||
let selectedDuplicateGroup = null;
|
||
let previewedRepair = null;
|
||
let latestBackup = null;
|
||
|
||
function expandHome(path) {
|
||
return path.trim();
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[character]));
|
||
}
|
||
|
||
function detailLines(item) {
|
||
if (item.files) {
|
||
return item.files.map((file) => `<li>${escapeHtml(file)}</li>`).join('');
|
||
}
|
||
const lines = [];
|
||
if (item.artist || item.title) lines.push(`${item.artist || 'Unknown artist'} — ${item.title || item.filename}`);
|
||
if (item.crate) lines.push(`Crate: ${item.crate}`);
|
||
if (item.saved_path) lines.push(`Saved path: ${item.saved_path}`);
|
||
if (item.candidate) lines.push(`Candidate: ${item.candidate}`);
|
||
if (item.path) lines.push(`File: ${item.path}`);
|
||
if (item.target) lines.push(`Target: ${item.target}`);
|
||
if (item.score || item.reason) lines.push(`${item.score || 'Match'} · ${item.reason || 'Candidate found'}`);
|
||
if (item.repeated_filename) lines.push('Same filename appears more than once in Serato’s missing list.');
|
||
return lines.map((line) => `<li>${escapeHtml(line)}</li>`).join('');
|
||
}
|
||
|
||
function renderDetail(key) {
|
||
const detail = latestAnalysis?.details?.[key];
|
||
if (!detail) return;
|
||
|
||
drillTriggers.forEach((trigger) => {
|
||
trigger.classList.toggle('selected', trigger.dataset.detail === key);
|
||
});
|
||
drilldownTitle.textContent = detail.title;
|
||
drilldownCount.textContent = `${detail.total ?? 0} found`;
|
||
drilldownSummary.textContent = detail.summary;
|
||
|
||
repairPanel.hidden = true;
|
||
selectedDuplicateGroup = null;
|
||
previewedRepair = null;
|
||
latestBackup = null;
|
||
restoreRepairButton.hidden = true;
|
||
if (!detail.items?.length) {
|
||
drilldownList.innerHTML = '<div class="empty-detail">Nothing to review here. Tiny victory parade, very tasteful.</div>';
|
||
return;
|
||
}
|
||
|
||
drilldownList.innerHTML = detail.items.map((item, index) => `
|
||
<article class="detail-item${item.files ? ' selectable-duplicate' : ''}" ${item.files ? `data-duplicate-index="${index}" role="button" tabindex="0"` : ''}>
|
||
<strong>${escapeHtml(item.filename || item.path || 'Untitled item')}</strong>
|
||
<ul>${detailLines(item)}</ul>
|
||
${item.files ? '<small>Choose this group to review a safe cleanup →</small>' : ''}
|
||
</article>
|
||
`).join('');
|
||
}
|
||
|
||
function chooseDuplicate(detailKey, index) {
|
||
const group = latestAnalysis?.details?.[detailKey]?.items?.[index];
|
||
if (!group?.files) return;
|
||
selectedDuplicateGroup = group;
|
||
previewedRepair = null;
|
||
applyRepairButton.disabled = true;
|
||
repairPreview.hidden = true;
|
||
repairMessage.textContent = '';
|
||
repairChoice.innerHTML = group.files.map((file, fileIndex) => `
|
||
<label class="keeper-option"><input type="radio" name="keeper" value="${escapeHtml(file)}" ${fileIndex === 0 ? 'checked' : ''}><span><strong>${fileIndex === 0 ? 'Keep this file' : 'Keep instead'}</strong><small>${escapeHtml(file)}</small></span></label>
|
||
`).join('');
|
||
repairPanel.hidden = false;
|
||
repairPanel.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||
}
|
||
|
||
function repairPayload() {
|
||
const keeper = document.querySelector('input[name="keeper"]:checked')?.value;
|
||
if (!selectedDuplicateGroup || !keeper) throw new Error('Choose a file to keep');
|
||
return {serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), keeper, group_files: selectedDuplicateGroup.files, backup_limit: document.querySelector('#keep-all-backups').checked ? null : Number(document.querySelector('#backup-limit').value)};
|
||
}
|
||
|
||
async function requestRepair(endpoint) {
|
||
const response = await fetch(endpoint, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(repairPayload())});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || 'Duplicate cleanup failed');
|
||
return data;
|
||
}
|
||
|
||
previewRepairButton.addEventListener('click', async () => {
|
||
repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true;
|
||
try {
|
||
previewedRepair = await requestRepair('/api/duplicates/preview');
|
||
repairPreview.innerHTML = `<strong>Ready to protect and consolidate</strong><p>${previewedRepair.replaced.length} duplicate file(s) will be backed up, then replaced with shortcuts to the keeper. ${previewedRepair.metadata_backups} Serato metadata file(s) will also be copied into the rollback snapshot. Database V2 will not be changed.</p>`;
|
||
repairPreview.hidden = false; applyRepairButton.disabled = false;
|
||
repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
|
||
} catch (error) { repairMessage.textContent = error.message; }
|
||
});
|
||
|
||
applyRepairButton.addEventListener('click', async () => {
|
||
if (!previewedRepair) return;
|
||
applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…';
|
||
try {
|
||
const result = await requestRepair('/api/duplicates/apply');
|
||
latestBackup = result.backup;
|
||
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
|
||
previewRepairButton.disabled = true;
|
||
restoreRepairButton.hidden = false;
|
||
} catch (error) { repairMessage.textContent = error.message; applyRepairButton.disabled = false; }
|
||
});
|
||
|
||
restoreRepairButton.addEventListener('click', async () => {
|
||
if (!latestBackup) return;
|
||
restoreRepairButton.disabled = true; repairMessage.textContent = 'Restoring the duplicate files…';
|
||
try {
|
||
const response = await fetch('/api/backups/restore', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serato: expandHome(document.querySelector('#serato-path').value), backup: latestBackup})});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.error || 'Restore failed');
|
||
repairMessage.textContent = `Restore complete. ${result.restored.length} original file(s) returned.`;
|
||
restoreRepairButton.hidden = true;
|
||
} catch (error) { repairMessage.textContent = error.message; restoreRepairButton.disabled = false; }
|
||
});
|
||
|
||
repairChoice.addEventListener('change', () => { previewedRepair = null; applyRepairButton.disabled = true; repairPreview.hidden = true; repairMessage.textContent = 'Keeper changed. Preview the plan again.'; });
|
||
drilldownList.addEventListener('click', (event) => { const item = event.target.closest('[data-duplicate-index]'); if (item) chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); });
|
||
drilldownList.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const item = event.target.closest('[data-duplicate-index]'); if (item) { event.preventDefault(); chooseDuplicate(document.querySelector('.drill-trigger.selected')?.dataset.detail, Number(item.dataset.duplicateIndex)); } });
|
||
|
||
function render(data) {
|
||
latestAnalysis = data;
|
||
document.querySelectorAll('[data-field]').forEach((element) => {
|
||
const value = data[element.dataset.field];
|
||
element.textContent = value ?? '—';
|
||
});
|
||
const score = data.score;
|
||
document.querySelector('#health-score').textContent = score == null ? '—' : `${score}%`;
|
||
document.querySelector('#score-ring').style.setProperty('--score', score ?? 0);
|
||
document.querySelector('#health-message').textContent = score == null
|
||
? 'Not enough data yet'
|
||
: score >= 95 ? 'Looking excellent' : score >= 80 ? 'A few things need attention' : 'Review recommended';
|
||
document.querySelector('#score-basis').textContent = data.score_basis;
|
||
document.querySelector('#analysis-time').textContent = `Completed ${new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
|
||
renderDetail(data.database_missing_paths > 0 ? 'database_missing_tracks' : 'old_crate_references');
|
||
results.hidden = false;
|
||
results.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||
}
|
||
|
||
function closeInfoButtons(except = null) {
|
||
infoButtons.forEach((infoButton) => {
|
||
if (infoButton !== except) {
|
||
infoButton.classList.remove('open');
|
||
infoButton.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
}
|
||
|
||
infoButtons.forEach((infoButton) => {
|
||
infoButton.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
const willOpen = !infoButton.classList.contains('open');
|
||
closeInfoButtons(infoButton);
|
||
infoButton.classList.toggle('open', willOpen);
|
||
infoButton.setAttribute('aria-expanded', String(willOpen));
|
||
});
|
||
});
|
||
|
||
drillTriggers.forEach((trigger) => {
|
||
trigger.addEventListener('click', () => renderDetail(trigger.dataset.detail));
|
||
trigger.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
renderDetail(trigger.dataset.detail);
|
||
}
|
||
});
|
||
});
|
||
|
||
document.addEventListener('click', () => closeInfoButtons());
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Escape') closeInfoButtons();
|
||
});
|
||
|
||
form.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
errorBox.hidden = true;
|
||
button.disabled = true;
|
||
button.querySelector('span').textContent = 'Analyzing safely…';
|
||
const roots = document.querySelector('#reference-roots').value
|
||
.split('\n').map((value) => value.trim()).filter(Boolean);
|
||
try {
|
||
const response = await fetch('/api/analyze', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
serato: expandHome(document.querySelector('#serato-path').value),
|
||
music: expandHome(document.querySelector('#music-path').value),
|
||
reference_roots: roots,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || 'Analysis failed');
|
||
render(data);
|
||
} catch (error) {
|
||
errorBox.textContent = error.message;
|
||
errorBox.hidden = false;
|
||
} finally {
|
||
button.disabled = false;
|
||
button.querySelector('span').textContent = 'Analyze library';
|
||
}
|
||
});
|