Add backup-first duplicate repair
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -8,7 +8,17 @@ 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();
|
||||
@@ -51,19 +61,91 @@ function renderDetail(key) {
|
||||
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) => `
|
||||
<article class="detail-item">
|
||||
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) => {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</nav>
|
||||
<div class="safety-card">
|
||||
<span class="safety-icon">✓</span>
|
||||
<div><strong>Read-only mode</strong><p>Your library will not be modified.</p></div>
|
||||
<div><strong>Protected mode</strong><p>Analysis is read-only. Repairs require a preview and backup.</p></div>
|
||||
</div>
|
||||
<div class="sidebar-foot">Local interface · v0.1</div>
|
||||
</aside>
|
||||
@@ -88,6 +88,23 @@
|
||||
<p id="drilldown-summary">Click a metric above to see example files and saved paths behind that number.</p>
|
||||
<div id="drilldown-list" class="detail-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="duplicate-repair" class="repair-panel panel" hidden>
|
||||
<div class="section-heading"><div><p class="eyebrow">Backup-first cleanup</p><h2>Safely consolidate duplicates</h2></div><span class="repair-tag">Preview required</span></div>
|
||||
<p>Choose the real file to keep. Every other path will be backed up and replaced with a shortcut to it, so existing Serato crates remain mapped. Serato’s database V2 is never edited.</p>
|
||||
<div id="repair-choice" class="repair-choice"></div>
|
||||
<div class="backup-options">
|
||||
<label>Backups to keep <input id="backup-limit" type="number" min="1" value="10"></label>
|
||||
<label class="check-label"><input id="keep-all-backups" type="checkbox"> Keep every backup</label>
|
||||
</div>
|
||||
<div id="repair-preview" class="repair-preview" hidden></div>
|
||||
<div class="repair-actions">
|
||||
<button id="preview-repair" type="button">Preview changes</button>
|
||||
<button id="apply-repair" class="danger-action" type="button" disabled>Apply backed-up cleanup</button>
|
||||
<button id="restore-repair" type="button" hidden>Restore this backup</button>
|
||||
</div>
|
||||
<div id="repair-message" class="repair-message" role="status"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user