Add batch duplicate review workflow
This commit is contained in:
+66
-21
@@ -26,6 +26,7 @@ let latestAnalysis = null;
|
||||
let selectedDuplicateGroup = null;
|
||||
let previewedRepair = null;
|
||||
let latestBackup = null;
|
||||
let reviewState = null;
|
||||
|
||||
function expandHome(path) {
|
||||
return path.trim();
|
||||
@@ -124,34 +125,59 @@ function renderDetail(key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail.items[0].file_previews) {
|
||||
reviewState = {key, index: 0, choices: new Map()};
|
||||
repairPanel.hidden = false;
|
||||
renderReviewGroup();
|
||||
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"` : ''}>
|
||||
<article class="detail-item">
|
||||
<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;
|
||||
function renderReviewGroup() {
|
||||
const groups = latestAnalysis.details[reviewState.key].items;
|
||||
const group = groups[reviewState.index];
|
||||
const chosen = reviewState.choices.get(reviewState.index);
|
||||
drilldownCount.textContent = `${reviewState.index + 1} of ${groups.length} · ${reviewState.choices.size} approved`;
|
||||
drilldownSummary.textContent = 'Listen to each candidate, choose the keeper, and we’ll move to the next group. Skip anything uncertain.';
|
||||
drilldownList.innerHTML = `
|
||||
<article class="review-workspace">
|
||||
<div class="review-heading"><div><span>Comparing now</span><strong>${escapeHtml(group.filename)}</strong></div><span>${reviewState.choices.size} selected</span></div>
|
||||
<div class="review-candidates">${group.file_previews.map((file, index) => `
|
||||
<section class="review-candidate ${chosen === file.path ? 'winner' : ''}">
|
||||
<span class="candidate-number">Option ${index + 1}</span>
|
||||
<strong>${escapeHtml(file.path.split('/').pop())}</strong>
|
||||
<small>${escapeHtml(file.path)}</small>
|
||||
<div class="file-actions"><button type="button" data-audio-url="${escapeHtml(file.audio_url)}" data-audio-name="${escapeHtml(file.path)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(file.reveal_token)}">Show in Finder</button></div>
|
||||
<button class="choose-winner" type="button" data-choose-winner="${index}">${chosen === file.path ? '✓ Selected keeper' : 'Keep this one →'}</button>
|
||||
</section>
|
||||
`).join('')}</div>
|
||||
<div class="review-navigation"><button type="button" data-review-previous ${reviewState.index === 0 ? 'disabled' : ''}>← Previous</button><button type="button" data-review-skip>Skip for now</button></div>
|
||||
</article>`;
|
||||
updateBatchSummary();
|
||||
}
|
||||
|
||||
function updateBatchSummary() {
|
||||
if (!reviewState) return;
|
||||
const groups = latestAnalysis.details[reviewState.key].items;
|
||||
repairChoice.innerHTML = `<div class="batch-summary"><strong>${reviewState.choices.size} group${reviewState.choices.size === 1 ? '' : 's'} approved</strong><span>${groups.length - reviewState.choices.size} skipped or still awaiting a decision</span></div>`;
|
||||
previewRepairButton.disabled = reviewState.choices.size === 0;
|
||||
previewedRepair = null;
|
||||
applyRepairButton.disabled = true;
|
||||
repairPreview.hidden = true;
|
||||
repairMessage.textContent = '';
|
||||
repairChoice.innerHTML = group.files.map((file, fileIndex) => `
|
||||
<div class="keeper-option"><label><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><div class="file-actions"><button type="button" data-audio-url="${escapeHtml(group.file_previews[fileIndex].audio_url)}" data-audio-name="${escapeHtml(file)}">▶ Play preview</button><button type="button" data-reveal-token="${escapeHtml(group.file_previews[fileIndex].reveal_token)}">Show in Finder</button></div></div>
|
||||
`).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)};
|
||||
if (!reviewState?.choices.size) throw new Error('Choose at least one keeper');
|
||||
const groups = latestAnalysis.details[reviewState.key].items;
|
||||
const choices = Array.from(reviewState.choices, ([index, keeper]) => ({keeper, group_files: groups[index].files}));
|
||||
return {serato: expandHome(document.querySelector('#serato-path').value), music: expandHome(document.querySelector('#music-path').value), choices, backup_limit: document.querySelector('#keep-all-backups').checked ? null : Number(document.querySelector('#backup-limit').value)};
|
||||
}
|
||||
|
||||
async function requestRepair(endpoint) {
|
||||
@@ -164,8 +190,8 @@ async function requestRepair(endpoint) {
|
||||
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>`;
|
||||
previewedRepair = await requestRepair('/api/duplicates/batch/preview');
|
||||
repairPreview.innerHTML = `<strong>One safe plan for ${previewedRepair.choice_count} approved group(s)</strong><p>${previewedRepair.replaced.length} duplicate file(s) will be backed up, then replaced with shortcuts to their selected keepers. ${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; }
|
||||
@@ -175,7 +201,7 @@ 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');
|
||||
const result = await requestRepair('/api/duplicates/batch/apply');
|
||||
latestBackup = result.backup;
|
||||
repairMessage.textContent = `Cleanup complete. Restore backup: ${result.backup}`;
|
||||
previewRepairButton.disabled = true;
|
||||
@@ -197,7 +223,6 @@ restoreRepairButton.addEventListener('click', async () => {
|
||||
} 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.'; });
|
||||
async function handleFileAction(event) {
|
||||
const previewButton = event.target.closest('[data-audio-url]');
|
||||
const revealButton = event.target.closest('[data-reveal-token]');
|
||||
@@ -224,8 +249,28 @@ async function handleFileAction(event) {
|
||||
|
||||
closeAudioPreview.addEventListener('click', () => { audioPlayer.pause(); audioPlayer.removeAttribute('src'); audioPlayer.load(); audioPreview.hidden = true; });
|
||||
repairChoice.addEventListener('click', handleFileAction);
|
||||
drilldownList.addEventListener('click', async (event) => { if (await handleFileAction(event)) return; 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)); } });
|
||||
drilldownList.addEventListener('click', async (event) => {
|
||||
if (await handleFileAction(event) || !reviewState) return;
|
||||
const groups = latestAnalysis.details[reviewState.key].items;
|
||||
const winner = event.target.closest('[data-choose-winner]');
|
||||
if (winner) {
|
||||
const fileIndex = Number(winner.dataset.chooseWinner);
|
||||
reviewState.choices.set(reviewState.index, groups[reviewState.index].files[fileIndex]);
|
||||
if (reviewState.index < groups.length - 1) reviewState.index += 1;
|
||||
renderReviewGroup();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-review-skip]')) {
|
||||
reviewState.choices.delete(reviewState.index);
|
||||
if (reviewState.index < groups.length - 1) reviewState.index += 1;
|
||||
renderReviewGroup();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-review-previous]') && reviewState.index > 0) {
|
||||
reviewState.index -= 1;
|
||||
renderReviewGroup();
|
||||
}
|
||||
});
|
||||
|
||||
function render(data) {
|
||||
latestAnalysis = data;
|
||||
|
||||
Reference in New Issue
Block a user