Show detailed batch repair preview
This commit is contained in:
@@ -18,6 +18,10 @@ backup containing every affected audio file plus the Serato metadata snapshot,
|
|||||||
then installs all compatibility shortcuts as one rollback unit. Any validation
|
then installs all compatibility shortcuts as one rollback unit. Any validation
|
||||||
or filesystem failure prevents or rolls back the batch.
|
or filesystem failure prevents or rolls back the batch.
|
||||||
|
|
||||||
|
The dry-run opens in a closable modal that names every selected keeper and every
|
||||||
|
path that will become a compatibility shortcut. Closing the modal preserves the
|
||||||
|
queue position and all decisions so the DJ can continue reviewing before apply.
|
||||||
|
|
||||||
## Edge cases
|
## Edge cases
|
||||||
|
|
||||||
- The same group cannot be submitted twice.
|
- The same group cannot be submitted twice.
|
||||||
|
|||||||
@@ -360,6 +360,13 @@ def duplicate_repair_batch(
|
|||||||
result = {
|
result = {
|
||||||
"choice_count": len(plans),
|
"choice_count": len(plans),
|
||||||
"replaced": replaced,
|
"replaced": replaced,
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"keeper": str(plan.keeper),
|
||||||
|
"replaced": [str(path) for path in plan.replaced],
|
||||||
|
}
|
||||||
|
for plan in plans
|
||||||
|
],
|
||||||
"metadata_backups": len(plans[0].metadata_files),
|
"metadata_backups": len(plans[0].metadata_files),
|
||||||
"strategy": "shortcut",
|
"strategy": "shortcut",
|
||||||
"database_v2_modified": False,
|
"database_v2_modified": False,
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ const audioPreview = document.querySelector('#audio-preview');
|
|||||||
const audioPreviewName = document.querySelector('#audio-preview-name');
|
const audioPreviewName = document.querySelector('#audio-preview-name');
|
||||||
const audioPlayer = document.querySelector('#audio-player');
|
const audioPlayer = document.querySelector('#audio-player');
|
||||||
const closeAudioPreview = document.querySelector('#close-audio-preview');
|
const closeAudioPreview = document.querySelector('#close-audio-preview');
|
||||||
|
const batchPreviewModal = document.querySelector('#batch-preview-modal');
|
||||||
|
const batchPreviewSummary = document.querySelector('#batch-preview-summary');
|
||||||
|
const batchPreviewList = document.querySelector('#batch-preview-list');
|
||||||
|
const batchPreviewSafety = document.querySelector('#batch-preview-safety');
|
||||||
|
const closeBatchPreview = document.querySelector('#close-batch-preview');
|
||||||
|
const continueReviewing = document.querySelector('#continue-reviewing');
|
||||||
|
const acceptPreview = document.querySelector('#accept-preview');
|
||||||
let latestAnalysis = null;
|
let latestAnalysis = null;
|
||||||
let selectedDuplicateGroup = null;
|
let selectedDuplicateGroup = null;
|
||||||
let previewedRepair = null;
|
let previewedRepair = null;
|
||||||
@@ -191,12 +198,23 @@ previewRepairButton.addEventListener('click', async () => {
|
|||||||
repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true;
|
repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true;
|
||||||
try {
|
try {
|
||||||
previewedRepair = await requestRepair('/api/duplicates/batch/preview');
|
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>`;
|
batchPreviewSummary.textContent = `${previewedRepair.choice_count} keeper decision(s) · ${previewedRepair.replaced.length} duplicate file(s) consolidated`;
|
||||||
|
batchPreviewList.innerHTML = previewedRepair.decisions.map((decision, index) => `
|
||||||
|
<article class="preview-decision"><span>Decision ${index + 1}</span><div class="winner-path"><b>Keep</b><strong>${escapeHtml(decision.keeper.split('/').pop())}</strong><small>${escapeHtml(decision.keeper)}</small></div><div class="replaced-paths"><b>Replace with a shortcut</b>${decision.replaced.map((path) => `<small>${escapeHtml(path)}</small>`).join('')}</div></article>
|
||||||
|
`).join('');
|
||||||
|
batchPreviewSafety.textContent = `${previewedRepair.metadata_backups} Serato metadata file(s) and every replaced audio file will be backed up. Database V2 will not be modified.`;
|
||||||
|
repairPreview.innerHTML = `<strong>Preview approved</strong><p>${previewedRepair.choice_count} keeper decision(s) are ready for one backed-up apply.</p>`;
|
||||||
repairPreview.hidden = false; applyRepairButton.disabled = false;
|
repairPreview.hidden = false; applyRepairButton.disabled = false;
|
||||||
repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
|
repairMessage.textContent = 'Preview complete. Nothing has changed yet.';
|
||||||
|
batchPreviewModal.showModal();
|
||||||
} catch (error) { repairMessage.textContent = error.message; }
|
} catch (error) { repairMessage.textContent = error.message; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function dismissBatchPreview() { batchPreviewModal.close(); }
|
||||||
|
closeBatchPreview.addEventListener('click', dismissBatchPreview);
|
||||||
|
continueReviewing.addEventListener('click', dismissBatchPreview);
|
||||||
|
acceptPreview.addEventListener('click', dismissBatchPreview);
|
||||||
|
|
||||||
applyRepairButton.addEventListener('click', async () => {
|
applyRepairButton.addEventListener('click', async () => {
|
||||||
if (!previewedRepair) return;
|
if (!previewedRepair) return;
|
||||||
applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…';
|
applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…';
|
||||||
|
|||||||
@@ -121,8 +121,15 @@
|
|||||||
<audio id="audio-player" controls preload="metadata"></audio>
|
<audio id="audio-player" controls preload="metadata"></audio>
|
||||||
<button id="close-audio-preview" type="button" aria-label="Close audio preview">×</button>
|
<button id="close-audio-preview" type="button" aria-label="Close audio preview">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<dialog id="batch-preview-modal" class="batch-preview-modal">
|
||||||
|
<div class="modal-heading"><div><p class="eyebrow">Final dry-run</p><h2>Here’s exactly what will happen</h2></div><button id="close-batch-preview" type="button" aria-label="Close preview">×</button></div>
|
||||||
|
<p id="batch-preview-summary"></p>
|
||||||
|
<div id="batch-preview-list" class="batch-preview-list"></div>
|
||||||
|
<div class="modal-safety"><span>✓</span><p><strong>Backup happens first</strong><small id="batch-preview-safety"></small></p></div>
|
||||||
|
<div class="modal-actions"><button id="continue-reviewing" type="button">Close and continue reviewing</button><button id="accept-preview" type="button">Looks right</button></div>
|
||||||
|
</dialog>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/app.js?v=5" defer></script>
|
<script src="/app.js?v=6" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -139,6 +139,164 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-preview-modal {
|
||||||
|
width: min(820px, calc(100vw - 32px));
|
||||||
|
max-height: min(82vh, 760px);
|
||||||
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(155, 135, 245, .3);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #161923;
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 30px 100px rgba(0, 0, 0, .65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-modal::backdrop {
|
||||||
|
background: rgba(4, 5, 8, .78);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-heading,
|
||||||
|
.modal-actions,
|
||||||
|
.modal-safety {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 23px;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-heading > button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#batch-preview-summary {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 390px;
|
||||||
|
margin: 18px 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-decision {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr 1fr;
|
||||||
|
gap: 13px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 13px;
|
||||||
|
background: rgba(255, 255, 255, .025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-decision > span,
|
||||||
|
.preview-decision b {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-path,
|
||||||
|
.replaced-paths {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-path strong,
|
||||||
|
.winner-path small,
|
||||||
|
.replaced-paths small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-path strong {
|
||||||
|
color: #8ce4b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-path small,
|
||||||
|
.replaced-paths small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-safety {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid rgba(102, 217, 232, .2);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(102, 217, 232, .05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-safety > span {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-safety p,
|
||||||
|
.modal-safety strong,
|
||||||
|
.modal-safety small {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-safety strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-safety small {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
background: rgba(255, 255, 255, .04);
|
||||||
|
color: var(--text);
|
||||||
|
font: 700 11px/1 inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accept-preview {
|
||||||
|
border-color: transparent;
|
||||||
|
background: #6f5bd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.preview-decision {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.file-compare-row {
|
.file-compare-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ def test_web_static_assets_are_declared_and_packaged():
|
|||||||
assert "Old crate references" in html
|
assert "Old crate references" in html
|
||||||
assert "Choose a diagnostic" in html
|
assert "Choose a diagnostic" in html
|
||||||
assert "Backup recovery" in html
|
assert "Backup recovery" in html
|
||||||
|
assert "Here’s exactly what will happen" in html
|
||||||
|
assert 'id="batch-preview-modal"' in html
|
||||||
assert 'data-detail="database_missing_tracks"' in html
|
assert 'data-detail="database_missing_tracks"' in html
|
||||||
assert 'data-detail="old_crate_references"' in html
|
assert 'data-detail="old_crate_references"' in html
|
||||||
assert html.count('class="info-button"') >= 10
|
assert html.count('class="info-button"') >= 10
|
||||||
@@ -139,4 +141,7 @@ def test_batch_preview_combines_approved_groups_without_changes(tmp_path):
|
|||||||
assert result["applied"] is False
|
assert result["applied"] is False
|
||||||
assert result["choice_count"] == 2
|
assert result["choice_count"] == 2
|
||||||
assert len(result["replaced"]) == 2
|
assert len(result["replaced"]) == 2
|
||||||
|
assert len(result["decisions"]) == 2
|
||||||
|
assert result["decisions"][0]["keeper"].endswith("First.mp3")
|
||||||
|
assert len(result["decisions"][0]["replaced"]) == 1
|
||||||
assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)
|
assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)
|
||||||
|
|||||||
Reference in New Issue
Block a user